diff --git a/.gitattributes b/.gitattributes index e8ed6d8c..331c9fdc 100644 --- a/.gitattributes +++ b/.gitattributes @@ -12,5 +12,6 @@ tools/owned_vm_request.cs text eol=lf tools/run_vm_request_queue.ps1 text eol=lf tools/fixtures/vm_request_*.ps1 text eol=lf third_party/conpty/LICENSE text eol=lf +third_party/qt/LICENSES/LGPL-3.0-only.txt text eol=lf third_party/msvc_runtime/NOTICE.txt text eol=lf experiments/core/fixtures/*.bin binary diff --git a/.github/workflows/qt-cpp.yml b/.github/workflows/qt-cpp.yml index 0ad6c6ed..8a12dbab 100644 --- a/.github/workflows/qt-cpp.yml +++ b/.github/workflows/qt-cpp.yml @@ -20,7 +20,7 @@ jobs: with: python-version: '3.12' - name: Verify preparation failure contracts - run: python -m unittest tools.test_prepare_qt_build tools.test_prepare_msvc_runtime tools.test_msvc_runtime -v + run: python -m unittest tools.test_prepare_qt_build tools.test_prepare_msvc_runtime tools.test_msvc_runtime tools.test_stage_qt_product -v - name: Prepare manifest-pinned Qt qtbase, Microsoft ConPTY and CRT run: python -m tools.prepare_qt_build - name: Preserve path-free dependency preparation identity @@ -43,6 +43,28 @@ jobs: "-DTERMFORGE_MSVC_RUNTIME_DIR=$dependencyRoot/crt" - name: Build Qt product and independent smoke run: cmake --build build/qt-cpp --config Release --target termforge_qt_shell qt_cpp_shell_smoke qt_cpp_resize_smoke qt_unicode_input_test qt_unicode_render_test qt_eight_session_test qt_terminal_modes_test qt_hidden_retention_test qt_history_navigation_test qt_selection_migration_test qt_find_test qt_scrollbar_size_test qt_clipboard_system_test --parallel 2 + - name: Stage the frozen Qt product and verify every file identity + run: | + python -m tools.stage_qt_product stage ` + --bin-dir build/qt-cpp/qt-bin/Release ` + --dependencies local/qt-build-deps ` + --dependency-summary local/qt-build-preparation.json ` + --out local/qt-product-staging ` + --summary local/qt-product-staging.json + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + python -m tools.stage_qt_product verify --staging local/qt-product-staging + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Preserve path-free product staging identities + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: qt-cpp-product-staging + retention-days: 14 + if-no-files-found: warn + path: | + local/qt-product-staging.json + local/qt-product-staging/product-manifest.json + local/qt-product-staging/THIRD-PARTY-NOTICES.txt - name: Record complete path-free runtime manifests run: | python tools/msvc_runtime.py manifest --directory build/qt-cpp/qt-bin/Release --executable termforge_qt_shell.exe diff --git a/CMakeLists.txt b/CMakeLists.txt index cd178ad8..9929279d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -683,8 +683,14 @@ if(TERMFORGE_BUILD_QT_SHELL) "$/assets/fonts" VERBATIM) add_dependencies(${target} ${target}_font_bundle) + # The system D3D compiler and Microsoft DXC redistributables are not part + # of the fixed, pinned product set: the Widgets raster entry does not link + # them, and windeployqt would otherwise copy them from the developer's + # Windows SDK/system directories. Product staging verifies the remaining + # files against the pinned SDK; see tools/stage_qt_product.py. add_custom_command(TARGET ${target} POST_BUILD COMMAND "${qt_deploy_tool}" --release --no-translations --no-compiler-runtime + --no-system-d3d-compiler --no-system-dxc-compiler "$" VERBATIM) endfunction() diff --git a/assets/fonts/README.md b/assets/fonts/README.md index a0cf8ae6..fe32f1cc 100644 --- a/assets/fonts/README.md +++ b/assets/fonts/README.md @@ -9,7 +9,7 @@ | `RobotoMono-Regular.ttf` | [fonts/ttf/RobotoMono-Regular.ttf](https://raw.githubusercontent.com/googlefonts/RobotoMono/895ec691990d041dd727c7b5afa3ce56525d98e6/fonts/ttf/RobotoMono-Regular.ttf) | 125748 | `af0bff7599c3df3831755c16e39b3c496df74b8c8d8a1161b14dc8461be17cb4` | | `OFL.txt` | [OFL.txt](https://raw.githubusercontent.com/googlefonts/RobotoMono/895ec691990d041dd727c7b5afa3ce56525d98e6/OFL.txt) | 4395 | `50ab8dd54680d3473f649c9db86fece88434d097c7834475c1c72d2f8c429215` | -版权所有者与许可见随附 [OFL.txt](OFL.txt)。该文件授权字体随软件分发,要求保留版权声明及完整许可,字体本身继续适用 SIL Open Font License 1.1。TermForge 的 MIT 许可不覆盖此字体。字体和许可均按上游原始字节保存,没有子集化、转换或改名。 +版权所有者与许可见随附 [OFL.txt](OFL.txt)。该文件授权字体随软件分发,要求保留版权声明及完整许可,字体本身继续适用 SIL Open Font License 1.1。TermForge 的 MIT 许可不覆盖此字体。字体和许可均按上游原始字节保存,没有子集化、转换或改名。机器可读的身份、上游固定提交与许可映射见 [manifest.json](manifest.json);构建和产品 staging 以该文件校验字体与许可的原始字节,本文不作为许可依据。 构建不访问网络。Windows `termforge_shell` 的构建步骤把本目录的字体、许可与本文复制到可执行文件旁的 `assets/fonts/`;分发可执行文件时须一并保留该目录。`WorkspaceFont` 从可执行文件目录解析路径,以 [`AddFontResourceExW(..., FR_PRIVATE, ...)`](https://learn.microsoft.com/en-us/windows/win32/api/wingdi/nf-wingdi-addfontresourceexw)仅对本进程加载,释放 HFONT 后卸载。字体不存在、加载失败或 [`GetTextFaceW`](https://learn.microsoft.com/en-us/windows/win32/api/wingdi/nf-wingdi-gettextfacew) 返回的实际 face 不是 `Roboto Mono` 时明确失败,不静默替换。 diff --git a/assets/fonts/manifest.json b/assets/fonts/manifest.json new file mode 100644 index 00000000..dcb9e066 --- /dev/null +++ b/assets/fonts/manifest.json @@ -0,0 +1,36 @@ +{ + "schema": "termforge-font-resource-v1", + "name": "Roboto Mono", + "style": "Regular (static)", + "scope": "Workspace UI font for the C++/Qt entry, loaded privately per process. It is not the terminal grid font and not a claim of WindTerm font parity.", + "upstream": { + "repository": "https://github.com/googlefonts/RobotoMono", + "fixed_commit": "895ec691990d041dd727c7b5afa3ce56525d98e6", + "acquired": "2026-10-23", + "note": "The unmodified static Regular font and the same-commit license are kept as the upstream original bytes. No subsetting, conversion or renaming is applied." + }, + "license": { + "spdx": "OFL-1.1", + "bundled_path": "OFL.txt", + "upstream_url": "https://raw.githubusercontent.com/googlefonts/RobotoMono/895ec691990d041dd727c7b5afa3ce56525d98e6/OFL.txt" + }, + "files": { + "RobotoMono-Regular.ttf": { + "bytes": 125748, + "sha256": "af0bff7599c3df3831755c16e39b3c496df74b8c8d8a1161b14dc8461be17cb4", + "upstream_path": "fonts/ttf/RobotoMono-Regular.ttf" + }, + "OFL.txt": { + "bytes": 4395, + "sha256": "50ab8dd54680d3473f649c9db86fece88434d097c7834475c1c72d2f8c429215", + "upstream_path": "OFL.txt" + } + }, + "deployment": { + "source_directory": "assets/fonts", + "staged_directory": "assets/fonts", + "additional_documentation": "README.md", + "required": true, + "note": "Product staging requires the pinned font and license, verifies the build copy is byte-identical to this source tree, and also carries README.md as provenance documentation." + } +} diff --git a/docs/FEATURES.md b/docs/FEATURES.md index d40c5f7b..c3f0244e 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -41,7 +41,7 @@ `DA1` 当前只在 strict/replay 路径回复 `ESC[?6c`;recovery 产品路径保持静默。`?1004`/`?9001` 与单参数 `CSI 1t/2t/3t` 接受后忽略,不代表焦点上报、Win32 输入模式或窗口操作已实现。`?2004`、鼠标模式、DA2 和 OSC 10/11 仍缺;详见 [#28](https://github.com/JavaZeroo/termforge/issues/28)。 -Qt 构建显式准备并部署[固定微软 CRT 14.51.36247.0](../third_party/msvc_runtime/README.md),不安装到 System32。隔离 Windows 的[限定加载与 cmd 验证](../experiments/shell/results/2026-10-29-qt-crt-deploy/README.md)保留原包失败、原 52 文件不变且仅追加 CRT/原许可后的成功,以及新源码包的独立验证;它不解除完整产品打包、Qt/第三方分发审计、实际 DPI/IME 或物理呈现的验收缺口。 +Qt 构建显式准备并部署[固定微软 CRT 14.51.36247.0](../third_party/msvc_runtime/README.md),不安装到 System32。隔离 Windows 的[限定加载与 cmd 验证](../experiments/shell/results/2026-10-29-qt-crt-deploy/README.md)保留原包失败、原 52 文件不变且仅追加 CRT/原许可后的成功,以及新源码包的独立验证;它不解除完整产品打包、Qt/第三方分发审计、实际 DPI/IME 或物理呈现的验收缺口。正式入口现有 [`packaging/qt-product/manifest.json`](../packaging/qt-product/manifest.json) 冻结的工程 staging:只接收固定依赖准备摘要和逐文件字节都匹配的构建目录,输出路径无关的逐文件 SHA-256 与许可索引,并拒绝测试夹具或未固定来源;它仍不是 R2 包,干净 Windows 运行、96/144 DPI、完整 Qt 第三方许可清单与最终验收未完成。 官方参考的基础文字行为已按 [R1-01 合同](research/windterm-r1-text-contract.md)完成两组各两轮的自动取证;这为独立实现提供行为依据,产品能力仍按下表分别验收。 @@ -92,7 +92,7 @@ Qt 构建显式准备并部署[固定微软 CRT 14.51.36247.0](../third_party/ms | 八会话文字工作流 | **限定验收**:[R1 固定源码整体验收](../experiments/shell/results/2026-10-24-r1-system-workflow/validation.json)覆盖同次 8 会话/拒绝第 9 个、后台有限洪流、历史/Find/实际选择与 CF 复制/确认粘贴/真实拼音、切换缩放、淘汰、取消关闭和重复回收;实际字节、目标隔离、动作与 Job/desktop 回收有独立审计 | 限定 Windows 固定拼音、本地 cmd 与有界负载;这是固定 R1 配置的组合验收,不能替代 R2 候选/新后端组合。实际呈现、常用 TUI、长期稳定及 UI/性能等价仍未验收 | R1 | | 活体资源与滚动端点 | **限定测量**:普通启动默认关闭;[R1 组合审计](../experiments/shell/results/2026-10-24-r1-system-workflow/validation.json)绑定实际接收、state 与同 revision 的 WM_PAINT/BitBlt/GdiFlush 提交,保留跨时钟校准与阶段资源,可离线重算 | launcher-tree 不等于 UI 归因;观察进程成本不等于增量采样成本,短命进程及退出 CPU 可能漏采。短驻留不证明长期无泄漏,`presented_done=null`,不构成性能通过 | R1;扩展 R4 / R5 | | 自动正确性与构建 | **限定验收**:Windows shell/ConPTY/呈现及 Windows/Linux 核心 CI、差分/恢复/队列/生命周期场景 | 产品打包、干净机器使用、依赖升级、长期回归和更广平台矩阵;#40 的 CI 纯夹具超时、窄窗口选择 #73、四 shell 原事件 #80、hosted 准备 #81 和[复制夹具 OpenClipboard 5 #85](https://github.com/JavaZeroo/termforge/issues/85) 等原失败已按各责任项记录修正或用户接受的未复现收口;当前开放的是 #44/#80/#99,由 #38 汇总;#72 保存判据与 #80 同步条件的限定修正不代替其他配置或未知根因验收 | R2 / R4 | -| Windows 产品交付 | **实现中,未验收**:首个 [v0.1.0-alpha.1 预发布](releases/v0.1.0-alpha.1.md)提供 Windows x64、静态 MSVC 运行库、附资源许可和构建身份的免安装 ZIP;未签名,已验环境限 Windows 11 本机与 Server 2025 CI,布局仍写入 LOCALAPPDATA | 这是旧 Win32 路线的 R1 体验包,尚非正式 Qt 路线的 R2 基础开发包;干净机器与更广平台矩阵、签名/安装、配置升级及持续运行仍待完成。Windows 10 未验收,Server 2022 句柄基线已按 ADR-0009 改记录制,不代表产品在该平台验收 | R2 / R3 | +| Windows 产品交付 | **实现中,未验收**:首个 [v0.1.0-alpha.1 预发布](releases/v0.1.0-alpha.1.md)提供 Windows x64、静态 MSVC 运行库、附资源许可和构建身份的免安装 ZIP;正式 Qt 路线已有可复现的工程 staging/清单入口(见上),但没有正式 Qt 产品包。两者均未签名,已验环境限 Windows 11 本机与 Server 2025 CI,布局仍写入 LOCALAPPDATA | 旧 Win32 体验包不是正式 Qt 路线的 R2 基础开发包;staging 也不等于产品包或验收。干净机器与更广平台矩阵、Qt 第三方分发审计、签名/安装、配置升级及持续运行仍待完成。Windows 10 未验收,Server 2022 句柄基线已按 ADR-0009 改记录制,不代表产品在该平台验收 | R2 / R3 | | Linux/macOS 与其他架构 | **未实现**客户端;Linux 核心 CI 已有。[官方平台范围台账](../research/windterm-2.7.0-platform-scope.md)已固定 2.7.0 的 Windows x86/x64、macOS x64、Linux x64 资产及声明下限;这不是 TermForge 支持或实测结论 | 最终需真实 Linux/macOS 客户端;平台调查可提前进行,整合及完整验收排在 Windows 11 x64 完整版本之后。其他系统版本、最低版本和架构由用户后续明确;旧系统及 ARM 等保持调查记录,不自动承诺或排除,也不能由核心 CI 推断产品支持 | 持续调查;R5 全平台出口 | | 吞吐、响应、内存与稳定性 | **实现中,未验收**:已有机制实验、连续 CPU 区间校准与合成回归;候选字素改动的受限诊断中 project/interactive 吞吐中位数约下降 16%/9%,由 [#54](https://github.com/JavaZeroo/termforge/issues/54) 跟踪。[冻结批次执行器](../experiments/measure/R54-COST.md#冻结批次执行器windows)核对构建来源、原始输出、停止规则与逐轮配对统计;[独立 hosted 构建与实测](../experiments/measure/results/2026-10-27-r54-hosted/README.md)已验证来源和失败保留,但首批因环境护栏停止,尚无完整成本批次;[共享宿主CPU诊断](../experiments/measure/results/2026-10-27-r54-cpu-boundary/README.md)及[新hosted六位置](../experiments/measure/results/2026-10-27-r54-cpu-hosted/README.md)分别记录完整子进程/执行器分量;后者仍有3.333%–5.263%未归属余额,不改原护栏或判成本通过 | 旧诊断的共享宿主、allocator 包装与缺少真实呈现/输入时延端点使其不构成正式预算;新的不完整批次也不作吞吐结论。仍需同负载/同正确性、无插桩对照、观测开销和长期/多会话公平性;可靠原版基线后提出逐项预算及允许波动范围,**由用户明确确认**。与 WindTerm 的实际差距**待取证** | 当前优先;贯穿 R1–R5 | | 大历史与动态内存压缩 | **未实现**产品能力;WindTerm 压缩属于官方性能声明,内部机制尚未证实 | 1/10/100 万行等明确规模下的内存/滚动/搜索/淘汰实验;根据瓶颈决定独立存储/压缩实现 | R4 | diff --git a/packaging/qt-product/manifest.json b/packaging/qt-product/manifest.json new file mode 100644 index 00000000..77a472c4 --- /dev/null +++ b/packaging/qt-product/manifest.json @@ -0,0 +1,114 @@ +{ + "schema": "termforge-qt-product-staging-contract-v1", + "scope": "Frozen Windows x64 C++/Qt/ConPTY product staging inputs for the termforge_qt_shell entry. This is an engineering candidate: not a released package, not an accepted R2 deliverable and not a redistribution clearance.", + "entry_executable": "termforge_qt_shell.exe", + "platform": "windows-x64", + "configuration": "Release", + "staging_entry": "python -m tools.stage_qt_product stage", + "build": { + "cmake_options": [ + "-DTERMFORGE_BUILD_QT_SHELL=ON", + "-DTERMFORGE_BUILD_CANDIDATE_ADAPTER=OFF" + ], + "windeployqt_flags": [ + "--release", + "--no-translations", + "--no-compiler-runtime", + "--no-system-d3d-compiler", + "--no-system-dxc-compiler" + ], + "note": "The Qt SDK, fixed ConPTY pair and fixed CRT are prepared by tools.prepare_qt_build. BUILD_TESTING only adds separate test output directories; no test executable belongs to this product staging." + }, + "qt_deployed": { + "libraries": [ + "Qt6Core.dll", + "Qt6Gui.dll", + "Qt6Network.dll", + "Qt6Widgets.dll" + ], + "plugins": [ + "generic/qtuiotouchplugin.dll", + "imageformats/qgif.dll", + "imageformats/qico.dll", + "imageformats/qjpeg.dll", + "networkinformation/qnetworklistmanager.dll", + "platforms/qwindows.dll", + "styles/qmodernwindowsstyle.dll", + "tls/qcertonlybackend.dll", + "tls/qschannelbackend.dll" + ], + "source_layout": "Libraries must be byte-identical to /bin/; plugins must be byte-identical to /plugins/." + }, + "os_provided": [ + { + "name": "icuuc.dll", + "provider": "Microsoft Windows 11 System32 ICU common forwarder", + "deployment": "excluded_not_bundled", + "reason": "Qt6Core imports icuuc.dll, and windeployqt copies the developer machine's System32 forwarder into the build directory. The staged product must not redistribute or pin that OS component; the Windows 11 target supplies it at run time.", + "verification": "Staging requires the build copy to be byte-identical to the running system's System32 copy before excluding it. A copy from another source fails explicitly instead of being silently dropped." + } + ], + "excluded_system_redistributables": [ + { + "name": "D3Dcompiler_47.dll", + "reason": "windeployqt --no-system-d3d-compiler; the Windows system D3D compiler is not pinned or redistributed in this package." + }, + { + "name": "dxcompiler.dll", + "reason": "windeployqt --no-system-dxc-compiler; the Microsoft DXC out-of-band redistributable is not pinned, and the Widgets raster entry does not link it." + }, + { + "name": "dxil.dll", + "reason": "windeployqt --no-system-dxc-compiler; the Microsoft DXC out-of-band redistributable is not pinned, and the Widgets raster entry does not link it." + } + ], + "license_evidence": { + "termforge": { + "spdx": "MIT", + "files": [ + "LICENSE" + ] + }, + "qt": { + "license_manifest": "third_party/qt/manifest.json", + "bundled_texts": [ + "licenses/LGPL-3.0-only.txt" + ], + "status": "third_party_component_inventory_pending" + }, + "conpty": { + "license_manifest": "third_party/conpty/manifest.json", + "files": [ + "conpty-backend/LICENSE", + "conpty-backend/manifest.json" + ], + "status": "bundled" + }, + "msvc_runtime": { + "license_manifest": "third_party/msvc_runtime/manifest.json", + "files": [ + "LICENSE-MICROSOFT-VC-REDIST.docx", + "NOTICE.txt" + ], + "status": "bundled" + }, + "font": { + "license_manifest": "assets/fonts/manifest.json", + "files": [ + "assets/fonts/OFL.txt" + ], + "status": "bundled" + } + }, + "test_fixture_policy": "Staging admits only this contract and the pinned dependency manifests. Test executables (qt_*_test, qt_cpp_* smoke, shell_*_client), debug/link/log artifacts and any other unlisted file are rejected rather than copied.", + "ignored_build_artifacts": { + "runtime-manifest.json": "Derived inventory written into the same directory by tools/msvc_runtime.py manifest; it is evidence output, never part of the product staging." + }, + "claims": { + "r2_acceptance": false, + "clean_machine_run": "not_performed", + "single_screen_96_144_dpi": "not_verified_in_this_slice", + "full_third_party_license_inventory": "pending", + "performance_equivalence": false + } +} diff --git a/src/qt/README.md b/src/qt/README.md index 3b80a659..f3df1ea3 100644 --- a/src/qt/README.md +++ b/src/qt/README.md @@ -30,9 +30,23 @@ build/qt-cpp/qt-bin/Release/termforge_qt_shell.exe ctest --test-dir build/qt-cpp -C Release -R '^(qt_cpp_(shell|resize)|qt_unicode_input|qt_unicode_render|qt_eight_session|qt_terminal_modes(_retained)?|qt_hidden_retention)$' --output-on-failure ``` +生成可核对的工程候选 staging(仍不是 R2 包,也不发布或执行产品): + +```powershell +python -m tools.stage_qt_product stage ` + --bin-dir build/qt-cpp/qt-bin/Release ` + --dependencies local/qt-build-deps ` + --dependency-summary local/qt-build-preparation.json ` + --out local/qt-product-staging ` + --summary local/qt-product-staging.json +python -m tools.stage_qt_product verify --staging local/qt-product-staging +``` + +staging 只接收 [`packaging/qt-product/manifest.json`](../../packaging/qt-product/manifest.json) 冻结的文件集合,并要求依赖准备摘要、固定 Qt SDK 树哈希、ConPTY/CRT/字体许可标识和每个实际文件字节都匹配;缺文件、测试夹具、额外文件、来源不符或摘要与当前仓库清单不一致都会明确失败。输出 `product-manifest.json` 记录 source commit/tree、构建参数、入口、逐文件 SHA-256、许可文件与仍未闭合的分发项,不含本机绝对路径;同目录由 `tools/msvc_runtime.py manifest` 生成的 `runtime-manifest.json` 视为派生证据,不进入 staging。本地产物只写入忽略的 `local/`。 + 当前主线注册的 Qt CTest 包括上述六项与 `qt_terminal_modes_retained`(只缩宽的保留/投影契约)、`qt_unicode_render`(固定 Unicode 样本的独立接收 + 离屏绘制/fallback 记录)、`qt_eight_session`(八会话目标隔离与生命周期);IME 合成事件检查在 `qt_cpp_shell` 内。历史冻结包的 `qt_ime_input_test.exe` 不属于当前主线构建目标,不用于替代这组验证。旧六阶段 `--resize` 仍以显式诊断运行并保留失败,不注册进默认 CI。 -固定 ConPTY/CRT 输入缺失或篡改时构建明确失败;实际工具集更新到固定 CRT 之外时须重新审查依赖。产品中的 shell 启动失败保留可关闭的错误窗口。随目录保留 Qt 动态运行库、平台插件、`conpty-backend/`、`assets/fonts/`、十个固定 CRT DLL 及其原许可/NOTICE;每次构建重新校验并部署 CRT,不运行安装器、不修改 System32。最终目录用 `tools/msvc_runtime.py manifest` 记录全部文件。当前只是本地构建输出,Qt/第三方的完整分发审计及产品打包尚未完成。 +固定 ConPTY/CRT 输入缺失或篡改时构建明确失败;实际工具集更新到固定 CRT 之外时须重新审查依赖。产品中的 shell 启动失败保留可关闭的错误窗口。随目录保留 Qt 动态运行库、平台插件、`conpty-backend/`、`assets/fonts/`、十个固定 CRT DLL 及其原许可/NOTICE;每次构建重新校验并部署 CRT,不运行安装器、不修改 System32。windeployqt 显式跳过系统 D3D/DXC 重分发文件;`icuuc.dll` 由 Windows 11 提供,构建目录中由 windeployqt 从 System32 复制的副本在 staging 核对一致后被排除,不随包分发。`tools/msvc_runtime.py manifest` 仍可记录构建目录的全部运行时文件。当前产物是工程候选 staging,不是 R2 包:干净 Windows 运行、单屏 96/144 DPI、完整 IME/TUI/reliability、Qt 第三方分发审计及最终包验收仍未完成。 ## 独立验证面 diff --git a/third_party/qt/LICENSES/LGPL-3.0-only.txt b/third_party/qt/LICENSES/LGPL-3.0-only.txt new file mode 100644 index 00000000..65c5ca88 --- /dev/null +++ b/third_party/qt/LICENSES/LGPL-3.0-only.txt @@ -0,0 +1,165 @@ + GNU LESSER GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + + This version of the GNU Lesser General Public License incorporates +the terms and conditions of version 3 of the GNU General Public +License, supplemented by the additional permissions listed below. + + 0. Additional Definitions. + + As used herein, "this License" refers to version 3 of the GNU Lesser +General Public License, and the "GNU GPL" refers to version 3 of the GNU +General Public License. + + "The Library" refers to a covered work governed by this License, +other than an Application or a Combined Work as defined below. + + An "Application" is any work that makes use of an interface provided +by the Library, but which is not otherwise based on the Library. +Defining a subclass of a class defined by the Library is deemed a mode +of using an interface provided by the Library. + + A "Combined Work" is a work produced by combining or linking an +Application with the Library. The particular version of the Library +with which the Combined Work was made is also called the "Linked +Version". + + The "Minimal Corresponding Source" for a Combined Work means the +Corresponding Source for the Combined Work, excluding any source code +for portions of the Combined Work that, considered in isolation, are +based on the Application, and not on the Linked Version. + + The "Corresponding Application Code" for a Combined Work means the +object code and/or source code for the Application, including any data +and utility programs needed for reproducing the Combined Work from the +Application, but excluding the System Libraries of the Combined Work. + + 1. Exception to Section 3 of the GNU GPL. + + You may convey a covered work under sections 3 and 4 of this License +without being bound by section 3 of the GNU GPL. + + 2. Conveying Modified Versions. + + If you modify a copy of the Library, and, in your modifications, a +facility refers to a function or data to be supplied by an Application +that uses the facility (other than as an argument passed when the +facility is invoked), then you may convey a copy of the modified +version: + + a) under this License, provided that you make a good faith effort to + ensure that, in the event an Application does not supply the + function or data, the facility still operates, and performs + whatever part of its purpose remains meaningful, or + + b) under the GNU GPL, with none of the additional permissions of + this License applicable to that copy. + + 3. Object Code Incorporating Material from Library Header Files. + + The object code form of an Application may incorporate material from +a header file that is part of the Library. You may convey such object +code under terms of your choice, provided that, if the incorporated +material is not limited to numerical parameters, data structure +layouts and accessors, or small macros, inline functions and templates +(ten or fewer lines in length), you do both of the following: + + a) Give prominent notice with each copy of the object code that the + Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the object code with a copy of the GNU GPL and this license + document. + + 4. Combined Works. + + You may convey a Combined Work under terms of your choice that, +taken together, effectively do not restrict modification of the +portions of the Library contained in the Combined Work and reverse +engineering for debugging such modifications, if you also do each of +the following: + + a) Give prominent notice with each copy of the Combined Work that + the Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the Combined Work with a copy of the GNU GPL and this license + document. + + c) For a Combined Work that displays copyright notices during + execution, include the copyright notice for the Library among + these notices, as well as a reference directing the user to the + copies of the GNU GPL and this license document. + + d) Do one of the following: + + 0) Convey the Minimal Corresponding Source under the terms of this + License, and the Corresponding Application Code in a form + suitable for, and under terms that permit, the user to + recombine or relink the Application with a modified version of + the Linked Version to produce a modified Combined Work, in the + manner specified by section 6 of the GNU GPL for conveying + Corresponding Source. + + 1) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (a) uses at run time + a copy of the Library already present on the user's computer + system, and (b) will operate properly with a modified version + of the Library that is interface-compatible with the Linked + Version. + + e) Provide Installation Information, but only if you would otherwise + be required to provide such information under section 6 of the + GNU GPL, and only to the extent that such information is + necessary to install and execute a modified version of the + Combined Work produced by recombining or relinking the + Application with a modified version of the Linked Version. (If + you use option 4d0, the Installation Information must accompany + the Minimal Corresponding Source and Corresponding Application + Code. If you use option 4d1, you must provide the Installation + Information in the manner specified by section 6 of the GNU GPL + for conveying Corresponding Source.) + + 5. Combined Libraries. + + You may place library facilities that are a work based on the +Library side by side in a single library together with other library +facilities that are not Applications and are not covered by this +License, and convey such a combined library under terms of your +choice, if you do both of the following: + + a) Accompany the combined library with a copy of the same work based + on the Library, uncombined with any other library facilities, + conveyed under the terms of this License. + + b) Give prominent notice with the combined library that part of it + is a work based on the Library, and explaining where to find the + accompanying uncombined form of the same work. + + 6. Revised Versions of the GNU Lesser General Public License. + + The Free Software Foundation may publish revised and/or new versions +of the GNU Lesser General Public License from time to time. Such new +versions will be similar in spirit to the present version, but may +differ in detail to address new problems or concerns. + + Each version is given a distinguishing version number. If the +Library as you received it specifies that a certain numbered version +of the GNU Lesser General Public License "or any later version" +applies to it, you have the option of following the terms and +conditions either of that published version or of any later version +published by the Free Software Foundation. If the Library as you +received it does not specify a version number of the GNU Lesser +General Public License, you may choose any version of the GNU Lesser +General Public License ever published by the Free Software Foundation. + + If the Library as you received it specifies that a proxy can decide +whether future versions of the GNU Lesser General Public License shall +apply, that proxy's public statement of acceptance of any version is +permanent authorization for you to choose that version for the +Library. diff --git a/third_party/qt/README.md b/third_party/qt/README.md index d43b2cc7..7fcac190 100644 --- a/third_party/qt/README.md +++ b/third_party/qt/README.md @@ -6,7 +6,7 @@ manifest 固定了 Qt 官方 Updates.xml 和原始 qtbase 7z 的 URL、大小与哈希。官方包名为 `qt.qt6.6103.win64_msvc2022_64`,归档大小 **42,748,757 B**,SHA-256 为 `4db84dee7fe3c558f242bef0a88852613af76580dc6d2b24596479f47004dad7`;本次整理已重新核对本地原始归档及 Updates.xml。 -对应源码为 [qtbase 6.10.3 源码归档](https://download.qt.io/archive/qt/6.10/6.10.3/submodules/qtbase-everywhere-src-6.10.3.tar.xz)及 Qt 官方仓库的 `v6.10.3`。这里只标识源码获取位置,没有把源码归档下载、哈希复核或完整对应源码交付算作已完成。 +对应源码为 [qtbase 6.10.3 源码归档](https://download.qt.io/archive/qt/6.10/6.10.3/submodules/qtbase-everywhere-src-6.10.3.tar.xz)及 Qt 官方仓库的 `v6.10.3`。本次为提取随包许可文本下载并测量了该源码归档:**50,509,856 B**,SHA-256 `383dc907816338f0cba72088a524c07458dfc69ce684ca9132fcc4fe91c24b0b`;归档本身不提交、不随产品分发。产品的完整对应源码交付条件仍属于分发审计,不因本记录完成。 最初本地准备使用 aqtinstall 3.3.0,会修补 qmake 和 `.prl` 的安装前缀。下面的自动复建直接解压同一官方归档,仅写相对的 `bin/qt.conf`(`Prefix=..`),保留原始 qmake 与 `.prl` 字节。**原始归档、两种准备方式得到的 SDK、最终部署目录是不同身份**,不能用归档哈希冒充 SDK 或产品目录的完整哈希;具体构建结果按各次证据绑定实际输入和输出。 @@ -45,8 +45,14 @@ ctest --test-dir build/qt-cpp -C Release -R '^qt_cpp_(shell|resize)$' --no-tests CI 的 `windows-2025` 已实际解析到 `windows-2025-vs2026`,该映像没有 VS 2022 实例;因此工作流显式使用 [CMake 的 VS 2026 generator](https://cmake.org/cmake/help/latest/generator/Visual%20Studio%2018%202026.html),要求 CMake 4.2 或更新。Qt SDK 仍是固定的 MSVC 2022 x64 包,不把 SDK 名称改称 VS 2026。[微软的二进制兼容说明](https://learn.microsoft.com/en-us/cpp/porting/binary-compat-2015-2017?view=msvc-170)允许较新链接器消费较早工具链构建的动态库,但实际兼容性仍由这次独立构建和会话检查证明;本地 VS 2022 验证与 hosted VS 2026 验证分别记录。 +## 产品 staging 与部署边界 + +`windeployqt` 调用显式带 `--no-system-d3d-compiler --no-system-dxc-compiler`:系统 D3D 编译器与微软 DXC OOB 重分发文件既不在本仓库固定依赖中,也不是当前 Widgets 光栅入口静态链接的依赖,因此不从开发机 Windows SDK/System32 借用。`Qt6Core.dll` 静态导入的 `icuuc.dll` 由 Windows 11 System32 提供;构建目录中由 windeployqt 复制的副本只作为来源证据核对(必须与 System32 字节一致),staging 排除它而不随包分发。 + +[`packaging/qt-product/manifest.json`](../../packaging/qt-product/manifest.json) 冻结正式入口实际部署的 Qt 库/插件集合、OS 提供项、被排除的系统重分发文件与许可证据。`python -m tools.stage_qt_product stage` 只接受该清单、当前固定依赖准备摘要、固定 SDK 树哈希与逐文件字节都匹配的构建目录,输出路径无关的 `product-manifest.json`(含 source commit/tree、构建参数、入口、逐文件 SHA-256 和许可证据);`verify` 子命令可重新核对已生成 staging 的文件集合与哈希。该 staging 是工程候选,不是 R2 包:干净 Windows 运行、单屏 96/144 DPI、完整第三方许可清单及最终包验收均未完成。 + ## 许可与分发边界 -Qt 模块保留自己的许可,不由项目 MIT 许可覆盖。[Qt 6.10 许可说明](https://doc.qt.io/qt-6.10/licensing.html)与 SDK SBOM/头文件显示,当前 Core、Gui、Widgets、Test 的模块许可选项包含 LGPL-3.0-only,以及相应 GPL/商业许可选项;manifest 保留精确 SPDX 表达式及实际 SBOM 身份。当前应用计划采用**动态链接的 LGPLv3 路径**,这不是对完整产品分发条件已经满足的声明。 +Qt 模块保留自己的许可,不由项目 MIT 许可覆盖。[Qt 6.10 许可说明](https://doc.qt.io/qt-6.10/licensing.html)与 SDK SBOM/头文件显示,当前 Core、Gui、Widgets、Test 的模块许可选项包含 LGPL-3.0-only,以及相应 GPL/商业许可选项;manifest 保留精确 SPDX 表达式及实际 SBOM 身份。当前应用计划采用**动态链接的 LGPLv3 路径**,这不是对完整产品分发条件已经满足的声明。仓库随附从固定 qtbase 6.10.3 源码归档 `LICENSES/LGPL-3.0-only.txt` 提取的许可原文([LICENSES/LGPL-3.0-only.txt](LICENSES/LGPL-3.0-only.txt)),staging 会把该文本连同其归档成员身份与 SHA-256 一起带入产品目录。 -Qt 内还包含使用其他许可的第三方代码;不能仅凭模块级许可表达式代替实际部署文件清单。产品分发前仍须完成运行库/插件及第三方许可声明、对应源码与替换条件的核对,以及干净机器运行验证;当前记录只支持本地构建依赖的来源与身份。 +Qt 内还包含使用其他许可的第三方代码;不能仅凭模块级许可表达式代替实际部署文件清单。产品分发前仍须完成运行库/插件及第三方许可声明、对应源码与替换条件的核对,以及干净机器运行验证;当前记录只支持本地构建依赖的来源与身份。staging 的许可索引将该项明确标为 `third_party_component_inventory_pending`,不将其写成已完成的再分发结论。 diff --git a/third_party/qt/manifest.json b/third_party/qt/manifest.json index ad2b6736..6976f8e5 100644 --- a/third_party/qt/manifest.json +++ b/third_party/qt/manifest.json @@ -36,9 +36,10 @@ "version_tag": "v6.10.3", "repository": "https://code.qt.io/qt/qtbase.git", "source_archive_url": "https://download.qt.io/archive/qt/6.10/6.10.3/submodules/qtbase-everywhere-src-6.10.3.tar.xz", - "source_archive_downloaded_for_this_record": false, - "source_archive_sha256": null, - "note": "The matching official source archive is identified, not included or locally hash-verified by this build-dependency record." + "source_archive_downloaded_for_this_record": true, + "source_archive_bytes": 50509856, + "source_archive_sha256": "383dc907816338f0cba72088a524c07458dfc69ce684ca9132fcc4fe91c24b0b", + "note": "The matching official source archive was downloaded and hashed on 2026-11-08 to extract the bundled GNU LGPL v3 license text. The archive itself is not committed or redistributed by this record." }, "linkage": "dynamic", "license": { @@ -86,7 +87,19 @@ "https://doc.qt.io/qt-6.10/licenses-used-in-qt.html" ], "third_party_code_scope": "Qt includes third-party components with their own licenses; the module-level expression is not a full deployed-file license inventory.", - "redistribution_review_status": "pending_before_product_distribution" + "redistribution_review_status": "pending_before_product_distribution", + "bundled_text_files": [ + { + "spdx": "LGPL-3.0-only", + "path": "LICENSES/LGPL-3.0-only.txt", + "bytes": 7651, + "sha256": "da7eabb7bafdf7d3ae5e9f223aa5bdc1eece45ac569dc21b3b037520b4464768", + "selection": "Bundled because the current product plan takes the dynamic-link LGPL-3.0-only option; this is not a completed redistribution review.", + "source_archive_url": "https://download.qt.io/archive/qt/6.10/6.10.3/submodules/qtbase-everywhere-src-6.10.3.tar.xz", + "source_archive_sha256": "383dc907816338f0cba72088a524c07458dfc69ce684ca9132fcc4fe91c24b0b", + "source_archive_member": "qtbase-everywhere-src-6.10.3/LICENSES/LGPL-3.0-only.txt" + } + ] }, "installer": { "name": "aqtinstall", diff --git a/tools/stage_qt_product.py b/tools/stage_qt_product.py new file mode 100644 index 00000000..2c58214a --- /dev/null +++ b/tools/stage_qt_product.py @@ -0,0 +1,719 @@ +"""Stage the fixed Windows x64 C++/Qt/ConPTY product from a verified build. + +Only files frozen by ``packaging/qt-product/manifest.json`` and the pinned +third-party manifests enter the staging directory. Each staged file must be +byte-identical to its prepared dependency or source-tree origin, license +evidence is carried next to the runtime files, and the resulting manifest is +path-free. This tool never downloads, executes, signs, publishes or accepts a +product package; clean-machine and R2 workflow validation remain separate. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import stat +import struct +import subprocess +import tempfile + +ROOT = Path(__file__).resolve().parents[1] +SCHEMA = "termforge-qt-product-manifest-v1" +OPERATION_SCHEMA = "termforge-qt-product-operation-v1" +CONTRACT_PATH = "packaging/qt-product/manifest.json" +PREPARATION_SCHEMA = "termforge-qt-build-preparation-v1" +ENTRY = "termforge_qt_shell.exe" +NOTICES_PATH = "THIRD-PARTY-NOTICES.txt" +MANIFEST_PATH = "product-manifest.json" +MAX_DETAIL = 8 + +if __package__: + from tools.prepare_qt_build import tree_identity +else: # pragma: no cover - direct script execution is diagnostic only + from prepare_qt_build import tree_identity + + +class StagingError(Exception): + """A fixed public code; never a raw process, network or filesystem message.""" + + +def sha256_bytes(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def sha256_path(path: Path) -> str: + with path.open("rb") as source: + return hashlib.file_digest(source, "sha256").hexdigest() + + +def read_json(path: Path) -> dict: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError): + raise StagingError("input_unreadable") from None + if not isinstance(value, dict): + raise StagingError("input_schema") + return value + + +def relative_files(directory: Path) -> list[str]: + """Sorted relative POSIX file paths, rejecting reparse points.""" + if not directory.is_dir(): + raise StagingError("input_directory") + found = [] + pending = [directory] + while pending: + for path in sorted(pending.pop().iterdir()): + try: + information = path.lstat() + except OSError: + raise StagingError("input_unreadable") from None + if stat.S_ISLNK(information.st_mode) or bool( + getattr(information, "st_file_attributes", 0) & stat.FILE_ATTRIBUTE_REPARSE_POINT): + raise StagingError("reparse_point") + if path.is_dir(): + pending.append(path) + elif path.is_file(): + found.append(path.relative_to(directory).as_posix()) + return sorted(found) + + +def pe_identity(data: bytes, executable: bool) -> dict: + """Read a file-backed PE32+ AMD64 identity without loading or executing it.""" + if len(data) < 64 or data[:2] != b"MZ": + raise StagingError("pe_header") + offset = struct.unpack_from(" len(data) or data[offset:offset + 4] != b"PE\0\0": + raise StagingError("pe_header") + machine, sections, _, _, _, optional_size, characteristics = struct.unpack_from(" len(data): + raise StagingError("pe_architecture") + magic = struct.unpack_from(" dict: + def git(*arguments: str) -> str: + try: + result = subprocess.run(["git", "-C", str(root), *arguments], check=False, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=30) + except (OSError, subprocess.TimeoutExpired): + raise StagingError("source_identity") from None + if result.returncode: + raise StagingError("source_identity") + return result.stdout.decode("utf-8", "replace").strip() + + commit = git("rev-parse", "HEAD") + tree = git("rev-parse", "HEAD^{tree}") + if not re.fullmatch(r"[0-9a-f]{40}", commit) or not re.fullmatch(r"[0-9a-f]{40}", tree): + raise StagingError("source_identity") + dirty = bool(git("status", "--porcelain", "--untracked-files=all")) + if dirty and not allow_dirty: + raise StagingError("source_dirty") + try: + timestamp = int(git("show", "-s", "--format=%ct", "HEAD")) + except ValueError: + raise StagingError("source_identity") from None + return {"commit": commit, "tree": tree, "commit_timestamp": timestamp, + "dirty": dirty, "development_override": allow_dirty} + + +def build_configuration(bin_dir: Path) -> dict: + if bin_dir.name != "Release": + raise StagingError("build_directory") + cache = next((parent / "CMakeCache.txt" for parent in (bin_dir, *bin_dir.parents[:3]) + if (parent / "CMakeCache.txt").is_file()), None) + if cache is None: + raise StagingError("build_cache_missing") + values = {} + try: + for line in cache.read_text(encoding="utf-8", errors="replace").splitlines(): + match = re.fullmatch(r"([^:#][^:]*):[^=]+=(.*)", line) + if match: + values[match[1]] = match[2] + except OSError: + raise StagingError("build_cache_missing") from None + if values.get("CMAKE_GENERATOR_PLATFORM", "").lower() != "x64": + raise StagingError("build_configuration") + if values.get("TERMFORGE_BUILD_QT_SHELL", "OFF").upper() != "ON": + raise StagingError("build_configuration") + if values.get("TERMFORGE_BUILD_CANDIDATE_ADAPTER", "ON").upper() != "OFF": + raise StagingError("build_configuration") + if "Release" not in values.get("CMAKE_CONFIGURATION_TYPES", "Release").split(";"): + raise StagingError("build_configuration") + toolset = None + linker = values.get("CMAKE_LINKER", "") + match = re.search(r"/VC/Tools/MSVC/(14[.][0-9]+[.][0-9]+)/bin/", linker.replace("\\", "/")) + if match: + toolset = match.group(1) + return {"generator": values.get("CMAKE_GENERATOR", ""), "architecture": "x64", + "configuration": "Release", "target": "termforge_qt_shell", + "qt_shell_enabled": True, "candidate_adapter_enabled": False, + "msvc_toolset_version": toolset} + + +def verify_dependency_summary(root: Path, dependencies: Path, summary_path: Path) -> dict: + summary = read_json(summary_path) + if summary.get("schema") != PREPARATION_SCHEMA or summary.get("status") != "passed": + raise StagingError("dependency_summary_invalid") + input_manifests = summary.get("input_manifests") + if not isinstance(input_manifests, dict): + raise StagingError("dependency_summary_invalid") + for name in ("qt", "conpty", "msvc_runtime"): + manifest = root / "third_party" / name / "manifest.json" + if not manifest.is_file() or input_manifests.get(name) != sha256_path(manifest): + raise StagingError("dependency_manifest_mismatch") + script = root / "tools" / "prepare_qt_build.py" + if not script.is_file() or summary.get("preparation_script_sha256") != sha256_path(script): + raise StagingError("dependency_script_mismatch") + + sdk = summary.get("sdk") or {} + if (dependencies / "qt").is_dir(): + identity = tree_identity(dependencies / "qt") + if (identity["files"] != sdk.get("files") or + identity["tree_sha256"] != sdk.get("tree_sha256")): + raise StagingError("dependency_tree_identity") + else: + raise StagingError("dependency_directory") + + conpty_manifest = read_json(root / "third_party" / "conpty" / "manifest.json") + if summary.get("qt_archive_sha256") != read_json( + root / "third_party" / "qt" / "manifest.json").get("official_binary_archive", {}).get("sha256"): + raise StagingError("dependency_manifest_identity") + if summary.get("conpty_package_sha256") != conpty_manifest.get("package_sha256"): + raise StagingError("dependency_manifest_identity") + for name, digest in (summary.get("conpty_files") or {}).items(): + path = dependencies / "conpty" / name + if not path.is_file() or sha256_path(path) != digest: + raise StagingError("dependency_conpty_identity") + if conpty_manifest.get("files", {}).get(name) != digest: + raise StagingError("dependency_manifest_identity") + + runtime = summary.get("msvc_runtime") or {} + runtime_manifest = read_json(root / "third_party" / "msvc_runtime" / "manifest.json") + if runtime.get("version") != runtime_manifest.get("version"): + raise StagingError("dependency_manifest_identity") + for name, digest in (runtime.get("files") or {}).items(): + path = dependencies / "crt" / name + if not path.is_file() or sha256_path(path) != digest: + raise StagingError("dependency_crt_identity") + if runtime_manifest.get("files", {}).get(name, {}).get("sha256") != digest: + raise StagingError("dependency_manifest_identity") + license_metadata = runtime_manifest.get("license") or {} + license_name = license_metadata.get("deployed_name") + if (not isinstance(license_name, str) or Path(license_name).name != license_name or + runtime.get("license_sha256") != license_metadata.get("sha256") or + runtime.get("notice_sha256") != runtime_manifest.get("notice_sha256")): + raise StagingError("dependency_manifest_identity") + for name, digest in (("NOTICE.txt", runtime_manifest.get("notice_sha256")), + (license_name, license_metadata.get("sha256"))): + path = dependencies / "crt" / name + if not path.is_file() or sha256_path(path) != digest: + raise StagingError("dependency_crt_identity") + return summary + + +def load_contract(root: Path) -> dict: + contract = read_json(root / CONTRACT_PATH) + if contract.get("schema") != "termforge-qt-product-staging-contract-v1": + raise StagingError("staging_contract_schema") + if contract.get("entry_executable") != ENTRY: + raise StagingError("staging_contract_entry") + for key in ("qt_deployed", "os_provided", "license_evidence", "claims"): + if key not in contract: + raise StagingError("staging_contract_schema") + return contract + + +def expected_files(root: Path, dependencies: Path, contract: dict, bin_dir: Path) -> dict: + """Map staged relative path to its verified origin and evidence class.""" + expected = {} + + def add(path: str, source: Path, component: str, role: str, from_build: bool, + pinned: str | None = None): + if path in expected: + raise StagingError("staging_contract_duplicate") + expected[path] = {"source": source, "component": component, "role": role, + "from_build": from_build, "pinned_sha256": pinned} + + # The built entry binary has no external origin file to compare against; its + # identity is the recorded SHA-256 plus the frozen source commit. + add(ENTRY, bin_dir / ENTRY, "product", "runtime", True) + + # Original project license. The staged copy is generated from the source. + license_path = root / "LICENSE" + if not license_path.is_file(): + raise StagingError("project_license_missing") + add("LICENSE", license_path, "termforge", "license", False) + + # Font artifacts are pinned by their own machine-readable manifest; the + # build copies them through the font bundle target. + font_manifest_path = root / "assets" / "fonts" / "manifest.json" + font_manifest = read_json(font_manifest_path) + if font_manifest.get("schema") != "termforge-font-resource-v1": + raise StagingError("font_manifest_schema") + fonts = font_manifest.get("files") + if not isinstance(fonts, dict) or not fonts: + raise StagingError("font_manifest_schema") + for name, identity in fonts.items(): + if Path(name).name != name: + raise StagingError("font_manifest_schema") + source = root / "assets" / "fonts" / name + if (not source.is_file() or source.stat().st_size != identity.get("bytes") or + sha256_path(source) != identity.get("sha256")): + raise StagingError("font_identity") + add(f"assets/fonts/{name}", source, "font", "license" if name == "OFL.txt" else "runtime", True, + identity.get("sha256")) + readme = root / "assets" / "fonts" / "README.md" + if not readme.is_file(): + raise StagingError("font_identity") + add("assets/fonts/README.md", readme, "font", "documentation", True) + + qt_manifest_path = root / "third_party" / "qt" / "manifest.json" + qt_manifest = read_json(qt_manifest_path) + qt_libraries = contract["qt_deployed"].get("libraries") or [] + qt_plugins = contract["qt_deployed"].get("plugins") or [] + for name in qt_libraries: + if Path(name).name != name or not name.endswith(".dll"): + raise StagingError("staging_contract_qt") + add(name, dependencies / "qt" / "bin" / name, "qt", "runtime", True) + for name in qt_plugins: + relative = Path(name) + if len(relative.parts) < 2 or ".." in relative.parts or relative.suffix != ".dll": + raise StagingError("staging_contract_qt") + add(name, dependencies / "qt" / "plugins" / relative, "qt", "runtime", True) + + bundled_texts = qt_manifest.get("license", {}).get("bundled_text_files") or [] + declared_texts = contract["license_evidence"].get("qt", {}).get("bundled_texts") or [] + for target in declared_texts: + match = [entry for entry in bundled_texts + if isinstance(entry, dict) and Path(str(entry.get("path", ""))).name == Path(target).name] + if len(match) != 1: + raise StagingError("qt_license_manifest") + entry = match[0] + source = root / "third_party" / "qt" / entry["path"] + if (not source.is_file() or source.stat().st_size != entry.get("bytes") or + sha256_path(source) != entry.get("sha256")): + raise StagingError("qt_license_identity") + add(target, source, "qt", "license", False, entry.get("sha256")) + + conpty_manifest_path = root / "third_party" / "conpty" / "manifest.json" + conpty_manifest = read_json(conpty_manifest_path) + for name, digest in (conpty_manifest.get("files") or {}).items(): + if Path(name).name != name: + raise StagingError("conpty_manifest") + add(f"conpty-backend/{name}", dependencies / "conpty" / name, "conpty", "runtime", True, digest) + conpty_license = root / "third_party" / "conpty" / "LICENSE" + if (not conpty_license.is_file() or + sha256_path(conpty_license) != conpty_manifest.get("bundled_license_sha256")): + raise StagingError("conpty_identity") + add("conpty-backend/LICENSE", conpty_license, "conpty", "license", True, + conpty_manifest.get("bundled_license_sha256")) + add("conpty-backend/manifest.json", conpty_manifest_path, "conpty", "evidence", True) + + runtime_manifest_path = root / "third_party" / "msvc_runtime" / "manifest.json" + runtime_manifest = read_json(runtime_manifest_path) + for name, identity in (runtime_manifest.get("files") or {}).items(): + if Path(name).name != name or not name.endswith(".dll"): + raise StagingError("msvc_runtime_manifest") + add(name, dependencies / "crt" / name, "msvc_runtime", "runtime", True, identity.get("sha256")) + license_metadata = runtime_manifest.get("license") or {} + license_name = license_metadata.get("deployed_name") + if not isinstance(license_name, str) or Path(license_name).name != license_name: + raise StagingError("msvc_runtime_manifest") + add(license_name, dependencies / "crt" / license_name, "msvc_runtime", "license", True, + license_metadata.get("sha256")) + add("NOTICE.txt", dependencies / "crt" / "NOTICE.txt", "msvc_runtime", "license", True, + runtime_manifest.get("notice_sha256")) + + add(NOTICES_PATH, root / CONTRACT_PATH, "termforge", "documentation", False) + return expected + + +def verify_build(bin_dir: Path, expected: dict, contract: dict, system_root: Path) -> dict: + """Require the build directory to contain exactly the frozen product set.""" + actual = set(relative_files(bin_dir)) + ignored = contract.get("ignored_build_artifacts", {}) + if not isinstance(ignored, dict) or any(Path(name).name != name for name in ignored): + raise StagingError("staging_contract_schema") + os_provided = {} + for entry in contract["os_provided"]: + name = entry.get("name") + if not isinstance(name, str) or Path(name).name != name: + raise StagingError("staging_contract_schema") + os_provided[name] = entry + build_expected = {path for path, spec in expected.items() if spec["from_build"]} + missing = sorted(build_expected - actual) + if missing: + raise StagingError(f"missing_product_file:{','.join(missing[:MAX_DETAIL])}") + unexpected = sorted(actual - build_expected - set(os_provided) - set(ignored)) + if unexpected: + fixtures = [name for name in unexpected + if re.search(r"(_test|_smoke|smoke|client)\.exe$", name, re.IGNORECASE) or + name.lower().endswith((".pdb", ".ilk", ".exp", ".lib", ".obj", ".log"))] + if fixtures: + raise StagingError(f"test_fixture_in_product:{','.join(fixtures[:MAX_DETAIL])}") + raise StagingError(f"unexpected_product_file:{','.join(unexpected[:MAX_DETAIL])}") + + for path, spec in expected.items(): + if not spec["from_build"]: + continue + deployed = bin_dir / path + data = deployed.read_bytes() + source = spec["source"].read_bytes() + if data != source: + raise StagingError(f"{spec['component']}_identity:{path}") + if spec["pinned_sha256"] is not None and sha256_bytes(data) != spec["pinned_sha256"]: + raise StagingError(f"{spec['component']}_identity:{path}") + + observed_os = [] + for name, entry in sorted(os_provided.items()): + deployed = bin_dir / name + if not deployed.is_file(): + observed_os.append({"name": name, "present_in_build": False, **entry}) + continue + system = system_root / "System32" / name + if not system.is_file() or deployed.read_bytes() != system.read_bytes(): + raise StagingError(f"os_component_identity:{name}") + observed_os.append({"name": name, "present_in_build": True, + "observed_sha256": sha256_path(deployed), **entry}) + + # Test executables live in their own qt-*-bin directories; only the frozen + # product files enter staging. The ConPTY host is the only other executable. + if ENTRY not in expected: + raise StagingError("entry_set") + for path in sorted(expected): + if path.lower().endswith(".exe"): + pe_identity((bin_dir / path).read_bytes(), executable=True) + elif path.lower().endswith(".dll"): + pe_identity((bin_dir / path).read_bytes(), executable=False) + return observed_os + + +def bundled_texts(root: Path, contract: dict) -> list[dict]: + """Declared Qt license texts with their pinned source identity.""" + qt_manifest = read_json(root / "third_party" / "qt" / "manifest.json") + available = qt_manifest.get("license", {}).get("bundled_text_files") or [] + bundled = [] + for target in contract["license_evidence"]["qt"]["bundled_texts"]: + matches = [entry for entry in available + if isinstance(entry, dict) and Path(str(entry.get("path", ""))).name == Path(target).name] + if len(matches) != 1: + raise StagingError("qt_license_manifest") + entry = matches[0] + bundled.append({"component": "qt", "path": target, + "source": f"third_party/qt/{entry['path']}", + "sha256": entry["sha256"], "spdx": entry.get("spdx")}) + return bundled + + +def notices_text(contract: dict, source: dict, summary: dict, observed_os: list[dict]) -> bytes: + qt = summary.get("qt_version", "unknown") + conpty = summary.get("conpty_version", "unknown") + runtime = (summary.get("msvc_runtime") or {}).get("version", "unknown") + lines = [ + "TermForge product staging - third-party components and bundled license evidence", + "", + "This file is a generated evidence index for the Windows x64 C++/Qt/ConPTY", + "engineering staging. It is not a legal review or a redistribution clearance.", + "", + f"TermForge original code: MIT; bundled at LICENSE (source commit {source['commit']}).", + f"Qt {qt}: dynamic-link LGPL-3.0-only option; bundled license text at", + " licenses/LGPL-3.0-only.txt; full Qt third-party inventory is pending.", + f"Microsoft Windows Console ConPTY {conpty}: MIT; bundled at conpty-backend/LICENSE.", + f"Microsoft Visual C++ v14 Runtime {runtime}: bundled with its original license", + " and NOTICE at LICENSE-MICROSOFT-VC-REDIST.docx and NOTICE.txt.", + "Roboto Mono (Regular, static): SIL Open Font License 1.1; bundled at", + " assets/fonts/OFL.txt with its provenance notes at assets/fonts/README.md.", + "Provided by the Windows 11 operating system, not bundled: " + + ", ".join(entry["name"] for entry in contract["os_provided"]) + ".", + "Deliberately not deployed because they are neither pinned nor linked by this", + " entry: " + ", ".join(entry["name"] for entry in contract["excluded_system_redistributables"]) + ".", + "Redistribution clearance: pending user review (Qt/third-party deployed file", + " inventory, corresponding-source conditions and clean-machine run).", + "", + ] + return "\n".join(lines).encode("utf-8") + + +def product_manifest(root: Path, contract: dict, source: dict, build: dict, summary: dict, + expected: dict, observed_os: list[dict], tool_sha256: str, + contract_sha256: str, bundled: list[dict]) -> dict: + files = {} + for path, spec in sorted(expected.items()): + if path == MANIFEST_PATH: + continue + if path == NOTICES_PATH: + continue + data = spec["source"].read_bytes() + files[path] = {"bytes": len(data), "sha256": sha256_bytes(data), + "component": spec["component"], "role": spec["role"]} + notices = notices_text(contract, source, summary, observed_os) + files[NOTICES_PATH] = {"bytes": len(notices), "sha256": sha256_bytes(notices), + "component": "termforge", "role": "documentation"} + + def manifest_sha(relative: str) -> str: + return sha256_path(root / relative) + + runtime = summary.get("msvc_runtime") or {} + manifest = { + "schema": SCHEMA, + "status": "engineering_candidate", + "generated_by": {"tool": "tools/stage_qt_product.py", "tool_sha256": tool_sha256}, + "contract": {"path": CONTRACT_PATH, "sha256": contract_sha256}, + "identity": { + "product": "TermForge C++/Qt local terminal entry", + "entry_executable": ENTRY, + "platform": contract["platform"], + "configuration": build["configuration"], + "distribution_state": "engineering_candidate_not_released", + "r2_acceptance": False, + }, + "source": source, + "build": build, + "dependencies": { + "preparation_schema": PREPARATION_SCHEMA, + "preparation_script_sha256": summary.get("preparation_script_sha256"), + "input_manifests": summary.get("input_manifests"), + "qt": { + "version": summary.get("qt_version"), + "architecture": summary.get("qt_architecture"), + "archive_sha256": summary.get("qt_archive_sha256"), + "prepared_sdk": summary.get("sdk"), + "license_manifest": "third_party/qt/manifest.json", + "license_manifest_sha256": manifest_sha("third_party/qt/manifest.json"), + }, + "conpty": { + "version": summary.get("conpty_version"), + "package_sha256": summary.get("conpty_package_sha256"), + "files": summary.get("conpty_files"), + "license_manifest": "third_party/conpty/manifest.json", + "license_manifest_sha256": manifest_sha("third_party/conpty/manifest.json"), + }, + "msvc_runtime": { + "version": runtime.get("version"), + "architecture": runtime.get("architecture"), + "package_sha256": runtime.get("package_sha256"), + "files": runtime.get("files"), + "license_sha256": runtime.get("license_sha256"), + "notice_sha256": runtime.get("notice_sha256"), + "license_manifest": "third_party/msvc_runtime/manifest.json", + "license_manifest_sha256": manifest_sha("third_party/msvc_runtime/manifest.json"), + }, + "font": { + "manifest": "assets/fonts/manifest.json", + "manifest_sha256": manifest_sha("assets/fonts/manifest.json"), + "files": {name: {"sha256": spec["pinned_sha256"]} + for name, spec in sorted(expected.items()) + if spec["component"] == "font" and spec["pinned_sha256"]}, + }, + }, + "os_provided": observed_os, + "excluded_system_redistributables": contract["excluded_system_redistributables"], + "licenses": { + "termforge": {"spdx": "MIT", "bundled_paths": ["LICENSE"], + "sha256": files["LICENSE"]["sha256"], + "scope": "original TermForge code and documentation"}, + "qt": {"selected_option": "LGPL-3.0-only (dynamic link)", + "bundled_paths": [item["path"] for item in bundled], + "bundled_sha256": {item["path"]: item["sha256"] for item in bundled}, + "evidence_manifest": "third_party/qt/manifest.json", + "status": "third_party_component_inventory_pending"}, + "conpty": {"spdx": "MIT", "bundled_paths": ["conpty-backend/LICENSE"], + "evidence_manifest": "conpty-backend/manifest.json", "status": "bundled"}, + "msvc_runtime": {"bundled_paths": ["LICENSE-MICROSOFT-VC-REDIST.docx", "NOTICE.txt"], + "evidence_manifest": "third_party/msvc_runtime/manifest.json", + "status": "bundled"}, + "font": {"spdx": "OFL-1.1", "bundled_paths": ["assets/fonts/OFL.txt"], + "evidence_manifest": "assets/fonts/manifest.json", "status": "bundled"}, + }, + "redistribution_clearance": { + "status": "pending_user_review", + "reason": "The Qt third-party deployed-file inventory, corresponding-source and replacement conditions, and a clean-machine run are not closed. This staging is an engineering candidate, not a redistribution clearance.", + }, + "claims": contract["claims"], + "files": dict(sorted(files.items())), + "test_fixtures": { + "included": [], + "policy": contract["test_fixture_policy"], + "separate_directories": "Qt test executables are built into their own qt-*-bin directories and are rejected by staging.", + }, + "limitations": [ + "No clean Windows machine extraction/run has been performed for this staging.", + "Single-screen 96/144 DPI, IME, resize/history and reliability acceptance are outside this build-preparation slice.", + "Qt's deployed third-party component licenses, corresponding-source conditions and clean-machine runtime completeness remain open.", + "icuuc.dll is provided by the Windows 11 system and is not bundled; this staging is not validated on other Windows versions.", + "No tag, release or published binary is created by this tool.", + ], + } + return manifest + + +def verify_staging(staging: Path) -> dict: + manifest_path = staging / MANIFEST_PATH + manifest = read_json(manifest_path) + if manifest.get("schema") != SCHEMA or manifest.get("status") != "engineering_candidate": + raise StagingError("manifest_schema") + files = manifest.get("files") + if not isinstance(files, dict) or not files: + raise StagingError("manifest_files") + actual = set(relative_files(staging)) + declared = set(files) | {MANIFEST_PATH} + if actual != declared: + missing = sorted(declared - actual) + extra = sorted(actual - declared) + if missing: + raise StagingError(f"missing_product_file:{','.join(missing[:MAX_DETAIL])}") + raise StagingError(f"unexpected_product_file:{','.join(extra[:MAX_DETAIL])}") + for path, identity in files.items(): + data = (staging / path).read_bytes() + if len(data) != identity.get("bytes") or sha256_bytes(data) != identity.get("sha256"): + raise StagingError(f"file_identity:{path}") + entry = manifest.get("identity", {}).get("entry_executable") + if entry not in files: + raise StagingError("entry_set") + pe_identity((staging / entry).read_bytes(), executable=True) + for path in sorted(files): + if path.lower().endswith(".dll"): + pe_identity((staging / path).read_bytes(), executable=False) + for license_entry in manifest.get("licenses", {}).values(): + for path in license_entry.get("bundled_paths", []): + if path not in files: + raise StagingError("license_evidence_missing") + return {"schema": OPERATION_SCHEMA, "status": "passed", "operation": "verify", + "manifest_sha256": sha256_path(manifest_path), "staging": tree_identity(staging), + "claims": manifest.get("claims")} + + +def write_json(path: Path, value: dict) -> None: + report = (json.dumps(value, ensure_ascii=False, indent=2) + "\n").encode("utf-8") + path.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile(prefix=".staging-", dir=path.parent, delete=False) as handle: + candidate = Path(handle.name) + handle.write(report) + try: + candidate.replace(path) + finally: + candidate.unlink(missing_ok=True) + + +def stage(source_root: Path, bin_dir: Path, dependencies: Path, dependency_summary: Path, + out: Path, summary_path: Path, allow_dirty: bool, system_root: Path | None = None) -> dict: + if out.exists(): + raise StagingError("output_exists") + if not bin_dir.is_dir(): + raise StagingError("build_directory") + contract = load_contract(source_root) + summary = verify_dependency_summary(source_root, dependencies, dependency_summary) + source = source_identity(source_root, allow_dirty) + build = build_configuration(bin_dir) + expected = expected_files(source_root, dependencies, contract, bin_dir) + bundled = bundled_texts(source_root, contract) + + system = Path(system_root or os.environ.get("SystemRoot", r"C:\Windows")) + observed_os = verify_build(bin_dir, expected, contract, system) + + out.parent.mkdir(parents=True, exist_ok=True) + notices = notices_text(contract, source, summary, observed_os) + with tempfile.TemporaryDirectory(prefix="qt-product-", dir=out.parent) as temporary: + staging = Path(temporary) / "product" + staging.mkdir() + for path, spec in sorted(expected.items()): + if path == NOTICES_PATH: + data = notices + else: + data = spec["source"].read_bytes() + if spec["pinned_sha256"] is not None and sha256_bytes(data) != spec["pinned_sha256"]: + raise StagingError(f"{spec['component']}_identity:{path}") + target = staging / path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(data) + manifest = product_manifest(source_root, contract, source, build, summary, expected, + observed_os, sha256_path(Path(__file__).resolve()), + sha256_path(source_root / CONTRACT_PATH), bundled) + manifest_bytes = (json.dumps(manifest, ensure_ascii=False, indent=2) + "\n").encode("utf-8") + (staging / MANIFEST_PATH).write_bytes(manifest_bytes) + identity = tree_identity(staging) + result = {"schema": OPERATION_SCHEMA, "status": "passed", "operation": "stage", + "manifest_sha256": sha256_bytes(manifest_bytes), "staging": identity, + "source": {key: source[key] for key in ("commit", "tree", "dirty", "development_override")}, + "product": {"entry_executable": ENTRY, "files": len(manifest["files"]), + "distribution_state": "engineering_candidate_not_released"}, + "os_provided": [entry["name"] for entry in observed_os], + "claims": contract["claims"], + "scope": "Engineering product staging only; no release, clean-machine acceptance or R2 acceptance."} + staging.replace(out) + try: + write_json(summary_path, result) + except OSError: + result = {"schema": OPERATION_SCHEMA, "status": "failed", "operation": "stage", + "error": "summary_write_failed", "staging_published": True} + return result + + +def main(argv=None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + stage_command = commands.add_parser("stage", help="Create a frozen engineering staging directory") + stage_command.add_argument("--bin-dir", type=Path, required=True, + help="Built Release product directory, normally build/qt-cpp/qt-bin/Release") + stage_command.add_argument("--dependencies", type=Path, required=True, + help="Verified directory produced by tools.prepare_qt_build") + stage_command.add_argument("--dependency-summary", type=Path, required=True, + help="Path-free preparation summary from tools.prepare_qt_build") + stage_command.add_argument("--out", type=Path, required=True, help="New staging directory") + stage_command.add_argument("--summary", type=Path, required=True, + help="Path-free operation summary to write") + stage_command.add_argument("--source-root", type=Path, default=ROOT) + stage_command.add_argument("--allow-dirty-development", action="store_true", + help="Allow a dirty source tree and record the development override") + verify_command = commands.add_parser("verify", help="Re-verify an existing staging directory") + verify_command.add_argument("--staging", type=Path, required=True) + arguments = parser.parse_args(argv) + if arguments.command == "verify": + try: + result = verify_staging(arguments.staging.resolve()) + except StagingError as error: + result = {"schema": OPERATION_SCHEMA, "status": "failed", "operation": "verify", + "error": str(error)} + except (OSError, ValueError, KeyError, TypeError): + result = {"schema": OPERATION_SCHEMA, "status": "failed", "operation": "verify", + "error": "staging_input_failed"} + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 if result.get("status") == "passed" else 1 + try: + result = stage(arguments.source_root.resolve(), arguments.bin_dir.resolve(), + arguments.dependencies.resolve(), arguments.dependency_summary.resolve(), + arguments.out.resolve(), arguments.summary.resolve(), + arguments.allow_dirty_development) + if result.get("status") == "passed": + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 + except StagingError as error: + result = {"schema": OPERATION_SCHEMA, "status": "failed", "operation": "stage", + "error": str(error)} + except (OSError, ValueError, KeyError, TypeError): + result = {"schema": OPERATION_SCHEMA, "status": "failed", "operation": "stage", + "error": "staging_input_failed"} + try: + write_json(arguments.summary.resolve(), result) + except (OSError, ValueError): + result = {"schema": OPERATION_SCHEMA, "status": "failed", "operation": "stage", + "error": "summary_write_failed", "staging_error": result.get("error")} + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 if result.get("status") == "passed" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/test_stage_qt_product.py b/tools/test_stage_qt_product.py new file mode 100644 index 00000000..a80063d9 --- /dev/null +++ b/tools/test_stage_qt_product.py @@ -0,0 +1,455 @@ +"""Offline contract tests for the Qt product staging tool; no build or GUI access.""" +from __future__ import annotations + +from contextlib import redirect_stdout +import hashlib +import io +import json +from pathlib import Path +import struct +import subprocess +import tempfile +import unittest + +if __package__: + from . import stage_qt_product + from .prepare_qt_build import tree_identity +else: + import stage_qt_product + from prepare_qt_build import tree_identity + +REAL_ROOT = Path(__file__).resolve().parents[1] +CONTRACT = (REAL_ROOT / "packaging/qt-product/manifest.json").read_bytes() +PREPARE_TOOL = (REAL_ROOT / "tools/prepare_qt_build.py").read_bytes() +CRT_NAMES = ("msvcp140.dll", "vcruntime140.dll", "vcruntime140_1.dll") +FAKE_ICU = b"windows system icu forwarder fixture\n" + + +def digest(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def pe_fixture(kind: str = "exe") -> bytes: + """Small valid PE32+ AMD64 fixture; never executed as a program.""" + data = bytearray(1024) + data[:2] = b"MZ" + struct.pack_into(" str: + result = subprocess.run( + ["git", "-C", str(root), "-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", + *arguments], check=True, capture_output=True, text=True, timeout=30) + return result.stdout.strip() + + +class Fixture: + """A synthetic repository, dependency preparation and build output.""" + + def __init__(self, case: unittest.TestCase): + temporary = tempfile.TemporaryDirectory(prefix="termforge staging fixture ") + case.addCleanup(temporary.cleanup) + base = Path(temporary.name) + self.root = base / "repo" + self.deps = base / "deps" + self.build = base / "build" + self.system = base / "system" + self.summary = base / "preparation.json" + self.contract = json.loads(CONTRACT.decode("utf-8")) + (self.root / "tools").mkdir(parents=True) + (self.root / "tools/prepare_qt_build.py").write_bytes(PREPARE_TOOL) + for directory in ("assets/fonts", "third_party/qt/LICENSES", "third_party/conpty", + "third_party/msvc_runtime", "packaging/qt-product"): + (self.root / directory).mkdir(parents=True, exist_ok=True) + (self.root / "packaging/qt-product/manifest.json").write_bytes(CONTRACT) + (self.root / "LICENSE").write_text("MIT fixture\n", encoding="utf-8") + self._fonts() + self._manifests() + self._dependencies() + self._build_output() + self._commit() + + def _fonts(self): + fonts = self.root / "assets/fonts" + (fonts / "RobotoMono-Regular.ttf").write_bytes(b"font fixture bytes\n") + (fonts / "OFL.txt").write_text("OFL fixture text\n", encoding="utf-8") + (fonts / "README.md").write_text("# font fixture notes\n", encoding="utf-8") + manifest = { + "schema": "termforge-font-resource-v1", + "name": "Roboto Mono", "style": "Regular (static)", + "upstream": {"repository": "fixture", "fixed_commit": "a" * 40, "acquired": "2026-01-01"}, + "license": {"spdx": "OFL-1.1", "bundled_path": "OFL.txt"}, + "files": {}, + "deployment": {"source_directory": "assets/fonts", "staged_directory": "assets/fonts", + "additional_documentation": "README.md", "required": True}, + } + for name, upstream in (("RobotoMono-Regular.ttf", "fonts/ttf/RobotoMono-Regular.ttf"), + ("OFL.txt", "OFL.txt")): + data = (fonts / name).read_bytes() + manifest["files"][name] = {"bytes": len(data), "sha256": digest(data), "upstream_path": upstream} + (fonts / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") + + def _manifests(self): + license_data = b"LGPL fixture text\n" + (self.root / "third_party/qt/LICENSES/LGPL-3.0-only.txt").write_bytes(license_data) + qt_manifest = { + "version": "6.10.3", "architecture": "win64_msvc2022_64", + "official_binary_archive": {"sha256": "a" * 64}, + "license": {"bundled_text_files": [{ + "spdx": "LGPL-3.0-only", "path": "LICENSES/LGPL-3.0-only.txt", + "bytes": len(license_data), "sha256": digest(license_data)}]}, + } + (self.root / "third_party/qt/manifest.json").write_text( + json.dumps(qt_manifest, indent=2) + "\n", encoding="utf-8") + self.qt_manifest = qt_manifest + + conpty_license = b"MIT fixture conpty\n" + (self.root / "third_party/conpty/LICENSE").write_bytes(conpty_license) + conpty_manifest = { + "version": "1.25.260930003", "package_sha256": "b" * 64, + "bundled_license_sha256": digest(conpty_license), + "files": {"conpty.dll": "c" * 64, "OpenConsole.exe": "d" * 64}, + } + (self.root / "third_party/conpty/manifest.json").write_text( + json.dumps(conpty_manifest, indent=2) + "\n", encoding="utf-8") + self.conpty_manifest = conpty_manifest + + runtime_license = b"Microsoft redistributable license fixture\n" + runtime_files = {} + for name in CRT_NAMES: + runtime_files[name] = {"sha256": digest(f"crt fixture {name}".encode())} + runtime_manifest = { + "version": "14.51.36247.0", "architecture": "windows-x64", + "package_sha256": "e" * 64, + "files": runtime_files, + "license": {"deployed_name": "LICENSE-MICROSOFT-VC-REDIST.docx", + "sha256": digest(runtime_license)}, + "notice_sha256": digest(b"crt notice fixture\n"), + } + (self.root / "third_party/msvc_runtime/manifest.json").write_text( + json.dumps(runtime_manifest, indent=2) + "\n", encoding="utf-8") + self.runtime_manifest = runtime_manifest + + def _dependencies(self): + qt = self.deps / "qt" + for name in self.contract["qt_deployed"]["libraries"]: + path = qt / "bin" / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(pe_fixture("dll") + name.encode()) + for name in self.contract["qt_deployed"]["plugins"]: + path = qt / "plugins" / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(pe_fixture("dll") + name.encode()) + conpty = self.deps / "conpty" + conpty.mkdir(parents=True) + (conpty / "conpty.dll").write_bytes(pe_fixture("dll") + b"conpty") + (conpty / "OpenConsole.exe").write_bytes(pe_fixture("exe") + b"openconsole") + self.conpty_manifest["files"] = { + "conpty.dll": digest((conpty / "conpty.dll").read_bytes()), + "OpenConsole.exe": digest((conpty / "OpenConsole.exe").read_bytes()), + } + (self.root / "third_party/conpty/manifest.json").write_text( + json.dumps(self.conpty_manifest, indent=2) + "\n", encoding="utf-8") + crt = self.deps / "crt" + crt.mkdir(parents=True) + for name in CRT_NAMES: + (crt / name).write_bytes(pe_fixture("dll") + name.encode()) + (crt / "LICENSE-MICROSOFT-VC-REDIST.docx").write_bytes(b"Microsoft redistributable license fixture\n") + (crt / "NOTICE.txt").write_bytes(b"crt notice fixture\n") + runtime_files = {name: {"sha256": digest((crt / name).read_bytes())} for name in CRT_NAMES} + self.runtime_manifest["files"] = runtime_files + self.runtime_manifest["license"]["sha256"] = digest( + (crt / "LICENSE-MICROSOFT-VC-REDIST.docx").read_bytes()) + self.runtime_manifest["notice_sha256"] = digest((crt / "NOTICE.txt").read_bytes()) + (self.root / "third_party/msvc_runtime/manifest.json").write_text( + json.dumps(self.runtime_manifest, indent=2) + "\n", encoding="utf-8") + qt_manifest = json.loads((self.root / "third_party/qt/manifest.json").read_text(encoding="utf-8")) + self.write_preparation_summary(qt_manifest) + + def write_preparation_summary(self, qt_manifest: dict): + summary = { + "schema": stage_qt_product.PREPARATION_SCHEMA, "status": "passed", + "qt_version": "6.10.3", "qt_architecture": "win64_msvc2022_64", + "qt_archive_sha256": qt_manifest["official_binary_archive"]["sha256"], + "input_manifests": { + name: digest((self.root / "third_party" / name / "manifest.json").read_bytes()) + for name in ("qt", "conpty", "msvc_runtime") + }, + "preparation_script_sha256": digest(PREPARE_TOOL), + "sdk": tree_identity(self.deps / "qt"), + "conpty_version": "1.25.260930003", + "conpty_package_sha256": self.conpty_manifest["package_sha256"], + "conpty_files": dict(self.conpty_manifest["files"]), + "msvc_runtime": { + "version": "14.51.36247.0", "architecture": "windows-x64", + "package_sha256": self.runtime_manifest["package_sha256"], + "files": {name: identity["sha256"] for name, identity in self.runtime_manifest["files"].items()}, + "license_sha256": self.runtime_manifest["license"]["sha256"], + "notice_sha256": self.runtime_manifest["notice_sha256"], + }, + } + self.summary.write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8") + + def _build_output(self): + self.bin = self.build / "qt-bin/Release" + self.bin.mkdir(parents=True) + self.build.joinpath("CMakeCache.txt").write_text( + "CMAKE_GENERATOR:INTERNAL=Visual Studio 17 2022\n" + "CMAKE_GENERATOR_PLATFORM:INTERNAL=x64\n" + "CMAKE_CONFIGURATION_TYPES:STRING=Debug;Release\n" + "TERMFORGE_BUILD_QT_SHELL:BOOL=ON\n" + "TERMFORGE_BUILD_CANDIDATE_ADAPTER:BOOL=OFF\n" + "CMAKE_LINKER:FILEPATH=C:/Program Files/Microsoft Visual Studio/2022/Community/" + "VC/Tools/MSVC/14.42.34433/bin/Hostx64/x64/link.exe\n", encoding="utf-8") + (self.bin / "termforge_qt_shell.exe").write_bytes(pe_fixture("exe")) + for name in self.contract["qt_deployed"]["libraries"]: + (self.bin / name).write_bytes((self.deps / "qt/bin" / name).read_bytes()) + for name in self.contract["qt_deployed"]["plugins"]: + target = self.bin / name + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes((self.deps / "qt/plugins" / name).read_bytes()) + (self.bin / "conpty-backend").mkdir() + for name in ("conpty.dll", "OpenConsole.exe"): + (self.bin / "conpty-backend" / name).write_bytes((self.deps / "conpty" / name).read_bytes()) + (self.bin / "conpty-backend/manifest.json").write_bytes( + (self.root / "third_party/conpty/manifest.json").read_bytes()) + (self.bin / "conpty-backend/LICENSE").write_bytes( + (self.root / "third_party/conpty/LICENSE").read_bytes()) + for name in CRT_NAMES: + (self.bin / name).write_bytes((self.deps / "crt" / name).read_bytes()) + (self.bin / "LICENSE-MICROSOFT-VC-REDIST.docx").write_bytes( + (self.deps / "crt/LICENSE-MICROSOFT-VC-REDIST.docx").read_bytes()) + (self.bin / "NOTICE.txt").write_bytes((self.deps / "crt/NOTICE.txt").read_bytes()) + (self.bin / "assets/fonts").mkdir(parents=True) + for name in ("RobotoMono-Regular.ttf", "OFL.txt", "README.md"): + (self.bin / "assets/fonts" / name).write_bytes( + (self.root / "assets/fonts" / name).read_bytes()) + self.system.joinpath("System32").mkdir(parents=True) + (self.bin / "icuuc.dll").write_bytes(FAKE_ICU) + (self.system / "System32/icuuc.dll").write_bytes(FAKE_ICU) + + def _commit(self): + git(self.root, "init", "-q") + git(self.root, "add", "-A") + git(self.root, "commit", "-q", "-m", "fixture") + + def stage(self, out=None, summary=None, allow_dirty=False): + out = out or self.build.parent / "staging" + summary = summary or self.build.parent / "staging-summary.json" + result = stage_qt_product.stage( + self.root, self.bin, self.deps, self.summary, out, summary, allow_dirty, self.system) + return result, out, summary + + +class StagingTests(unittest.TestCase): + def setUp(self): + self.fixture = Fixture(self) + + def assert_failure(self, code, *, allow_dirty=False): + with self.assertRaises(stage_qt_product.StagingError) as raised: + self.fixture.stage(allow_dirty=allow_dirty) + self.assertIn(code, str(raised.exception)) + self.assertFalse((self.fixture.build.parent / "staging").exists()) + + def test_stage_produces_a_frozen_path_free_manifest(self): + result, out, summary = self.fixture.stage() + self.assertEqual(result["status"], "passed") + self.assertTrue(out.is_dir()) + manifest = json.loads((out / "product-manifest.json").read_text(encoding="utf-8")) + self.assertEqual(manifest["schema"], stage_qt_product.SCHEMA) + self.assertEqual(manifest["status"], "engineering_candidate") + self.assertEqual(manifest["identity"]["entry_executable"], "termforge_qt_shell.exe") + self.assertFalse(manifest["identity"]["r2_acceptance"]) + self.assertFalse(manifest["source"]["dirty"]) + self.assertEqual(manifest["source"]["commit"], git(self.fixture.root, "rev-parse", "HEAD")) + self.assertEqual(manifest["dependencies"]["qt"]["version"], "6.10.3") + self.assertIn("licenses/LGPL-3.0-only.txt", manifest["files"]) + self.assertIn("THIRD-PARTY-NOTICES.txt", manifest["files"]) + self.assertIn("conpty-backend/LICENSE", manifest["files"]) + self.assertEqual(manifest["test_fixtures"]["included"], []) + self.assertEqual(manifest["os_provided"][0]["name"], "icuuc.dll") + self.assertTrue(manifest["os_provided"][0]["present_in_build"]) + self.assertEqual(manifest["os_provided"][0]["observed_sha256"], digest(FAKE_ICU)) + self.assertEqual(manifest["redistribution_clearance"]["status"], "pending_user_review") + self.assertNotIn("icuuc.dll", manifest["files"]) + for name, identity in manifest["files"].items(): + data = (out / name).read_bytes() + self.assertEqual(len(data), identity["bytes"], name) + self.assertEqual(digest(data), identity["sha256"], name) + notices = (out / "THIRD-PARTY-NOTICES.txt").read_text(encoding="utf-8") + self.assertIn("pending user review", notices) + self.assertIn("D3Dcompiler_47.dll", notices) + staged = {path.relative_to(out).as_posix() for path in out.rglob("*") if path.is_file()} + self.assertEqual(staged, set(manifest["files"]) | {"product-manifest.json"}) + self.assertNotIn(str(self.fixture.root), json.dumps(manifest)) + self.assertNotIn(str(self.fixture.root), json.dumps(result)) + self.assertEqual(stage_qt_product.verify_staging(out)["status"], "passed") + + repeated_result, repeated_out, _ = self.fixture.stage( + out=self.fixture.build.parent / "staging-again", + summary=self.fixture.build.parent / "staging-summary-again.json") + self.assertEqual(repeated_result["manifest_sha256"], result["manifest_sha256"]) + self.assertEqual(repeated_result["staging"], result["staging"]) + self.assertEqual((repeated_out / "product-manifest.json").read_bytes(), + (out / "product-manifest.json").read_bytes()) + self.assertTrue(summary.is_file()) + + def test_verify_detects_content_change(self): + _, out, _ = self.fixture.stage() + target = out / "Qt6Core.dll" + target.write_bytes(target.read_bytes() + b"tampered") + with self.assertRaises(stage_qt_product.StagingError) as raised: + stage_qt_product.verify_staging(out) + self.assertIn("file_identity:Qt6Core.dll", str(raised.exception)) + + def test_verify_detects_missing_and_extra_files(self): + _, out, _ = self.fixture.stage() + (out / "LICENSE").unlink() + with self.assertRaises(stage_qt_product.StagingError) as raised: + stage_qt_product.verify_staging(out) + self.assertIn("missing_product_file:LICENSE", str(raised.exception)) + _, second, _ = self.fixture.stage( + out=self.fixture.build.parent / "second", + summary=self.fixture.build.parent / "second.json") + (second / "unexpected.dll").write_bytes(b"unexpected") + with self.assertRaises(stage_qt_product.StagingError) as raised: + stage_qt_product.verify_staging(second) + self.assertIn("unexpected_product_file:unexpected.dll", str(raised.exception)) + + def test_missing_entry_and_test_fixture_are_rejected(self): + (self.fixture.bin / "termforge_qt_shell.exe").unlink() + self.assert_failure("missing_product_file:termforge_qt_shell.exe") + + def test_extra_test_executable_is_rejected_by_name(self): + (self.fixture.bin / "qt_cpp_shell_smoke.exe").write_bytes(pe_fixture("exe")) + self.assert_failure("test_fixture_in_product:qt_cpp_shell_smoke.exe") + + def test_other_unlisted_file_is_rejected(self): + (self.fixture.bin / "notes.txt").write_text("not a product file\n", encoding="utf-8") + self.assert_failure("unexpected_product_file:notes.txt") + + def test_derived_runtime_manifest_is_ignored_and_never_staged(self): + (self.fixture.bin / "runtime-manifest.json").write_text( + '{"schema": "termforge-windows-runtime-v1"}\n', encoding="utf-8") + result, out, _ = self.fixture.stage() + self.assertEqual(result["status"], "passed") + self.assertFalse((out / "runtime-manifest.json").exists()) + self.assertNotIn("runtime-manifest.json", json.loads( + (out / "product-manifest.json").read_text(encoding="utf-8"))["files"]) + + def test_tampered_build_components_are_rejected(self): + for relative, code in (("Qt6Core.dll", "qt_identity"), + ("msvcp140.dll", "msvc_runtime_identity"), + ("conpty-backend/conpty.dll", "conpty_identity"), + ("assets/fonts/RobotoMono-Regular.ttf", "font_identity")): + with self.subTest(relative=relative): + fixture = Fixture(self) + target = fixture.bin / relative + target.write_bytes(target.read_bytes() + b"tampered") + with self.assertRaises(stage_qt_product.StagingError) as raised: + fixture.stage() + self.assertIn(code, str(raised.exception)) + self.assertFalse((fixture.build.parent / "staging").exists()) + + def test_system_component_must_match_the_os_copy(self): + (self.fixture.bin / "icuuc.dll").write_bytes(b"another icu source") + self.assert_failure("os_component_identity:icuuc.dll") + + def test_dependency_summary_identity_is_enforced(self): + summary = json.loads(self.fixture.summary.read_text(encoding="utf-8")) + summary["sdk"]["tree_sha256"] = "f" * 64 + self.fixture.summary.write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8") + self.assert_failure("dependency_tree_identity") + + def test_dependency_manifest_binding_is_enforced(self): + summary = json.loads(self.fixture.summary.read_text(encoding="utf-8")) + summary["input_manifests"]["qt"] = "0" * 64 + self.fixture.summary.write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8") + self.assert_failure("dependency_manifest_mismatch") + + def test_dirty_source_requires_the_explicit_development_override(self): + (self.fixture.root / "untracked.txt").write_text("dirty\n", encoding="utf-8") + self.assert_failure("source_dirty") + result, out, _ = self.fixture.stage(allow_dirty=True) + self.assertEqual(result["status"], "passed") + manifest = json.loads((out / "product-manifest.json").read_text(encoding="utf-8")) + self.assertTrue(manifest["source"]["dirty"]) + self.assertTrue(manifest["source"]["development_override"]) + + def test_existing_output_is_never_overlaid(self): + out = self.fixture.build.parent / "staging" + out.mkdir() + marker = out / "keep.txt" + marker.write_text("keep\n", encoding="utf-8") + with self.assertRaises(stage_qt_product.StagingError) as raised: + self.fixture.stage() + self.assertIn("output_exists", str(raised.exception)) + self.assertEqual(marker.read_text(encoding="utf-8"), "keep\n") + + def test_build_configuration_is_frozen(self): + cache = self.fixture.build / "CMakeCache.txt" + text = cache.read_text(encoding="utf-8").replace( + "TERMFORGE_BUILD_CANDIDATE_ADAPTER:BOOL=OFF", + "TERMFORGE_BUILD_CANDIDATE_ADAPTER:BOOL=ON") + cache.write_text(text, encoding="utf-8") + self.assert_failure("build_configuration") + + def test_pinned_font_and_license_text_tampering_fails(self): + (self.fixture.root / "assets/fonts/RobotoMono-Regular.ttf").write_bytes(b"tampered font\n") + self.assert_failure("font_identity", allow_dirty=True) + second = Fixture(self) + (second.root / "third_party/qt/LICENSES/LGPL-3.0-only.txt").write_bytes(b"tampered license\n") + with self.assertRaises(stage_qt_product.StagingError) as raised: + second.stage(allow_dirty=True) + self.assertIn("qt_license_identity", str(raised.exception)) + + def test_pe_identity_rejects_wrong_architectures(self): + with self.assertRaises(stage_qt_product.StagingError): + stage_qt_product.pe_identity(pe_fixture("dll"), executable=True) + wrong = bytearray(pe_fixture("exe")) + struct.pack_into("