From 84de68fd1b0467e726226e7b52703489d0963455 Mon Sep 17 00:00:00 2001 From: Evan Hu Date: Mon, 7 Sep 2026 07:40:04 +0900 Subject: [PATCH] fix(server): embed uiAccess manifest before signing --- .github/workflows/release.yml | 4 ++ AGENTS.md | 3 +- installer/tests/build-failures.ps1 | 28 +++++++++++++ scripts/ci/embed-server-manifest.ps1 | 61 ++++++++++++++++++++++++++++ 4 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 scripts/ci/embed-server-manifest.ps1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 34901582..a177bd3b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -250,6 +250,10 @@ jobs: shell: pwsh run: cmake --build server/build-release --config Release --parallel + - name: Embed the Server uiAccess manifest + shell: pwsh + run: ./scripts/ci/embed-server-manifest.ps1 -BuildDir server/build-release/bin/Release + - name: Check the binaries the installer requires shell: pwsh run: ./scripts/ci/check-server-binaries.ps1 -BuildDir server/build-release/bin/Release diff --git a/AGENTS.md b/AGENTS.md index 5c87b571..6fd204db 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -101,6 +101,7 @@ release workflow 里的每一段 shell 都抽在 `scripts/ci/` 下,workflow | `verify-commit-on-main.sh` | 拒绝构建不在 main 历史里的 commit | | `install-boost.ps1` | 装 Server 链接但未声明的 Boost,triplet 必须是 static-md | | `check-server-binaries.ps1` | 提前拦住 Server 产物缺文件 | +| `embed-server-manifest.ps1` | 在上传和签名之前把 `uiAccess=true` manifest 注入 Server PE,并读回验证 | | `check-tsf-dll.ps1` | 确认 TSF DLL 产出 | | `download-dictionaries.sh` | 从产品锁指定仓库的 `dict-*` release 拉词库并校验 SHA256 | | `detect-release-signing.ps1` | 判定签名模式,决定产物后缀 | @@ -115,7 +116,7 @@ release workflow 里的每一段 shell 都抽在 `scripts/ci/` 下,workflow 词库不在 CI 里现建,从产品锁指定仓库的 `dict-*` release 下载并校验 SHA256。词库源数据与构建入口已并入 MSIME-Engine,现有 MSIME-Dict release 作为不可变旧产物保留;换发布源要在 `product_lock.py` 里明确评审,不是改个 tag 就能悄悄完成的事。词库改了要先在 Engine 跑构建 workflow 并勾选 publish,再发 Windows 版本;通过 `scripts/product_lock.py refresh --dictionary-tag ` 更新产品锁并评审摘要变更;发布构建不能临时覆盖词库版本。 -签名沿用「有证书就签、没有就发未签名版」的策略:配置了 `WINDOWS_SIGNING_CERTIFICATE_BASE64` 和 `WINDOWS_SIGNING_CERTIFICATE_PASSWORD` 两个 secret 时,用 `signtool` 签包内 EXE/DLL 和安装包;没配置时产物名带 `-unsigned` 后缀,并在 release 说明里写明 `uiAccess` 不会生效。`Sign-PackageBinaries-Local.ps1` 使用本机自签名证书,只用于本地验证,CI 不会调用它。 +签名沿用「有证书就签、没有就发未签名版」的策略:配置了 `WINDOWS_SIGNING_CERTIFICATE_BASE64` 和 `WINDOWS_SIGNING_CERTIFICATE_PASSWORD` 两个 secret 时,用 `signtool` 签包内 EXE/DLL 和安装包;没配置时产物名带 `-unsigned` 后缀,并在 release 说明里写明 `uiAccess` 不会生效。Server 的 `uiAccess=true` manifest 必须在上传和签名之前由 `scripts/ci/embed-server-manifest.ps1` 注入并验证;`Sign-PackageBinaries-Local.ps1` 使用本机自签名证书,只用于本地验证,CI 不会调用它。 ## 提交 diff --git a/installer/tests/build-failures.ps1 b/installer/tests/build-failures.ps1 index aab7750e..d1853825 100644 --- a/installer/tests/build-failures.ps1 +++ b/installer/tests/build-failures.ps1 @@ -10,6 +10,9 @@ try { New-Item -ItemType Directory -Force $scripts | Out-Null Copy-Item (Join-Path $source "$component/scripts/lcompile-release.ps1") $scripts } + $ciScripts = Join-Path $fixture 'scripts/ci' + New-Item -ItemType Directory -Force $ciScripts | Out-Null + Copy-Item (Join-Path $source 'scripts/ci/embed-server-manifest.ps1') $ciScripts function global:cmake { $global:MsimeBuildCalls += 1 $global:LASTEXITCODE = if ($global:MsimeBuildCalls -eq $global:MsimeFailAt) { 17 } else { 0 } @@ -41,6 +44,31 @@ try { try { & (Join-Path $fixture 'server/scripts/lcompile-release.ps1') -ManifestTool Invoke-MsimeManifestProbe } catch { $rejected = $_.Exception.Message -eq 'Server manifest embedding failed (31)' } if (-not $rejected) { throw 'Failed manifest embedding was accepted' } + + # The release workflow builds Server directly instead of calling lcompile-release.ps1, so + # exercise the standalone CI embedding step too. This keeps the manifest-before-signing + # contract covered without requiring a Windows SDK in this orchestration test. + $releaseBinary = Join-Path $fixture 'server/build-release/bin/Release/MetasequoiaImeServer.exe' + $releaseManifest = Join-Path $fixture 'server/MetasequoiaImeServer.manifest' + New-Item -ItemType Directory -Force (Split-Path $releaseBinary) | Out-Null + New-Item -ItemType Directory -Force (Split-Path $releaseManifest) | Out-Null + [IO.File]::WriteAllText($releaseBinary, 'binary') + [IO.File]::WriteAllText($releaseManifest, '') + $global:MsimeManifestArguments = @() + $global:MsimeManifestExit = 0 + $expectedInputResource = '-inputresource:' + $releaseBinary + ';#1' + function global:Invoke-MsimeManifestProbe { + $global:MsimeManifestArguments = @($args) + $global:LASTEXITCODE = 0 + if ($args.Count -eq 2 -and $args[0] -like '-inputresource:*;#1') { + $outPath = ($args[1] -replace '^-out:', '') + [IO.File]::WriteAllText($outPath, '') + } + } + & (Join-Path $ciScripts 'embed-server-manifest.ps1') -BuildDir 'server/build-release/bin/Release' -ManifestTool Invoke-MsimeManifestProbe + if ($global:MsimeManifestArguments.Count -ne 2 -or $global:MsimeManifestArguments[0] -ne $expectedInputResource) { + throw 'CI manifest embedding did not verify resource 1' + } Remove-Item Function:/cmake # Each installer entry must stop before staging/signing when the settings build fails. foreach ($relative in @('windows/scripts/lcompile-release-both.ps1', 'server/scripts/lcompile-release.ps1')) { diff --git a/scripts/ci/embed-server-manifest.ps1 b/scripts/ci/embed-server-manifest.ps1 new file mode 100644 index 00000000..91cc1302 --- /dev/null +++ b/scripts/ci/embed-server-manifest.ps1 @@ -0,0 +1,61 @@ +# Embed the Server's uiAccess manifest before the binary is uploaded or signed. +# Authenticode signs the exact PE bytes, so this must run before sign-binaries.ps1. +[CmdletBinding()] +param( + [string]$BuildDir = 'server/build-release/bin/Release', + [string]$ManifestPath = 'server/MetasequoiaImeServer.manifest', + [string]$ManifestTool +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '../..')).Path +$buildRoot = (Resolve-Path (Join-Path $repoRoot $BuildDir)).Path +$binary = Join-Path $buildRoot 'MetasequoiaImeServer.exe' +$manifest = (Resolve-Path (Join-Path $repoRoot $ManifestPath)).Path + +if (-not (Test-Path -LiteralPath $binary -PathType Leaf)) { + throw "Server binary not found: $binary" +} + +if ([string]::IsNullOrWhiteSpace($ManifestTool)) { + $ManifestTool = (Get-ChildItem 'C:\Program Files (x86)\Windows Kits\10\bin\*\x64\mt.exe' | + Sort-Object FullName -Descending | Select-Object -First 1).FullName +} + +$manifestCommand = if ([string]::IsNullOrWhiteSpace($ManifestTool)) { + $null +} +else { + Get-Command -Name $ManifestTool -ErrorAction SilentlyContinue +} +if ([string]::IsNullOrWhiteSpace($ManifestTool) -or + (-not $manifestCommand -and -not (Test-Path -LiteralPath $ManifestTool -PathType Leaf))) { + throw 'mt.exe not found in the Windows SDK.' +} + +Write-Host "Embedding $manifest into $binary" +& $ManifestTool -manifest $manifest "-outputresource:$binary;1" +if ($LASTEXITCODE -ne 0) { + throw "Server manifest embedding failed ($LASTEXITCODE)" +} + +# Read the resource back through mt.exe so a successful process exit cannot hide a +# wrong resource target or a malformed manifest. The temporary file never enters the +# package and is removed even when validation fails. +$probe = Join-Path ([IO.Path]::GetTempPath()) ("msime-server-manifest-" + [Guid]::NewGuid() + '.xml') +try { + & $ManifestTool "-inputresource:$binary;#1" "-out:$probe" + if ($LASTEXITCODE -ne 0) { + throw "Server manifest verification failed ($LASTEXITCODE)" + } + $embedded = Get-Content -LiteralPath $probe -Raw + if ($embedded -notmatch 'uiAccess\s*=\s*["'']true["'']') { + throw 'Embedded Server manifest does not enable uiAccess.' + } + Write-Host 'Verified embedded Server manifest: uiAccess=true.' +} +finally { + Remove-Item -LiteralPath $probe -Force -ErrorAction SilentlyContinue +}