返回 DeepSeek-Reasonix
windows_signing_packaging_test.go
根目录 / desktop / windows_signing_packaging_test.go
1 package main
2
3 import (
4 "encoding/xml"
5 "errors"
6 "os"
7 "os/exec"
8 "path/filepath"
9 "strings"
10 "testing"
11 )
12
13 type signPathArtifactConfiguration struct {
14 Zip signPathZip `xml:"zip-file"`
15 }
16
17 type signPathZip struct {
18 Files []signPathPEFile `xml:"pe-file"`
19 FileSets []signPathPEFileSet `xml:"pe-file-set"`
20 }
21
22 type signPathPEFile struct {
23 Path string `xml:"path,attr"`
24 Sign *struct{} `xml:"authenticode-sign"`
25 Verify *struct{} `xml:"authenticode-verify"`
26 }
27
28 type signPathPEFileSet struct {
29 Includes []struct {
30 Path string `xml:"path,attr"`
31 MinMatches string `xml:"min-matches,attr"`
32 } `xml:"include"`
33 ForEach struct {
34 Sign *struct{} `xml:"authenticode-sign"`
35 Verify *struct{} `xml:"authenticode-verify"`
36 } `xml:"for-each"`
37 }
38
39 func readTestFile(t *testing.T, path string) string {
40 t.Helper()
41 data, err := os.ReadFile(path)
42 if err != nil {
43 t.Fatal(err)
44 }
45 return string(data)
46 }
47
48 func parseSignPathConfiguration(t *testing.T, name string) signPathArtifactConfiguration {
49 t.Helper()
50 data, err := os.ReadFile(filepath.Join("..", ".signpath", "artifact-configurations", name))
51 if err != nil {
52 t.Fatal(err)
53 }
54 var config signPathArtifactConfiguration
55 if err := xml.Unmarshal(data, &config); err != nil {
56 t.Fatalf("parse %s: %v", name, err)
57 }
58 return config
59 }
60
61 func TestWindowsReleaseSignsPayloadBeforeRepackaging(t *testing.T) {
62 workflow := readTestFile(t, "../.github/workflows/release-desktop.yml")
63 finalizer := readTestFile(t, "../scripts/finalize-windows-signed-candidate.sh")
64 orderedSteps := []string{
65 "name: Build and package",
66 "name: Checkout protected release verifier",
67 "name: Smoke-test packaged Electron startup",
68 "name: Upload Windows signing inputs",
69 "name: Restore both native-tested Windows payloads",
70 "name: Connect to Certum",
71 "name: Finalize amd64 in the shared Certum session",
72 "name: Finalize arm64 in the shared Certum session",
73 "name: Upload signed package size reports",
74 }
75 last := -1
76 for _, step := range orderedSteps {
77 relativeIndex := strings.Index(workflow[last+1:], step)
78 index := last + 1 + relativeIndex
79 if relativeIndex < 0 {
80 t.Fatalf("desktop release workflow is missing %q", step)
81 }
82 if index <= last {
83 t.Fatalf("desktop release workflow step %q is out of order", step)
84 }
85 last = index
86 }
87 for _, want := range []string{
88 `uses: ./release-control/.github/actions/setup-certum`,
89 `github.repository == 'esengine/DeepSeek-Reasonix'`,
90 `Certum credentials are required for public Windows releases`,
91 `SIGNPATH_RELEASE_SIGNING_ATTESTATION does not match the current protected signing contract`,
92 `(needs.build.result == 'success' || (needs.build.result == 'skipped' && inputs.preflight_artifact_prefix != '' && inputs.orchestrated && inputs.signing_preflight_verified))`,
93 `needs.windows-sign.result == 'success'`,
94 `go run ./cmd/signpath-contract fingerprint`,
95 `ref: ${{ github.workflow_sha }}`,
96 `path: release-control`,
97 `node desktop/packaging/smoke.mjs`,
98 `finalize-windows-signed-candidate.sh amd64`,
99 `finalize-windows-signed-candidate.sh arm64`,
100 } {
101 if !strings.Contains(workflow, want) {
102 t.Errorf("desktop release workflow is missing signing contract %q", want)
103 }
104 }
105 for _, want := range []string{
106 `sign-certum.ps1" -PayloadDirectory`,
107 `go run ./cmd/sign windows-payload`,
108 `go run ./cmd/sign sign`,
109 `go run ./cmd/sign verify`,
110 `REASONIX_REQUIRE_PAYLOAD_MANIFEST=1`,
111 `sign-certum.ps1" -FilePath "$installer"`,
112 `verify-windows-authenticode.ps1`,
113 `-ExpectedThumbprint "$CERTUM_KEY_ID"`,
114 `go run ./cmd/sign sign "$dist"/*`,
115 } {
116 if !strings.Contains(finalizer, want) {
117 t.Errorf("Windows signing finalizer is missing contract %q", want)
118 }
119 }
120 ciWorkflow := readTestFile(t, "../.github/workflows/ci.yml")
121 if !strings.Contains(ciWorkflow, `node packaging/smoke.mjs build/electron/windows-amd64/app`) {
122 t.Error("Windows CI must smoke the packaged Electron shell startup")
123 }
124 if strings.Contains(ciWorkflow, "webview2") || strings.Contains(ciWorkflow, "WebView2") {
125 t.Error("Windows CI must not reference the retired WebView2 smoke harness")
126 }
127 for _, forbidden := range []string{
128 `signing-policy-slug: test-signing`,
129 `artifact-configuration-slug: windows-installer-test-v2`,
130 `steps.ver.outputs.channel == 'canary'`,
131 } {
132 if strings.Contains(workflow, forbidden) {
133 t.Errorf("public desktop release workflow contains legacy Canary signing contract %q", forbidden)
134 }
135 }
136
137 packager := readTestFile(t, "../scripts/package-windows-desktop.sh")
138 copyMain := strings.Index(packager, `cp "$PAYLOAD/$BINNAME.exe" "$INSTALLER_DIR/$BINNAME.exe"`)
139 makeNSIS := strings.Index(packager, "makensis \\\n")
140 portable := strings.Index(packager, `cp "$PAYLOAD/$BINNAME.exe" "$portable_staging/versions/$version_label/$BINNAME.exe"`)
141 bundle := strings.Index(packager, `installer_bundle="$DESKTOP/build/windows/installer-signing-bundle"`)
142 if copyMain < 0 || makeNSIS < 0 || portable < 0 || bundle < 0 {
143 t.Fatal("Windows packager is missing the signed-payload packaging stages")
144 }
145 if !(copyMain < makeNSIS && makeNSIS < portable && portable < bundle) {
146 t.Fatalf("Windows package order must be payload copy -> NSIS -> portable -> signing bundle (copy=%d nsis=%d portable=%d bundle=%d)", copyMain, makeNSIS, portable, bundle)
147 }
148 for _, want := range []string{
149 `node "$DESKTOP/packaging/signing-files.mjs" "$PAYLOAD" --check`,
150 `cp "$PAYLOAD/$GUARDNAME.exe" "$INSTALLER_DIR/$GUARDNAME.exe"`,
151 `cp "$PAYLOAD/$LAUNCHERNAME.exe" "$INSTALLER_DIR/$LAUNCHERNAME.exe"`,
152 `cp "$PAYLOAD/$UPDATE_HELPER" "$INSTALLER_DIR/$UPDATE_HELPER"`,
153 `cp "$PAYLOAD/$WINDOWS_CLINAME.exe" "$INSTALLER_DIR/$WINDOWS_CLINAME.exe"`,
154 `cp -R "$PAYLOAD/app" "$INSTALLER_DIR/app"`,
155 `rm -f -- "$INSTALLER_DIR/$PAYLOAD_MANIFEST" "$INSTALLER_DIR/$PAYLOAD_SIGNATURE"`,
156 `cp "$PAYLOAD/$PAYLOAD_MANIFEST" "$INSTALLER_DIR/$PAYLOAD_MANIFEST"`,
157 `cp "$PAYLOAD/$PAYLOAD_SIGNATURE" "$INSTALLER_DIR/$PAYLOAD_SIGNATURE"`,
158 `REASONIX_REQUIRE_PAYLOAD_MANIFEST`,
159 `"-DARG_REASONIX_SIGNED_UNINSTALLER=${uninstaller_path}"`,
160 `cp "$PAYLOAD/$LAUNCHERNAME.exe" "$portable_staging/$APPNAME.exe"`,
161 `cp -R "$PAYLOAD/app" "$portable_staging/versions/$version_label/app"`,
162 `"$ROOT/scripts/verify-windows-portable.sh" "$portable_staging"`,
163 `cp -R "$PAYLOAD/app" "$installer_bundle/app"`,
164 } {
165 if !strings.Contains(packager, want) {
166 t.Errorf("Windows packager is missing payload contract %q", want)
167 }
168 }
169
170 verifier := readTestFile(t, "../scripts/verify-windows-authenticode.ps1")
171 for _, want := range []string{
172 "Get-AuthenticodeSignature",
173 "$signature.SignerCertificate",
174 "$signature.Status -ne \"Valid\"",
175 "Expand-Archive",
176 `Get-ChildItem -LiteralPath $extractRoot -Recurse -File`,
177 `$activeDir.Replace("\", "/") -ne "versions/$activeVersion"`,
178 `Portable = (Join-Path $activeDir "reasonix-desktop.exe")`,
179 `Portable = "Reasonix.exe"; Payload = "reasonix-launcher.exe"`,
180 `Compare-Object $expectedPE $actualPE`,
181 `[ValidateSet("canonical", "legacy-dual")]`,
182 "Get-FileHash -Algorithm SHA256",
183 } {
184 if !strings.Contains(verifier, want) {
185 t.Errorf("Windows Authenticode verifier is missing %q", want)
186 }
187 }
188
189 completer := readTestFile(t, "../scripts/complete-signpath-request.ps1")
190 for _, want := range []string{
191 `$request.signingPolicySlug -ne $ExpectedSigningPolicySlug`,
192 `$status.status -eq "WaitingForApproval"`,
193 `"$requestBaseUrl/Approve"`,
194 `"$requestBaseUrl/Status"`,
195 `"$requestBaseUrl/SignedArtifact"`,
196 `$status.status -ne "Completed"`,
197 `[switch]$WaitForExternalApproval`,
198 `if ($WaitForExternalApproval)`,
199 `Waiting for an authorized SignPath user to approve request`,
200 `OutputArtifactDirectory must resolve inside GITHUB_WORKSPACE`,
201 `[string]$ApiUrl = "https://app.signpath.io/api"`,
202 `Expand-Archive`,
203 } {
204 if !strings.Contains(completer, want) {
205 t.Errorf("SignPath request completer is missing %q", want)
206 }
207 }
208 }
209
210 func TestWindowsPackagerRejectsMissingOrPartialRequiredPayloadManifest(t *testing.T) {
211 for _, tc := range []struct {
212 name string
213 manifest bool
214 signature bool
215 want string
216 }{
217 {name: "missing", want: "signed Windows packaging requires"},
218 {name: "manifest only", manifest: true, want: "must be provided together"},
219 {name: "signature only", signature: true, want: "must be provided together"},
220 } {
221 t.Run(tc.name, func(t *testing.T) {
222 payload := t.TempDir()
223 for _, name := range []string{
224 "reasonix-desktop.exe",
225 "reasonix-guard.exe",
226 "reasonix-launcher.exe",
227 "reasonix-update-helper.exe",
228 "reasonix-cli.exe",
229 "reasonix-uninstall.exe",
230 } {
231 if err := os.WriteFile(filepath.Join(payload, name), []byte(name), 0o600); err != nil {
232 t.Fatal(err)
233 }
234 }
235 // The packager validates the Electron app/ tree and signing-files.txt
236 // before the manifest gate, so the fixture must carry both.
237 if err := os.MkdirAll(filepath.Join(payload, "app"), 0o700); err != nil {
238 t.Fatal(err)
239 }
240 if err := os.WriteFile(filepath.Join(payload, "app", "Reasonix.exe"), []byte("shell"), 0o600); err != nil {
241 t.Fatal(err)
242 }
243 signingList := "app/Reasonix.exe\nreasonix-cli.exe\nreasonix-desktop.exe\nreasonix-guard.exe\nreasonix-launcher.exe\nreasonix-uninstall.exe\nreasonix-update-helper.exe\n"
244 if err := os.WriteFile(filepath.Join(payload, "signing-files.txt"), []byte(signingList), 0o600); err != nil {
245 t.Fatal(err)
246 }
247 if tc.manifest {
248 if err := os.WriteFile(filepath.Join(payload, "reasonix-payload.json"), []byte("{}"), 0o600); err != nil {
249 t.Fatal(err)
250 }
251 }
252 if tc.signature {
253 if err := os.WriteFile(filepath.Join(payload, "reasonix-payload.json.minisig"), []byte("sig"), 0o600); err != nil {
254 t.Fatal(err)
255 }
256 }
257 cmd := exec.Command("bash", "../scripts/package-windows-desktop.sh", "amd64", payload)
258 cmd.Env = append(os.Environ(), "REASONIX_REQUIRE_PAYLOAD_MANIFEST=1")
259 output, err := cmd.CombinedOutput()
260 if err == nil || !strings.Contains(string(output), tc.want) {
261 t.Fatalf("packager error = %v, output = %q, want %q", err, output, tc.want)
262 }
263 })
264 }
265 }
266
267 func TestProductionSigningRunsOnlyFromProtectedControlPlane(t *testing.T) {
268 stable := readTestFile(t, "../.github/workflows/release-stable.yml")
269 candidate := readTestFile(t, "../.github/workflows/release-candidate.yml")
270 promote := readTestFile(t, "../.github/workflows/release-promote.yml")
271 desktop := readTestFile(t, "../.github/workflows/release-desktop.yml")
272 if strings.Contains(stable, "\n push:\n") ||
273 strings.Contains(promote, "\n push:\n") || strings.Contains(desktop, "\n push:\n") {
274 t.Fatal("production workflows must not run directly with a tag-shaped SignPath origin")
275 }
276 if strings.Contains(candidate, "\n tags:") || strings.Contains(candidate, "\n pull_request") ||
277 !strings.Contains(candidate, "\n push:\n branches: [main-v2]\n paths:\n - release-notes/releases.json") {
278 t.Fatal("automatic preparation must use the protected Notes push, never tags or PR heads")
279 }
280 activation := readTestFile(t, "../scripts/release-candidate-tags.sh")
281 for _, want := range []string{
282 `actions/attest-build-provenance@v3`,
283 `candidate_preparation: true`,
284 `git push --atomic "$remote"`,
285 `environment: release`,
286 `candidate_verified: true`,
287 } {
288 if !strings.Contains(candidate+"\n"+promote+"\n"+activation, want) {
289 t.Errorf("sealed release control plane is missing %q", want)
290 }
291 }
292 if _, err := os.Stat("../.github/workflows/release-stable-trigger.yml"); !errors.Is(err, os.ErrNotExist) {
293 t.Errorf("retired tag relay still exists or cannot be checked: %v", err)
294 }
295
296 for _, path := range []string{
297 "../.github/workflows/release-preview.yml",
298 "../.github/workflows/release-cli-trigger.yml",
299 "../.github/workflows/release-desktop-trigger.yml",
300 } {
301 if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
302 t.Errorf("retired public prerelease workflow %s still exists or cannot be checked: %v", path, err)
303 }
304 }
305 }
306
307 func TestSignPathConfigurationsCoverExactWindowsPayload(t *testing.T) {
308 flatPayload := map[string]bool{
309 "reasonix-desktop.exe": true,
310 "reasonix-guard.exe": true,
311 "reasonix-launcher.exe": true,
312 "reasonix-update-helper.exe": true,
313 "reasonix-cli.exe": true,
314 "reasonix-uninstall.exe": true,
315 }
316
317 payload := parseSignPathConfiguration(t, "windows-payload.xml")
318 // The signed unit is the flat Go payload plus every PE file in the Electron
319 // app/ tree: Reasonix.exe is explicit, the rest ride the pe-file-set glob.
320 if len(payload.Zip.Files) != len(flatPayload)+1 {
321 t.Fatalf("windows-payload.xml files = %d, want %d", len(payload.Zip.Files), len(flatPayload)+1)
322 }
323 for _, file := range payload.Zip.Files {
324 if !flatPayload[file.Path] && file.Path != "app/Reasonix.exe" {
325 t.Errorf("windows-payload.xml contains unexpected path %q", file.Path)
326 }
327 if file.Sign == nil || file.Verify != nil {
328 t.Errorf("windows-payload.xml %q must sign, not verify", file.Path)
329 }
330 }
331 if len(payload.Zip.FileSets) != 1 {
332 t.Fatalf("windows-payload.xml pe-file-sets = %d, want 1", len(payload.Zip.FileSets))
333 }
334 payloadSet := payload.Zip.FileSets[0]
335 if payloadSet.ForEach.Sign == nil || payloadSet.ForEach.Verify != nil {
336 t.Error("windows-payload.xml pe-file-set must sign every app/ PE file")
337 }
338 for _, want := range []string{"app/**/*.exe", "app/**/*.dll"} {
339 found := false
340 for _, include := range payloadSet.Includes {
341 if include.Path == want && include.MinMatches == "1" {
342 found = true
343 }
344 }
345 if !found {
346 t.Errorf("windows-payload.xml pe-file-set must include %s with min-matches=1", want)
347 }
348 }
349
350 installer := parseSignPathConfiguration(t, "windows-installer-v2.xml")
351 if len(installer.Zip.Files) != len(flatPayload)+1 {
352 t.Fatalf("windows-installer.xml files = %d, want %d", len(installer.Zip.Files), len(flatPayload)+1)
353 }
354 verified := 0
355 signedInstaller := 0
356 for _, file := range installer.Zip.Files {
357 switch {
358 case file.Path == "*installer*.exe":
359 if file.Sign == nil || file.Verify != nil {
360 t.Error("windows-installer.xml must sign the outer installer")
361 }
362 signedInstaller++
363 case flatPayload[file.Path]:
364 if file.Verify == nil || file.Sign != nil {
365 t.Errorf("windows-installer.xml %q must verify, not re-sign", file.Path)
366 }
367 verified++
368 default:
369 t.Errorf("windows-installer.xml contains unexpected path %q", file.Path)
370 }
371 }
372 if signedInstaller != 1 || verified != len(flatPayload) {
373 t.Fatalf("windows-installer.xml signed installers=%d verified payload=%d", signedInstaller, verified)
374 }
375 if len(installer.Zip.FileSets) != 1 {
376 t.Fatalf("windows-installer.xml pe-file-sets = %d, want 1", len(installer.Zip.FileSets))
377 }
378 installerSet := installer.Zip.FileSets[0]
379 if installerSet.ForEach.Verify == nil || installerSet.ForEach.Sign != nil {
380 t.Error("windows-installer.xml pe-file-set must verify, not re-sign, the app/ tree")
381 }
382 for _, want := range []string{"app/**/*.exe", "app/**/*.dll"} {
383 found := false
384 for _, include := range installerSet.Includes {
385 if include.Path == want {
386 found = true
387 }
388 }
389 if !found {
390 t.Errorf("windows-installer.xml pe-file-set must include %s", want)
391 }
392 }
393
394 testInstaller := parseSignPathConfiguration(t, "windows-installer-test-v2.xml")
395 if len(testInstaller.Zip.Files) != 1 {
396 t.Fatalf("windows-installer-test-v2.xml files = %d, want 1", len(testInstaller.Zip.Files))
397 }
398 file := testInstaller.Zip.Files[0]
399 if file.Path != "*installer*.exe" || file.Sign == nil || file.Verify != nil {
400 t.Fatal("windows-installer-test-v2.xml must only sign the outer installer")
401 }
402 }
403
403 lines GO