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