| 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 |