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