返回 DeepSeek-Reasonix
main_windows_test.go
根目录 / desktop / cmd / update-helper / main_windows_test.go
1 //go:build windows
2
3 package main
4
5 import (
6 "crypto/sha256"
7 "encoding/hex"
8 "errors"
9 "io"
10 "log"
11 "os"
12 "path/filepath"
13 "strings"
14 "testing"
15 "time"
16
17 "reasonix/internal/installlayout"
18 "reasonix/internal/repair"
19 )
20
21 const testInstallerSHA256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
22
23 func prepareLegacyWindowsUpdate(t *testing.T, installDir, toVersion string) *repair.UpdateTransaction {
24 t.Helper()
25 paths := windowsReleaseUnitPaths(installDir)
26 for _, path := range paths {
27 if err := os.WriteFile(path, []byte("old-"+filepath.Base(path)), 0o700); err != nil {
28 t.Fatal(err)
29 }
30 }
31 tx, err := repair.PrepareFileUpdate("v1", toVersion, paths[0], paths[1:]...)
32 if err != nil {
33 t.Fatal(err)
34 }
35 return tx
36 }
37
38 func TestStageVerifiedInstallerFreezesExpectedBytes(t *testing.T) {
39 source := filepath.Join(t.TempDir(), "installer.exe")
40 content := []byte("verified-installer")
41 if err := os.WriteFile(source, content, 0o700); err != nil {
42 t.Fatal(err)
43 }
44 sum := sha256.Sum256(content)
45 expected := hex.EncodeToString(sum[:])
46 staged, cleanup, err := stageVerifiedInstaller(source, expected)
47 if err != nil {
48 t.Fatal(err)
49 }
50 t.Cleanup(func() { _ = cleanup() })
51 if staged == source {
52 t.Fatal("installer was not isolated from the mutable cache path")
53 }
54 if err := os.WriteFile(source, []byte("changed-after-handoff"), 0o700); err != nil {
55 t.Fatal(err)
56 }
57 if err := verifyInstallerSHA256(staged, expected); err != nil {
58 t.Fatalf("isolated installer changed with source: %v", err)
59 }
60 if got, err := os.ReadFile(staged); err != nil || string(got) != string(content) {
61 t.Fatalf("staged installer = %q, %v", got, err)
62 }
63 if err := os.WriteFile(staged, []byte("tampered"), 0o700); err != nil {
64 t.Fatal(err)
65 }
66 if err := verifyInstallerSHA256(staged, expected); err == nil {
67 t.Fatal("tampered staged installer passed final verification")
68 }
69 }
70
71 func TestStageVerifiedInstallerRejectsSourceHashDrift(t *testing.T) {
72 source := filepath.Join(t.TempDir(), "installer.exe")
73 if err := os.WriteFile(source, []byte("different"), 0o700); err != nil {
74 t.Fatal(err)
75 }
76 if _, cleanup, err := stageVerifiedInstaller(source, testInstallerSHA256); err == nil {
77 if cleanup != nil {
78 _ = cleanup()
79 }
80 t.Fatal("installer bytes different from desktop verification were accepted")
81 }
82 }
83
84 func TestRunRequiresTargetVersionBeforeStartingInstaller(t *testing.T) {
85 stubDesktopHandoff(t)
86 if code := run([]string{
87 "--installer", `C:\Temp\Reasonix-installer.exe`,
88 "--installer-sha256", testInstallerSHA256,
89 }); code != 2 {
90 t.Fatalf("run without --to-version = %d, want 2", code)
91 }
92 }
93
94 func TestRunVersionedLayoutDoesNotReadOrClaimLegacyPending(t *testing.T) {
95 stubDesktopHandoff(t)
96 installDir := t.TempDir()
97 seed := t.TempDir()
98 for _, name := range []string{"reasonix-desktop.exe", "reasonix-cli.exe", "reasonix-update-helper.exe"} {
99 if err := os.WriteFile(filepath.Join(seed, name), []byte("old-"+name), 0o700); err != nil {
100 t.Fatal(err)
101 }
102 }
103 if err := installlayout.ActivateVersion(installlayout.ActivationRequest{
104 InstallRoot: installDir,
105 Version: "v1.20.0",
106 RequestID: "seed-windows-helper",
107 Members: []installlayout.Member{
108 {Name: "reasonix-desktop.exe", Path: filepath.Join(seed, "reasonix-desktop.exe")},
109 {Name: "reasonix-cli.exe", Path: filepath.Join(seed, "reasonix-cli.exe")},
110 {Name: "reasonix-update-helper.exe", Path: filepath.Join(seed, "reasonix-update-helper.exe")},
111 },
112 RequiredNames: []string{"reasonix-desktop.exe", "reasonix-cli.exe", "reasonix-update-helper.exe"},
113 }); err != nil {
114 t.Fatal(err)
115 }
116
117 originalInstaller := runInstallerFn
118 originalRelaunch := startRelaunchFn
119 originalClaim := claimPendingFileUpdateFn
120 originalStageInstaller := stageInstallerFn
121 originalClaimInstallerExecution := claimInstallerExecutionFn
122 originalReconcileUninstall := reconcileWindowsUninstallRegistrationFn
123 originalVerify := verifyDesktopHandoffFn
124 t.Cleanup(func() {
125 runInstallerFn = originalInstaller
126 startRelaunchFn = originalRelaunch
127 claimPendingFileUpdateFn = originalClaim
128 stageInstallerFn = originalStageInstaller
129 claimInstallerExecutionFn = originalClaimInstallerExecution
130 reconcileWindowsUninstallRegistrationFn = originalReconcileUninstall
131 verifyDesktopHandoffFn = originalVerify
132 })
133 verifyDesktopHandoffFn = func(string, bool) error { return nil }
134 legacyClaimed := false
135 claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
136 legacyClaimed = true
137 return nil, func() {}, errors.New("legacy claim must not run")
138 }
139 stageInstallerFn = func(path, _ string) (string, func() error, error) {
140 return path, func() error { return nil }, nil
141 }
142 claimInstallerExecutionFn = func(string, string) (func(), error) { return func() {}, nil }
143 reconciledVersion := ""
144 reconcileWindowsUninstallRegistrationFn = func(root, targetVersion string) (bool, error) {
145 if root != installDir {
146 t.Fatalf("reconcile root = %q, want %q", root, installDir)
147 }
148 reconciledVersion = targetVersion
149 return true, nil
150 }
151 acceptWindowsPayloadManifestForTest(t)
152 runInstallerFn = func(_ string, staging string) error {
153 writeVersionedWindowsStaging(t, staging, "new-", "v1.20.1")
154 return nil
155 }
156 relaunched := false
157 startRelaunchFn = func(string, string) error { relaunched = true; return nil }
158
159 if code := run([]string{
160 "--installer", filepath.Join(installDir, "installer.exe"),
161 "--installer-sha256", testInstallerSHA256,
162 "--install-dir", installDir,
163 "--relaunch", filepath.Join(installDir, "reasonix-launcher.exe"),
164 "--to-version", "v1.20.1",
165 "--install-layout", "versioned-v1",
166 }); code != 0 {
167 t.Fatalf("run exit code = %d", code)
168 }
169 if legacyClaimed {
170 t.Fatal("versioned update claimed legacy pending transaction")
171 }
172 if !relaunched {
173 t.Fatal("versioned update did not relaunch")
174 }
175 if reconciledVersion != "v1.20.1" {
176 t.Fatalf("reconciled version = %q, want v1.20.1", reconciledVersion)
177 }
178 ptr, err := installlayout.ReadCurrent(installDir)
179 if err != nil || ptr.ActiveVersion != "v1.20.1" {
180 t.Fatalf("pointer=%+v err=%v", ptr, err)
181 }
182 }
183
184 func TestRunVersionedUpdateRelaunchesLauncherNotOldDesktop(t *testing.T) {
185 stubDesktopHandoff(t)
186 installDir := t.TempDir()
187 seed := t.TempDir()
188 acceptWindowsPayloadManifestForTest(t)
189 writeVersionedWindowsStaging(t, seed, "old-", "v1.24.0")
190 if err := activateVersionedWindowsFromStaging(versionedWindowsTransaction(installDir, "v1.24.0", "2026-01-01T00:00:00Z"), seed); err != nil {
191 t.Fatal(err)
192 }
193 oldDesktop, err := installlayout.ActiveDesktopPath(installDir)
194 if err != nil {
195 t.Fatal(err)
196 }
197
198 originalInstaller := runInstallerFn
199 originalRelaunch := startRelaunchFn
200 originalStageInstaller := stageInstallerFn
201 originalClaimInstallerExecution := claimInstallerExecutionFn
202 originalReconcileUninstall := reconcileWindowsUninstallRegistrationFn
203 originalVerify := verifyDesktopHandoffFn
204 t.Cleanup(func() {
205 runInstallerFn = originalInstaller
206 startRelaunchFn = originalRelaunch
207 stageInstallerFn = originalStageInstaller
208 claimInstallerExecutionFn = originalClaimInstallerExecution
209 reconcileWindowsUninstallRegistrationFn = originalReconcileUninstall
210 verifyDesktopHandoffFn = originalVerify
211 })
212 stageInstallerFn = func(path, _ string) (string, func() error, error) {
213 return path, func() error { return nil }, nil
214 }
215 claimInstallerExecutionFn = func(string, string) (func(), error) { return func() {}, nil }
216 reconcileWindowsUninstallRegistrationFn = func(string, string) (bool, error) { return true, nil }
217 runInstallerFn = func(_ string, staging string) error {
218 writeVersionedWindowsStaging(t, staging, "new-", "v1.24.1")
219 return nil
220 }
221 verified := false
222 verifyDesktopHandoffFn = func(root string, _ bool) error {
223 if root != installDir {
224 t.Fatalf("verify root = %q", root)
225 }
226 verified = true
227 return nil
228 }
229 var relaunchPath string
230 startRelaunchFn = func(path, dir string) error {
231 relaunchPath = path
232 if dir != installDir {
233 t.Fatalf("relaunch dir = %q", dir)
234 }
235 return nil
236 }
237
238 if code := run([]string{
239 "--installer", filepath.Join(installDir, "installer.exe"),
240 "--installer-sha256", testInstallerSHA256,
241 "--install-dir", installDir,
242 "--relaunch", oldDesktop,
243 "--to-version", "v1.24.1",
244 "--install-layout", "versioned-v1",
245 }); code != 0 {
246 t.Fatalf("run exit code = %d", code)
247 }
248 if !verified {
249 t.Fatal("versioned update did not verify its desktop owner")
250 }
251 if filepath.Base(relaunchPath) != "Reasonix.exe" {
252 t.Fatalf("relaunch path = %s, want install-root launcher (not %s)", relaunchPath, oldDesktop)
253 }
254 if relaunchPath == oldDesktop {
255 t.Fatal("relaunch used the retained previous desktop")
256 }
257 }
258
259 func TestRelaunchPublishedInstallBlockedOwnerPreventsRelaunch(t *testing.T) {
260 stubDesktopHandoff(t)
261 originalVerify := verifyDesktopHandoffFn
262 originalRelaunch := startRelaunchFn
263 t.Cleanup(func() {
264 verifyDesktopHandoffFn = originalVerify
265 startRelaunchFn = originalRelaunch
266 })
267 verifyDesktopHandoffFn = func(string, bool) error {
268 return errors.New("old instance remains")
269 }
270 relaunched := false
271 startRelaunchFn = func(string, string) error {
272 relaunched = true
273 return nil
274 }
275 if code := relaunchPublishedInstall(
276 log.New(io.Discard, "", 0),
277 filepath.Join(t.TempDir(), "reasonix-desktop.exe"),
278 t.TempDir(),
279 "relaunch",
280 ); code != 1 {
281 t.Fatalf("relaunchPublishedInstall = %d, want 1", code)
282 }
283 if relaunched {
284 t.Fatal("relaunch started before the superseded desktop exit was confirmed")
285 }
286 }
287
288 func TestRunHoldsReleaseUnitLockAcrossInstallerHandoff(t *testing.T) {
289 stubDesktopHandoff(t)
290 t.Setenv("REASONIX_HOME", t.TempDir())
291 installDir := t.TempDir()
292 pending := prepareLegacyWindowsUpdate(t, installDir, "v2")
293 var events []string
294 originalWait := waitForProcessExitFn
295 originalInstaller := runInstallerFn
296 originalRelaunch := startRelaunchFn
297 originalClaim := claimPendingFileUpdateFn
298 originalInstallStaged := installStagedReleaseUnitFn
299 originalRecord := recordInstalledUpdateFn
300 originalStageInstaller := stageInstallerFn
301 originalClaimInstallerExecution := claimInstallerExecutionFn
302 t.Cleanup(func() {
303 waitForProcessExitFn = originalWait
304 runInstallerFn = originalInstaller
305 startRelaunchFn = originalRelaunch
306 claimPendingFileUpdateFn = originalClaim
307 installStagedReleaseUnitFn = originalInstallStaged
308 recordInstalledUpdateFn = originalRecord
309 stageInstallerFn = originalStageInstaller
310 claimInstallerExecutionFn = originalClaimInstallerExecution
311 })
312 waitForProcessExitFn = func(uint32, time.Duration) error {
313 events = append(events, "wait")
314 return nil
315 }
316 runInstallerFn = func(string, string) error {
317 events = append(events, "installer")
318 return nil
319 }
320 startRelaunchFn = func(string, string) error {
321 events = append(events, "relaunch")
322 return nil
323 }
324 claimPendingFileUpdateFn = func(toVersion, createdAt, transactionID, launcherPath string, paths []string, _ time.Duration) (*repair.UpdateTransaction, func(), error) {
325 events = append(events, "claim:"+strings.Join([]string{toVersion, createdAt, transactionID, launcherPath, strings.Join(paths, "\x00")}, "\x01"))
326 return pending, func() { events = append(events, "release") }, nil
327 }
328 installStagedReleaseUnitFn = func(*repair.UpdateTransaction, string) (bool, []repair.FileUpdateInstallReceipt, error) {
329 events = append(events, "publish")
330 return true, []repair.FileUpdateInstallReceipt{}, nil
331 }
332 recordInstalledUpdateFn = func(tx *repair.UpdateTransaction, _ ...repair.FileUpdateInstallReceipt) (*repair.UpdateTransaction, error) {
333 events = append(events, "record-installed")
334 return tx, nil
335 }
336 stageInstallerFn = func(path, sha256 string) (string, func() error, error) {
337 events = append(events, "stage-installer:"+sha256)
338 return path + ".claimed", func() error { return nil }, nil
339 }
340 claimInstallerExecutionFn = func(string, string) (func(), error) {
341 events = append(events, "claim-installer")
342 return func() { events = append(events, "release-installer") }, nil
343 }
344
345 if code := run([]string{
346 "--parent-pid", "1234",
347 "--installer", filepath.Join(installDir, "installer.exe"),
348 "--installer-sha256", testInstallerSHA256,
349 "--install-dir", installDir,
350 "--relaunch", filepath.Join(installDir, "reasonix-desktop.exe"),
351 "--to-version", pending.ToVersion,
352 "--created-at", pending.CreatedAt,
353 "--transaction-id", repair.UpdateTransactionID(pending),
354 }); code != 0 {
355 t.Fatalf("run exit code = %d", code)
356 }
357 wantClaim := "claim:" + strings.Join([]string{
358 pending.ToVersion,
359 pending.CreatedAt,
360 repair.UpdateTransactionID(pending),
361 filepath.Join(installDir, "reasonix-desktop.exe"),
362 strings.Join(windowsReleaseUnitPaths(installDir), "\x00"),
363 }, "\x01")
364 if len(events) != 10 || events[0] != "wait" || events[1] != wantClaim ||
365 events[2] != "stage-installer:"+testInstallerSHA256 ||
366 events[3] != "claim-installer" || events[4] != "installer" ||
367 events[5] != "release-installer" || events[6] != "publish" ||
368 events[7] != "record-installed" || events[8] != "relaunch" ||
369 events[9] != "release" {
370 t.Fatalf("handoff events = %#v", events)
371 }
372 }
373
374 func TestRunDoesNotClaimUpdateBeforeParentExits(t *testing.T) {
375 stubDesktopHandoff(t)
376 installDir := t.TempDir()
377 originalWait := waitForProcessExitFn
378 originalInstaller := runInstallerFn
379 originalClaim := claimPendingFileUpdateFn
380 originalRecord := recordInstalledUpdateFn
381 t.Cleanup(func() {
382 waitForProcessExitFn = originalWait
383 runInstallerFn = originalInstaller
384 claimPendingFileUpdateFn = originalClaim
385 recordInstalledUpdateFn = originalRecord
386 })
387 waitForProcessExitFn = func(uint32, time.Duration) error {
388 return errors.New("parent still running")
389 }
390 claimed := false
391 claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
392 claimed = true
393 return &repair.UpdateTransaction{}, func() {}, nil
394 }
395 installerRan := false
396 runInstallerFn = func(string, string) error {
397 installerRan = true
398 return nil
399 }
400
401 if code := run([]string{
402 "--parent-pid", "1234",
403 "--installer", filepath.Join(installDir, "installer.exe"),
404 "--installer-sha256", testInstallerSHA256,
405 "--install-dir", installDir,
406 "--to-version", "v2",
407 "--created-at", "2026-07-29T00:00:00Z",
408 "--transaction-id", "transaction-1",
409 }); code != 1 {
410 t.Fatalf("run exit code = %d, want 1", code)
411 }
412 if claimed || installerRan {
413 t.Fatalf("claim=%v installer=%v before parent exit", claimed, installerRan)
414 }
415 }
416
417 func TestRunRelaunchesWhenLegacyPendingCannotBeClaimed(t *testing.T) {
418 stubDesktopHandoff(t)
419 installDir := t.TempDir()
420 originalWait := waitForProcessExitFn
421 originalRelaunch := startRelaunchFn
422 originalClaim := claimPendingFileUpdateFn
423 t.Cleanup(func() {
424 waitForProcessExitFn = originalWait
425 startRelaunchFn = originalRelaunch
426 claimPendingFileUpdateFn = originalClaim
427 })
428
429 waitForProcessExitFn = func(uint32, time.Duration) error { return nil }
430 relaunched := false
431 startRelaunchFn = func(path, dir string) error {
432 relaunched = path == filepath.Join(installDir, "reasonix-desktop.exe") && dir == installDir
433 return nil
434 }
435 claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
436 return nil, nil, errors.New("pending lock unavailable")
437 }
438
439 if code := run([]string{
440 "--parent-pid", "1234",
441 "--installer", filepath.Join(installDir, "installer.exe"),
442 "--installer-sha256", testInstallerSHA256,
443 "--install-dir", installDir,
444 "--relaunch", filepath.Join(installDir, "reasonix-desktop.exe"),
445 "--to-version", "v2",
446 "--created-at", "2026-08-02T00:00:00Z",
447 "--transaction-id", strings.Repeat("a", 64),
448 }); code != 1 {
449 t.Fatalf("run exit code = %d, want 1", code)
450 }
451 if !relaunched {
452 t.Fatal("helper did not relaunch the existing desktop")
453 }
454 }
455
456 func TestRunCancelsTransactionWhenStagedExtractionFails(t *testing.T) {
457 stubDesktopHandoff(t)
458 t.Setenv("REASONIX_HOME", t.TempDir())
459 installDir := t.TempDir()
460 originalWait := waitForProcessExitFn
461 originalInstaller := runInstallerFn
462 originalRelaunch := startRelaunchFn
463 originalClaim := claimPendingFileUpdateFn
464 originalInstallStaged := installStagedReleaseUnitFn
465 originalRecord := recordInstalledUpdateFn
466 originalStageInstaller := stageInstallerFn
467 originalClaimInstallerExecution := claimInstallerExecutionFn
468 t.Cleanup(func() {
469 waitForProcessExitFn = originalWait
470 runInstallerFn = originalInstaller
471 startRelaunchFn = originalRelaunch
472 claimPendingFileUpdateFn = originalClaim
473 installStagedReleaseUnitFn = originalInstallStaged
474 recordInstalledUpdateFn = originalRecord
475 stageInstallerFn = originalStageInstaller
476 claimInstallerExecutionFn = originalClaimInstallerExecution
477 })
478 waitForProcessExitFn = func(uint32, time.Duration) error { return nil }
479 runInstallerFn = func(string, string) error { return errors.New("installer failed") }
480 startRelaunchFn = func(string, string) error { return nil }
481 claimed := prepareLegacyWindowsUpdate(t, installDir, "v2")
482 claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
483 return claimed, func() {}, nil
484 }
485 stageInstallerFn = func(path, _ string) (string, func() error, error) {
486 return path, func() error { return nil }, nil
487 }
488 claimInstallerExecutionFn = func(string, string) (func(), error) {
489 return func() {}, nil
490 }
491 transactionID := repair.UpdateTransactionID(claimed)
492 if code := run([]string{
493 "--installer", filepath.Join(installDir, "installer.exe"),
494 "--installer-sha256", testInstallerSHA256,
495 "--install-dir", installDir,
496 "--relaunch", filepath.Join(installDir, "reasonix-desktop.exe"),
497 "--to-version", "v2",
498 "--created-at", claimed.CreatedAt,
499 "--transaction-id", transactionID,
500 }); code != 1 {
501 t.Fatalf("run exit code = %d, want 1", code)
502 }
503 if _, ok := repair.ReadUpdateApplyFailure(); ok {
504 t.Fatal("staging-only installer failure left a recovery marker despite no live publish")
505 }
506 }
507
508 func TestRunTreatsInstalledReleaseUnitRecordingFailureAsApplyFailure(t *testing.T) {
509 stubDesktopHandoff(t)
510 t.Setenv("REASONIX_HOME", t.TempDir())
511 installDir := t.TempDir()
512 originalWait := waitForProcessExitFn
513 originalInstaller := runInstallerFn
514 originalRelaunch := startRelaunchFn
515 originalClaim := claimPendingFileUpdateFn
516 originalRecord := recordInstalledUpdateFn
517 originalStageInstaller := stageInstallerFn
518 originalClaimInstallerExecution := claimInstallerExecutionFn
519 t.Cleanup(func() {
520 waitForProcessExitFn = originalWait
521 runInstallerFn = originalInstaller
522 startRelaunchFn = originalRelaunch
523 claimPendingFileUpdateFn = originalClaim
524 recordInstalledUpdateFn = originalRecord
525 stageInstallerFn = originalStageInstaller
526 claimInstallerExecutionFn = originalClaimInstallerExecution
527 })
528 waitForProcessExitFn = func(uint32, time.Duration) error { return nil }
529 runInstallerFn = func(string, string) error { return nil }
530 relaunched := false
531 startRelaunchFn = func(string, string) error {
532 relaunched = true
533 return nil
534 }
535 claimed := prepareLegacyWindowsUpdate(t, installDir, "v2")
536 claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
537 return claimed, func() {}, nil
538 }
539 stageInstallerFn = func(path, _ string) (string, func() error, error) {
540 return path, func() error { return nil }, nil
541 }
542 claimInstallerExecutionFn = func(string, string) (func(), error) {
543 return func() {}, nil
544 }
545 installStagedReleaseUnitFn = func(*repair.UpdateTransaction, string) (bool, []repair.FileUpdateInstallReceipt, error) {
546 return true, []repair.FileUpdateInstallReceipt{{
547 UpdateTransactionID: repair.UpdateTransactionID(claimed),
548 TargetPath: claimed.TargetPath,
549 InstalledStateID: "installed",
550 }}, nil
551 }
552 recordInstalledUpdateFn = func(*repair.UpdateTransaction, ...repair.FileUpdateInstallReceipt) (*repair.UpdateTransaction, error) {
553 return nil, errors.New("release unit drifted")
554 }
555 transactionID := repair.UpdateTransactionID(claimed)
556
557 if code := run([]string{
558 "--installer", filepath.Join(installDir, "installer.exe"),
559 "--installer-sha256", testInstallerSHA256,
560 "--install-dir", installDir,
561 "--relaunch", filepath.Join(installDir, "reasonix-desktop.exe"),
562 "--to-version", "v2",
563 "--created-at", claimed.CreatedAt,
564 "--transaction-id", transactionID,
565 }); code != 1 {
566 t.Fatalf("run exit code = %d, want 1", code)
567 }
568 if !relaunched {
569 t.Fatal("helper did not relaunch through recovery after post-install verification failure")
570 }
571 failure, ok := repair.ReadUpdateApplyFailure()
572 if !ok || failure.UpdateTransactionID != transactionID {
573 t.Fatalf("failure marker = %+v, present=%v", failure, ok)
574 }
575 }
576
577 func TestRunRelaunchesWhenStagingIdentityCannotBeBound(t *testing.T) {
578 stubDesktopHandoff(t)
579 t.Setenv("REASONIX_HOME", t.TempDir())
580 installDir := t.TempDir()
581 originalWait := waitForProcessExitFn
582 originalInstaller := runInstallerFn
583 originalRelaunch := startRelaunchFn
584 originalClaim := claimPendingFileUpdateFn
585 originalStageInstaller := stageInstallerFn
586 originalLstatStaging := lstatUpdateStagingFn
587 t.Cleanup(func() {
588 waitForProcessExitFn = originalWait
589 runInstallerFn = originalInstaller
590 startRelaunchFn = originalRelaunch
591 claimPendingFileUpdateFn = originalClaim
592 stageInstallerFn = originalStageInstaller
593 lstatUpdateStagingFn = originalLstatStaging
594 })
595 waitForProcessExitFn = func(uint32, time.Duration) error { return nil }
596 installerRan := false
597 runInstallerFn = func(string, string) error {
598 installerRan = true
599 return nil
600 }
601 relaunched := false
602 startRelaunchFn = func(string, string) error {
603 relaunched = true
604 return nil
605 }
606 claimed := prepareLegacyWindowsUpdate(t, installDir, "v2")
607 releases := 0
608 claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
609 return claimed, func() { releases++ }, nil
610 }
611 stageInstallerFn = func(path, _ string) (string, func() error, error) {
612 return path, func() error { return nil }, nil
613 }
614 lstatUpdateStagingFn = func(string) (os.FileInfo, error) {
615 return nil, errors.New("staging identity unavailable")
616 }
617
618 if code := run([]string{
619 "--installer", filepath.Join(installDir, "installer.exe"),
620 "--installer-sha256", testInstallerSHA256,
621 "--install-dir", installDir,
622 "--relaunch", filepath.Join(installDir, "reasonix-desktop.exe"),
623 "--to-version", claimed.ToVersion,
624 "--created-at", claimed.CreatedAt,
625 "--transaction-id", repair.UpdateTransactionID(claimed),
626 }); code != 1 {
627 t.Fatalf("run exit code = %d, want 1", code)
628 }
629 if installerRan {
630 t.Fatal("installer ran without a bound staging directory")
631 }
632 if !relaunched {
633 t.Fatal("helper did not relaunch after staging identity failure")
634 }
635 if releases == 0 {
636 t.Fatal("helper did not release the update claim")
637 }
638 }
639
640 func TestClaimVerifiedInstallerForExecutionFreezesPath(t *testing.T) {
641 path := filepath.Join(t.TempDir(), "installer.exe")
642 content := []byte("verified-installer")
643 if err := os.WriteFile(path, content, 0o700); err != nil {
644 t.Fatal(err)
645 }
646 sum := sha256.Sum256(content)
647 release, err := claimVerifiedInstallerForExecution(path, hex.EncodeToString(sum[:]))
648 if err != nil {
649 t.Fatal(err)
650 }
651 if err := os.WriteFile(path, []byte("tampered"), 0o700); err == nil {
652 release()
653 t.Fatal("installer remained writable while execution claim was held")
654 }
655 if err := os.Rename(path, path+".replaced"); err == nil {
656 release()
657 t.Fatal("installer remained renameable while execution claim was held")
658 }
659 release()
660 release()
661 if err := os.WriteFile(path, []byte("replacement"), 0o700); err != nil {
662 t.Fatalf("installer remained frozen after claim release: %v", err)
663 }
664 }
665
666 func TestClaimVerifiedInstallerForExecutionRejectsHashDrift(t *testing.T) {
667 path := filepath.Join(t.TempDir(), "installer.exe")
668 if err := os.WriteFile(path, []byte("tampered"), 0o700); err != nil {
669 t.Fatal(err)
670 }
671 if release, err := claimVerifiedInstallerForExecution(path, testInstallerSHA256); err == nil {
672 release()
673 t.Fatal("installer with the wrong SHA-256 received an execution claim")
674 }
675 }
676
676 lines GO