返回 DeepSeek-Reasonix
update_test.go
根目录 / internal / repair / update_test.go
1 package repair
2
3 import (
4 "bytes"
5 "encoding/json"
6 "errors"
7 "os"
8 "path/filepath"
9 "reflect"
10 "strings"
11 "sync"
12 "testing"
13 "time"
14 )
15
16 func fileUpdateInstallReceiptsForTest(t *testing.T, tx *UpdateTransaction) []FileUpdateInstallReceipt {
17 t.Helper()
18 receipts := make([]FileUpdateInstallReceipt, 0, len(tx.Files))
19 for _, file := range tx.Files {
20 if _, err := os.Lstat(file.TargetPath); err != nil {
21 if os.IsNotExist(err) && file.MissingBefore {
22 continue
23 }
24 t.Fatalf("inspect installed test release member %s: %v", file.TargetPath, err)
25 }
26 receipts = append(receipts, FileUpdateInstallReceipt{
27 UpdateTransactionID: UpdateTransactionID(tx),
28 TargetPath: file.TargetPath,
29 InstalledStateID: repairPlanReleaseNodeState(file.TargetPath),
30 })
31 }
32 return receipts
33 }
34
35 func TestFileUpdateRollbackRestoresPreviousBinary(t *testing.T) {
36 home := t.TempDir()
37 t.Setenv("REASONIX_HOME", home)
38 target := filepath.Join(t.TempDir(), "reasonix-desktop")
39 originalExecutable := repairExecutable
40 repairExecutable = func() (string, error) { return filepath.Join(filepath.Dir(target), "reasonix-guard"), nil }
41 t.Cleanup(func() { repairExecutable = originalExecutable })
42 if err := os.WriteFile(target, []byte("old"), 0o700); err != nil {
43 t.Fatal(err)
44 }
45 if _, err := PrepareFileUpdate("v1", "v2", target); err != nil {
46 t.Fatal(err)
47 }
48 if err := os.WriteFile(target, []byte("new"), 0o700); err != nil {
49 t.Fatal(err)
50 }
51 result, err := RollbackPendingUpdate()
52 if err != nil {
53 t.Fatal(err)
54 }
55 if !result.RolledBack || result.ToVersion != "v1" {
56 t.Fatalf("rollback result = %+v", result)
57 }
58 got, err := os.ReadFile(target)
59 if err != nil {
60 t.Fatal(err)
61 }
62 if string(got) != "old" {
63 t.Fatalf("restored binary = %q", got)
64 }
65 }
66
67 func TestReconcilePendingFileUpdateCancelsPreparedReleaseUnit(t *testing.T) {
68 t.Setenv("REASONIX_HOME", t.TempDir())
69 target := filepath.Join(t.TempDir(), "reasonix-desktop")
70 originalExecutable := repairExecutable
71 repairExecutable = func() (string, error) { return filepath.Join(filepath.Dir(target), "reasonix-guard"), nil }
72 t.Cleanup(func() { repairExecutable = originalExecutable })
73 if err := os.WriteFile(target, []byte("old"), 0o700); err != nil {
74 t.Fatal(err)
75 }
76 tx, err := PrepareFileUpdate("v1", "v2", target)
77 if err != nil {
78 t.Fatal(err)
79 }
80
81 result, err := ReconcilePendingUpdate("v2")
82 if err != nil {
83 t.Fatal(err)
84 }
85 if !result.Cleared || result.RolledBack || result.AwaitingHealth {
86 t.Fatalf("reconcile result = %+v", result)
87 }
88 if got, err := os.ReadFile(target); err != nil || string(got) != "old" {
89 t.Fatalf("prepared target changed = %q, %v", got, err)
90 }
91 for _, file := range tx.Files {
92 if _, err := os.Stat(file.BackupPath); !os.IsNotExist(err) {
93 t.Fatalf("prepared backup survived cancellation: %v", err)
94 }
95 }
96 if PendingUpdateExists() {
97 t.Fatal("pending file update survived cancellation")
98 }
99 }
100
101 func TestReconcilePendingFileUpdateLeavesBoundProbationaryReleaseUnit(t *testing.T) {
102 t.Setenv("REASONIX_HOME", t.TempDir())
103 target := filepath.Join(t.TempDir(), "reasonix-desktop")
104 originalExecutable := repairExecutable
105 repairExecutable = func() (string, error) { return filepath.Join(filepath.Dir(target), "reasonix-guard"), nil }
106 t.Cleanup(func() { repairExecutable = originalExecutable })
107 if err := os.WriteFile(target, []byte("old"), 0o700); err != nil {
108 t.Fatal(err)
109 }
110 tx, err := PrepareFileUpdate("v1", "v2", target)
111 if err != nil {
112 t.Fatal(err)
113 }
114 receipt, err := PublishClaimedFileUpdateMemberExact(tx, target, []byte("new"), 0o700)
115 if err != nil {
116 t.Fatal(err)
117 }
118 if _, err := RecordClaimedFileUpdateInstalled(tx, receipt); err != nil {
119 t.Fatal(err)
120 }
121
122 result, err := ReconcilePendingUpdate("v2")
123 if !errors.Is(err, ErrPendingUpdateAwaitingHealth) {
124 t.Fatalf("reconcile error = %v", err)
125 }
126 if !result.Pending || !result.AwaitingHealth || result.Cleared || result.RolledBack {
127 t.Fatalf("reconcile result = %+v", result)
128 }
129 if got, err := os.ReadFile(target); err != nil || string(got) != "new" {
130 t.Fatalf("probationary target changed = %q, %v", got, err)
131 }
132 if !PendingUpdateExists() {
133 t.Fatal("probationary transaction was removed")
134 }
135 }
136
137 func TestReconcilePendingFileUpdateRollsBackPublishedReleaseUnit(t *testing.T) {
138 t.Setenv("REASONIX_HOME", t.TempDir())
139 target := filepath.Join(t.TempDir(), "reasonix-desktop")
140 originalExecutable := repairExecutable
141 repairExecutable = func() (string, error) { return filepath.Join(filepath.Dir(target), "reasonix-guard"), nil }
142 t.Cleanup(func() { repairExecutable = originalExecutable })
143 if err := os.WriteFile(target, []byte("old"), 0o700); err != nil {
144 t.Fatal(err)
145 }
146 if _, err := PrepareFileUpdate("v1", "v2", target); err != nil {
147 t.Fatal(err)
148 }
149 if err := os.WriteFile(target, []byte("new"), 0o700); err != nil {
150 t.Fatal(err)
151 }
152
153 result, err := ReconcilePendingUpdate("v1")
154 if err != nil {
155 t.Fatal(err)
156 }
157 if !result.RolledBack || result.Cleared || result.AwaitingHealth {
158 t.Fatalf("reconcile result = %+v", result)
159 }
160 if got, err := os.ReadFile(target); err != nil || string(got) != "old" {
161 t.Fatalf("published target was not restored = %q, %v", got, err)
162 }
163 if PendingUpdateExists() {
164 t.Fatal("pending file update survived rollback")
165 }
166 }
167
168 func TestAppBundleRollbackRestoresWhenLiveBundleIsMissing(t *testing.T) {
169 home := t.TempDir()
170 t.Setenv("REASONIX_HOME", home)
171 dir, err := filepath.EvalSymlinks(t.TempDir())
172 if err != nil {
173 t.Fatal(err)
174 }
175 app := filepath.Join(dir, "Reasonix.app")
176 exe := filepath.Join(app, "Contents", "MacOS", "Reasonix")
177 if err := os.MkdirAll(filepath.Dir(exe), 0o700); err != nil {
178 t.Fatal(err)
179 }
180 if err := os.WriteFile(exe, []byte("old"), 0o700); err != nil {
181 t.Fatal(err)
182 }
183 originalExecutable := repairExecutable
184 repairExecutable = func() (string, error) { return exe, nil }
185 t.Cleanup(func() { repairExecutable = originalExecutable })
186
187 backup := app + ".reasonix-update-backup"
188 if _, err := PrepareAppBundleUpdate("v1", "v2", app, backup); err != nil {
189 t.Fatal(err)
190 }
191 if err := os.Rename(app, backup); err != nil {
192 t.Fatal(err)
193 }
194 result, err := RollbackPendingUpdate()
195 if err != nil {
196 t.Fatal(err)
197 }
198 if !result.RolledBack || result.ToVersion != "v1" {
199 t.Fatalf("rollback result = %+v", result)
200 }
201 got, err := os.ReadFile(exe)
202 if err != nil || string(got) != "old" {
203 t.Fatalf("restored bundle executable = %q (%v)", got, err)
204 }
205 if _, err := os.Stat(backup); !os.IsNotExist(err) {
206 t.Fatalf("backup bundle survived successful rollback: %v", err)
207 }
208 if HasPendingUpdate() {
209 t.Fatal("pending update survived successful rollback")
210 }
211 }
212
213 func TestAppBundleRollbackRecognizesAlreadyRestoredBundle(t *testing.T) {
214 home := t.TempDir()
215 t.Setenv("REASONIX_HOME", home)
216 dir, err := filepath.EvalSymlinks(t.TempDir())
217 if err != nil {
218 t.Fatal(err)
219 }
220 app := filepath.Join(dir, "Reasonix.app")
221 exe := filepath.Join(app, "Contents", "MacOS", "Reasonix")
222 if err := os.MkdirAll(filepath.Dir(exe), 0o700); err != nil {
223 t.Fatal(err)
224 }
225 if err := os.WriteFile(exe, []byte("old"), 0o700); err != nil {
226 t.Fatal(err)
227 }
228 originalExecutable := repairExecutable
229 repairExecutable = func() (string, error) { return exe, nil }
230 t.Cleanup(func() { repairExecutable = originalExecutable })
231
232 backup := app + ".reasonix-update-backup"
233 if _, err := PrepareAppBundleUpdate("v1", "v2", app, backup); err != nil {
234 t.Fatal(err)
235 }
236 // This is the durable state after backup -> live succeeded but the process
237 // exited before pending-update.json was removed. It is also the safe
238 // pre-handoff cancellation state.
239 result, err := RollbackPendingUpdate()
240 if err != nil || !result.RolledBack {
241 t.Fatalf("already-restored rollback = %+v, %v", result, err)
242 }
243 if got, err := os.ReadFile(exe); err != nil || string(got) != "old" {
244 t.Fatalf("recognized live bundle = %q, %v", got, err)
245 }
246 if HasPendingUpdate() {
247 t.Fatal("already-restored app transaction was not cleared")
248 }
249 }
250
251 func TestMarkUpdateHealthyRejectsAppBundleDrift(t *testing.T) {
252 home := t.TempDir()
253 t.Setenv("REASONIX_HOME", home)
254 dir, err := filepath.EvalSymlinks(t.TempDir())
255 if err != nil {
256 t.Fatal(err)
257 }
258 app := filepath.Join(dir, "Reasonix.app")
259 exe := filepath.Join(app, "Contents", "MacOS", "Reasonix")
260 if err := os.MkdirAll(filepath.Dir(exe), 0o700); err != nil {
261 t.Fatal(err)
262 }
263 if err := os.WriteFile(exe, []byte("old"), 0o700); err != nil {
264 t.Fatal(err)
265 }
266 originalExecutable := repairExecutable
267 repairExecutable = func() (string, error) { return exe, nil }
268 t.Cleanup(func() { repairExecutable = originalExecutable })
269
270 backup := app + ".reasonix-update-backup"
271 staging, err := os.MkdirTemp("", "reasonix-mac-update-*")
272 if err != nil {
273 t.Fatal(err)
274 }
275 t.Cleanup(func() { _ = os.RemoveAll(staging) })
276 stagedApp := filepath.Join(staging, "Reasonix.app")
277 stagedExe := filepath.Join(stagedApp, "Contents", "MacOS", "Reasonix")
278 if err := os.MkdirAll(filepath.Dir(stagedExe), 0o700); err != nil {
279 t.Fatal(err)
280 }
281 if err := os.WriteFile(stagedExe, []byte("new"), 0o700); err != nil {
282 t.Fatal(err)
283 }
284 tx, err := PrepareAppBundleUpdateHandoff(
285 "v1", "v2", app, backup, stagedApp, staging, os.Getpid(),
286 )
287 if err != nil {
288 t.Fatal(err)
289 }
290 if err := os.Rename(app, backup); err != nil {
291 t.Fatal(err)
292 }
293 if err := os.MkdirAll(filepath.Dir(exe), 0o700); err != nil {
294 t.Fatal(err)
295 }
296 if err := os.WriteFile(exe, []byte("new"), 0o700); err != nil {
297 t.Fatal(err)
298 }
299 if err := os.WriteFile(exe, []byte("drifted-after-launch"), 0o700); err != nil {
300 t.Fatal(err)
301 }
302
303 if err := MarkUpdateHealthyMatching(tx.ToVersion, tx.CreatedAt); err == nil ||
304 !strings.Contains(err.Error(), "installed bundle differs") {
305 t.Fatalf("healthy commit error = %v, want installed-bundle drift rejection", err)
306 }
307 if _, err := ReadPendingUpdate(); err != nil {
308 t.Fatalf("rejected healthy commit removed pending transaction: %v", err)
309 }
310 if _, err := os.Stat(backup); err != nil {
311 t.Fatalf("rejected healthy commit removed rollback backup: %v", err)
312 }
313 }
314
315 func TestMarkUpdateHealthyRejectsLegacyAppBundleWithoutInstalledDigest(t *testing.T) {
316 home := t.TempDir()
317 t.Setenv("REASONIX_HOME", home)
318 dir, err := filepath.EvalSymlinks(t.TempDir())
319 if err != nil {
320 t.Fatal(err)
321 }
322 app := filepath.Join(dir, "Reasonix.app")
323 exe := filepath.Join(app, "Contents", "MacOS", "Reasonix")
324 if err := os.MkdirAll(filepath.Dir(exe), 0o700); err != nil {
325 t.Fatal(err)
326 }
327 if err := os.WriteFile(exe, []byte("old"), 0o700); err != nil {
328 t.Fatal(err)
329 }
330 originalExecutable := repairExecutable
331 repairExecutable = func() (string, error) { return exe, nil }
332 t.Cleanup(func() { repairExecutable = originalExecutable })
333
334 backup := app + ".reasonix-update-backup"
335 tx, err := PrepareAppBundleUpdate("v1", "v2", app, backup)
336 if err != nil {
337 t.Fatal(err)
338 }
339 if err := os.Rename(app, backup); err != nil {
340 t.Fatal(err)
341 }
342 if err := os.MkdirAll(filepath.Dir(exe), 0o700); err != nil {
343 t.Fatal(err)
344 }
345 if err := os.WriteFile(exe, []byte("new"), 0o700); err != nil {
346 t.Fatal(err)
347 }
348
349 if err := MarkUpdateHealthyMatching(tx.ToVersion, tx.CreatedAt); err == nil ||
350 !strings.Contains(err.Error(), "installed bundle state is missing") {
351 t.Fatalf("legacy healthy commit = %v, want fail-closed rejection", err)
352 }
353 if !HasPendingUpdate() {
354 t.Fatal("legacy healthy rejection removed pending transaction")
355 }
356 if _, err := os.Stat(backup); err != nil {
357 t.Fatalf("legacy healthy rejection removed rollback backup: %v", err)
358 }
359 if got, err := os.ReadFile(exe); err != nil || string(got) != "new" {
360 t.Fatalf("legacy healthy rejection changed live bundle: %q, %v", got, err)
361 }
362 }
363
364 func TestAppBundleRollbackRejectsChangedBackupTree(t *testing.T) {
365 home := t.TempDir()
366 t.Setenv("REASONIX_HOME", home)
367 dir, err := filepath.EvalSymlinks(t.TempDir())
368 if err != nil {
369 t.Fatal(err)
370 }
371 app := filepath.Join(dir, "Reasonix.app")
372 exe := filepath.Join(app, "Contents", "MacOS", "Reasonix")
373 if err := os.MkdirAll(filepath.Dir(exe), 0o700); err != nil {
374 t.Fatal(err)
375 }
376 if err := os.WriteFile(exe, []byte("old"), 0o700); err != nil {
377 t.Fatal(err)
378 }
379 originalExecutable := repairExecutable
380 repairExecutable = func() (string, error) { return exe, nil }
381 t.Cleanup(func() { repairExecutable = originalExecutable })
382
383 backup := app + ".reasonix-update-backup"
384 if _, err := PrepareAppBundleUpdate("v1", "v2", app, backup); err != nil {
385 t.Fatal(err)
386 }
387 if err := os.Rename(app, backup); err != nil {
388 t.Fatal(err)
389 }
390 if err := os.WriteFile(filepath.Join(backup, "Contents", "MacOS", "Reasonix"), []byte("tampered"), 0o700); err != nil {
391 t.Fatal(err)
392 }
393 if _, err := RollbackPendingUpdate(); err == nil || !strings.Contains(err.Error(), "digest mismatch") {
394 t.Fatalf("rollback error = %v, want backup digest mismatch", err)
395 }
396 if _, err := os.Stat(backup); err != nil {
397 t.Fatalf("changed backup was removed: %v", err)
398 }
399 if HasPendingUpdate() == false {
400 t.Fatal("pending update was lost after rejected rollback")
401 }
402 }
403
404 func TestAppBundleRollbackCompensatesBackupChangedDuringPublish(t *testing.T) {
405 home := t.TempDir()
406 t.Setenv("REASONIX_HOME", home)
407 dir, err := filepath.EvalSymlinks(t.TempDir())
408 if err != nil {
409 t.Fatal(err)
410 }
411 app := filepath.Join(dir, "Reasonix.app")
412 exe := filepath.Join(app, "Contents", "MacOS", "Reasonix")
413 if err := os.MkdirAll(filepath.Dir(exe), 0o700); err != nil {
414 t.Fatal(err)
415 }
416 if err := os.WriteFile(exe, []byte("old"), 0o700); err != nil {
417 t.Fatal(err)
418 }
419 originalExecutable := repairExecutable
420 repairExecutable = func() (string, error) { return exe, nil }
421 t.Cleanup(func() { repairExecutable = originalExecutable })
422
423 backup := app + ".reasonix-update-backup"
424 if _, err := PrepareAppBundleUpdate("v1", "v2", app, backup); err != nil {
425 t.Fatal(err)
426 }
427 if err := os.Rename(app, backup); err != nil {
428 t.Fatal(err)
429 }
430 if err := os.MkdirAll(filepath.Dir(exe), 0o700); err != nil {
431 t.Fatal(err)
432 }
433 if err := os.WriteFile(exe, []byte("new"), 0o700); err != nil {
434 t.Fatal(err)
435 }
436
437 originalSwap := rollbackSwapRename
438 rollbackSwapRename = func(oldPath, newPath string) error {
439 if err := renameRepairNodeNoReplace(oldPath, newPath); err != nil {
440 return err
441 }
442 if oldPath == backup && newPath == app {
443 return os.WriteFile(filepath.Join(app, "Contents", "MacOS", "Reasonix"), []byte("tampered-during-publish"), 0o700)
444 }
445 return nil
446 }
447 t.Cleanup(func() { rollbackSwapRename = originalSwap })
448
449 result, err := RollbackPendingUpdate()
450 if err == nil || !strings.Contains(err.Error(), "restored bundle digest mismatch") {
451 t.Fatalf("rollback error = %v, want post-publish digest rejection", err)
452 }
453 if result.MixedInstall {
454 t.Fatalf("rollback reported mixed install after successful compensation: %+v", result)
455 }
456 if got, err := os.ReadFile(exe); err != nil || string(got) != "new" {
457 t.Fatalf("prior live bundle was not restored: %q, %v", got, err)
458 }
459 rejected, err := filepath.Glob(app + ".reasonix-cleanup-*")
460 if err != nil || len(rejected) != 1 {
461 t.Fatalf("rejected backup bundle = %v, %v", rejected, err)
462 }
463 rejectedExe := filepath.Join(rejected[0], "Contents", "MacOS", "Reasonix")
464 if got, err := os.ReadFile(rejectedExe); err != nil || string(got) != "tampered-during-publish" {
465 t.Fatalf("rejected bundle was not preserved: %q, %v", got, err)
466 }
467 if !HasPendingUpdate() {
468 t.Fatal("failed rollback removed pending recovery state")
469 }
470 }
471
472 func TestLegacyAppBundleRollbackWithoutTreeDigest(t *testing.T) {
473 home := t.TempDir()
474 t.Setenv("REASONIX_HOME", home)
475 dir, err := filepath.EvalSymlinks(t.TempDir())
476 if err != nil {
477 t.Fatal(err)
478 }
479 app := filepath.Join(dir, "Reasonix.app")
480 exe := filepath.Join(app, "Contents", "MacOS", "Reasonix")
481 if err := os.MkdirAll(filepath.Dir(exe), 0o700); err != nil {
482 t.Fatal(err)
483 }
484 if err := os.WriteFile(exe, []byte("old"), 0o700); err != nil {
485 t.Fatal(err)
486 }
487 originalExecutable := repairExecutable
488 repairExecutable = func() (string, error) { return exe, nil }
489 t.Cleanup(func() { repairExecutable = originalExecutable })
490
491 backup := app + ".reasonix-update-backup"
492 tx, err := PrepareAppBundleUpdate("v1", "v2", app, backup)
493 if err != nil {
494 t.Fatal(err)
495 }
496 tx.BackupTreeID = ""
497 if err := overwritePendingUpdateForTest(tx); err != nil {
498 t.Fatal(err)
499 }
500 if err := os.Rename(app, backup); err != nil {
501 t.Fatal(err)
502 }
503 result, err := RollbackPendingUpdate()
504 if err == nil || result.RolledBack ||
505 !strings.Contains(err.Error(), "backup bundle identity is missing") {
506 t.Fatalf("legacy rollback result = %+v, %v", result, err)
507 }
508 if _, err := os.Lstat(app); !os.IsNotExist(err) {
509 t.Fatalf("legacy rollback recreated target without authorization: %v", err)
510 }
511 backupExe := filepath.Join(backup, "Contents", "MacOS", "Reasonix")
512 if got, err := os.ReadFile(backupExe); err != nil || string(got) != "old" {
513 t.Fatalf("legacy rollback changed backup bundle = %q, %v", got, err)
514 }
515 if !HasPendingUpdate() {
516 t.Fatal("legacy rollback removed pending recovery state")
517 }
518 }
519
520 func TestRepairPlanCanConfirmLegacyAppBundleRollbackWithoutTreeDigest(t *testing.T) {
521 home := t.TempDir()
522 t.Setenv("REASONIX_HOME", home)
523 dir, err := filepath.EvalSymlinks(t.TempDir())
524 if err != nil {
525 t.Fatal(err)
526 }
527 app := filepath.Join(dir, "Reasonix.app")
528 exe := filepath.Join(app, "Contents", "MacOS", "Reasonix")
529 if err := os.MkdirAll(filepath.Dir(exe), 0o700); err != nil {
530 t.Fatal(err)
531 }
532 if err := os.WriteFile(exe, []byte("old"), 0o700); err != nil {
533 t.Fatal(err)
534 }
535 originalExecutable := repairExecutable
536 repairExecutable = func() (string, error) { return exe, nil }
537 t.Cleanup(func() { repairExecutable = originalExecutable })
538
539 backup := app + ".reasonix-update-backup"
540 tx, err := PrepareAppBundleUpdate("v1", "v2", app, backup)
541 if err != nil {
542 t.Fatal(err)
543 }
544 tx.BackupTreeID = ""
545 if err := overwritePendingUpdateForTest(tx); err != nil {
546 t.Fatal(err)
547 }
548 if err := os.Rename(app, backup); err != nil {
549 t.Fatal(err)
550 }
551 if err := os.MkdirAll(filepath.Dir(exe), 0o700); err != nil {
552 t.Fatal(err)
553 }
554 if err := os.WriteFile(exe, []byte("new"), 0o700); err != nil {
555 t.Fatal(err)
556 }
557 plan := RepairPlan{
558 SchemaVersion: RepairPlanSchemaVersion,
559 Summary: "restore legacy app bundle",
560 Actions: []RepairPlanAction{{
561 Type: "rollback_update",
562 Reason: "confirmed recovery",
563 }},
564 }
565 preview, err := PreviewRepairPlan(plan, ApplyPlanOptions{})
566 if err != nil {
567 t.Fatal(err)
568 }
569 result, err := ApplyRepairPlan(plan, ApplyPlanOptions{
570 ExpectedPreviewID: RepairPlanPreviewID(plan, preview),
571 })
572 if err != nil || len(result.Applied) != 1 {
573 t.Fatalf("confirmed legacy rollback = %+v, %v", result, err)
574 }
575 if got, err := os.ReadFile(exe); err != nil || string(got) != "old" {
576 t.Fatalf("confirmed legacy restore = %q, %v", got, err)
577 }
578 if HasPendingUpdate() {
579 t.Fatal("confirmed legacy rollback retained pending transaction")
580 }
581 }
582
583 func TestRepairPlanRejectsLegacyAppBundleSymlinkBackupBeforeMutation(t *testing.T) {
584 t.Setenv("REASONIX_HOME", t.TempDir())
585 dir, err := filepath.EvalSymlinks(t.TempDir())
586 if err != nil {
587 t.Fatal(err)
588 }
589 app := filepath.Join(dir, "Reasonix.app")
590 exe := filepath.Join(app, "Contents", "MacOS", "Reasonix")
591 if err := os.MkdirAll(filepath.Dir(exe), 0o700); err != nil {
592 t.Fatal(err)
593 }
594 if err := os.WriteFile(exe, []byte("old"), 0o700); err != nil {
595 t.Fatal(err)
596 }
597 originalExecutable := repairExecutable
598 repairExecutable = func() (string, error) { return exe, nil }
599 t.Cleanup(func() { repairExecutable = originalExecutable })
600
601 backup := app + ".reasonix-update-backup"
602 tx, err := PrepareAppBundleUpdate("v1", "v2", app, backup)
603 if err != nil {
604 t.Fatal(err)
605 }
606 tx.BackupTreeID = ""
607 if err := overwritePendingUpdateForTest(tx); err != nil {
608 t.Fatal(err)
609 }
610 external := filepath.Join(dir, "external-backup")
611 if err := os.Rename(app, external); err != nil {
612 t.Fatal(err)
613 }
614 if err := os.Symlink(external, backup); err != nil {
615 t.Skipf("symlink unavailable: %v", err)
616 }
617 plan := RepairPlan{
618 SchemaVersion: RepairPlanSchemaVersion,
619 Summary: "restore legacy app backup",
620 Actions: []RepairPlanAction{{
621 Type: "rollback_update",
622 Reason: "explicitly confirmed recovery",
623 }},
624 }
625 preview, err := PreviewRepairPlan(plan, ApplyPlanOptions{})
626 if err != nil {
627 t.Fatal(err)
628 }
629 _, err = ApplyRepairPlan(plan, ApplyPlanOptions{
630 ExpectedPreviewID: RepairPlanPreviewID(plan, preview),
631 })
632 if err == nil || !strings.Contains(err.Error(), "backup bundle is not a directory") {
633 t.Fatalf("legacy symlink backup rollback = %v", err)
634 }
635 if got, err := os.Readlink(backup); err != nil || got != external {
636 t.Fatalf("backup symlink changed before rejection: %q, %v", got, err)
637 }
638 if _, err := os.Lstat(app); !os.IsNotExist(err) {
639 t.Fatalf("live app path changed before rejection: %v", err)
640 }
641 if got, err := os.ReadFile(filepath.Join(external, "Contents", "MacOS", "Reasonix")); err != nil || string(got) != "old" {
642 t.Fatalf("external backup changed: %q, %v", got, err)
643 }
644 if !HasPendingUpdate() {
645 t.Fatal("rejected legacy symlink backup removed pending state")
646 }
647 }
648
649 func TestRollbackPendingUpdateRejectsUnexpectedVersion(t *testing.T) {
650 home := t.TempDir()
651 t.Setenv("REASONIX_HOME", home)
652 dir, err := filepath.EvalSymlinks(t.TempDir())
653 if err != nil {
654 t.Fatal(err)
655 }
656 target := filepath.Join(dir, "reasonix-desktop")
657 originalExecutable := repairExecutable
658 repairExecutable = func() (string, error) { return filepath.Join(dir, "reasonix-guard"), nil }
659 t.Cleanup(func() { repairExecutable = originalExecutable })
660 if err := os.WriteFile(target, []byte("v1"), 0o700); err != nil {
661 t.Fatal(err)
662 }
663 if _, err := PrepareFileUpdate("v1", "v2", target); err != nil {
664 t.Fatal(err)
665 }
666 if err := os.WriteFile(target, []byte("v2"), 0o700); err != nil {
667 t.Fatal(err)
668 }
669 result, err := rollbackPendingUpdateInvocation("v3", "", "")
670 if err != nil || result.RolledBack {
671 t.Fatalf("rollback = %+v, %v", result, err)
672 }
673 got, err := os.ReadFile(target)
674 if err != nil || string(got) != "v2" {
675 t.Fatalf("target = %q (%v), want v2", got, err)
676 }
677 if !HasPendingUpdate() {
678 t.Fatal("mismatched rollback removed the pending transaction")
679 }
680 }
681
682 func TestRollbackPendingUpdateFailsClosedWhenStateLockIsUnavailable(t *testing.T) {
683 home := t.TempDir()
684 t.Setenv("REASONIX_HOME", home)
685 dir, err := filepath.EvalSymlinks(t.TempDir())
686 if err != nil {
687 t.Fatal(err)
688 }
689 target := filepath.Join(dir, "reasonix-desktop")
690 guard := filepath.Join(dir, "reasonix-guard")
691 originalExecutable := repairExecutable
692 repairExecutable = func() (string, error) { return guard, nil }
693 t.Cleanup(func() { repairExecutable = originalExecutable })
694 if err := os.WriteFile(target, []byte("old"), 0o700); err != nil {
695 t.Fatal(err)
696 }
697 if _, err := PrepareFileUpdate("v1", "v2", target); err != nil {
698 t.Fatal(err)
699 }
700 if err := os.WriteFile(target, []byte("new"), 0o700); err != nil {
701 t.Fatal(err)
702 }
703
704 originalAcquire := acquirePendingUpdateLock
705 acquirePendingUpdateLock = func() (func(), error) {
706 return nil, errors.New("injected lock failure")
707 }
708 t.Cleanup(func() { acquirePendingUpdateLock = originalAcquire })
709
710 if _, err := RollbackPendingUpdate(); err == nil || !strings.Contains(err.Error(), "lock pending transaction") {
711 t.Fatalf("rollback error = %v, want strict lock failure", err)
712 }
713 got, err := os.ReadFile(target)
714 if err != nil || string(got) != "new" {
715 t.Fatalf("target changed without pending lock: %q (%v)", got, err)
716 }
717 if !HasPendingUpdate() {
718 t.Fatal("pending update was removed without pending lock")
719 }
720 }
721
722 func TestFileUpdateRollbackRestoresReleaseUnit(t *testing.T) {
723 home := t.TempDir()
724 t.Setenv("REASONIX_HOME", home)
725 // Resolve symlinks up front (macOS /var -> /private/var) so the recorded
726 // target dir matches the resolved launcher dir in validation.
727 dir, err := filepath.EvalSymlinks(t.TempDir())
728 if err != nil {
729 t.Fatal(err)
730 }
731 target := filepath.Join(dir, "reasonix-desktop")
732 guard := filepath.Join(dir, "reasonix-guard")
733 originalExecutable := repairExecutable
734 repairExecutable = func() (string, error) { return guard, nil }
735 t.Cleanup(func() { repairExecutable = originalExecutable })
736 if err := os.WriteFile(target, []byte("old-desktop"), 0o700); err != nil {
737 t.Fatal(err)
738 }
739 if err := os.WriteFile(guard, []byte("old-guard"), 0o700); err != nil {
740 t.Fatal(err)
741 }
742 missingSibling := filepath.Join(dir, "reasonix-update-helper.exe")
743 tx, err := PrepareFileUpdate("v1", "v2", target, guard, missingSibling)
744 if err != nil {
745 t.Fatal(err)
746 }
747 if len(tx.Files) != 3 || !tx.Files[2].MissingBefore {
748 t.Fatalf("release unit files = %+v", tx.Files)
749 }
750 if err := os.WriteFile(target, []byte("new-desktop"), 0o700); err != nil {
751 t.Fatal(err)
752 }
753 if err := os.WriteFile(guard, []byte("new-guard"), 0o700); err != nil {
754 t.Fatal(err)
755 }
756 if err := os.WriteFile(missingSibling, []byte("new-helper"), 0o700); err != nil {
757 t.Fatal(err)
758 }
759 result, err := RollbackPendingUpdate()
760 if err != nil {
761 t.Fatal(err)
762 }
763 if !result.RolledBack || result.ToVersion != "v1" {
764 t.Fatalf("rollback result = %+v", result)
765 }
766 for path, want := range map[string]string{target: "old-desktop", guard: "old-guard"} {
767 got, err := os.ReadFile(path)
768 if err != nil {
769 t.Fatal(err)
770 }
771 if string(got) != want {
772 t.Fatalf("restored %s = %q, want %q", filepath.Base(path), got, want)
773 }
774 }
775 if _, err := os.Stat(missingSibling); !os.IsNotExist(err) {
776 t.Fatalf("new-release-only sibling survived rollback: %v", err)
777 }
778 }
779
780 func TestCancelPendingUpdateRemovesReleaseUnitBackups(t *testing.T) {
781 home := t.TempDir()
782 t.Setenv("REASONIX_HOME", home)
783 dir, err := filepath.EvalSymlinks(t.TempDir())
784 if err != nil {
785 t.Fatal(err)
786 }
787 target := filepath.Join(dir, "reasonix-desktop")
788 guard := filepath.Join(dir, "reasonix-guard")
789 originalExecutable := repairExecutable
790 repairExecutable = func() (string, error) { return guard, nil }
791 t.Cleanup(func() { repairExecutable = originalExecutable })
792 for path, body := range map[string]string{target: "desktop", guard: "guard"} {
793 if err := os.WriteFile(path, []byte(body), 0o700); err != nil {
794 t.Fatal(err)
795 }
796 }
797 tx, err := PrepareFileUpdate("v1", "v2", target, guard)
798 if err != nil {
799 t.Fatal(err)
800 }
801 if err := CancelPendingUpdate("v2"); err != nil {
802 t.Fatal(err)
803 }
804 if HasPendingUpdate() {
805 t.Fatal("pending update remains after cancel")
806 }
807 for _, f := range tx.Files {
808 if _, err := os.Stat(f.BackupPath); !os.IsNotExist(err) {
809 t.Fatalf("backup %s still exists: %v", f.BackupPath, err)
810 }
811 }
812 }
813
814 func TestPrepareFileUpdatePreservesExistingPendingTransaction(t *testing.T) {
815 home := t.TempDir()
816 t.Setenv("REASONIX_HOME", home)
817 dir, err := filepath.EvalSymlinks(t.TempDir())
818 if err != nil {
819 t.Fatal(err)
820 }
821 target := filepath.Join(dir, "reasonix-desktop")
822 guard := filepath.Join(dir, "reasonix-guard")
823 originalExecutable := repairExecutable
824 repairExecutable = func() (string, error) { return guard, nil }
825 t.Cleanup(func() { repairExecutable = originalExecutable })
826 if err := os.WriteFile(target, []byte("v1"), 0o700); err != nil {
827 t.Fatal(err)
828 }
829 first, err := PrepareFileUpdate("v1", "v2", target)
830 if err != nil {
831 t.Fatal(err)
832 }
833 pendingBefore, err := os.ReadFile(PendingUpdatePath())
834 if err != nil {
835 t.Fatal(err)
836 }
837 backupBefore, err := os.ReadFile(first.BackupPath)
838 if err != nil {
839 t.Fatal(err)
840 }
841 if err := os.WriteFile(target, []byte("v2"), 0o700); err != nil {
842 t.Fatal(err)
843 }
844
845 if _, err := PrepareFileUpdate("v2", "v3", target); err == nil ||
846 !strings.Contains(err.Error(), "pending update already exists") {
847 t.Fatalf("second prepare error = %v, want existing-pending rejection", err)
848 }
849 pendingAfter, err := os.ReadFile(PendingUpdatePath())
850 if err != nil {
851 t.Fatal(err)
852 }
853 backupAfter, err := os.ReadFile(first.BackupPath)
854 if err != nil {
855 t.Fatal(err)
856 }
857 if !bytes.Equal(pendingAfter, pendingBefore) {
858 t.Fatal("rejected prepare changed the existing pending transaction")
859 }
860 if !bytes.Equal(backupAfter, backupBefore) {
861 t.Fatalf("rejected prepare changed rollback backup: got %q, want %q", backupAfter, backupBefore)
862 }
863 }
864
865 func TestExactUpdateTransitionsIgnoreLaterSameVersionTransaction(t *testing.T) {
866 t.Run("cancel", func(t *testing.T) {
867 home := t.TempDir()
868 t.Setenv("REASONIX_HOME", home)
869 dir, err := filepath.EvalSymlinks(t.TempDir())
870 if err != nil {
871 t.Fatal(err)
872 }
873 target := filepath.Join(dir, "reasonix-desktop")
874 guard := filepath.Join(dir, "reasonix-guard")
875 originalExecutable := repairExecutable
876 repairExecutable = func() (string, error) { return guard, nil }
877 t.Cleanup(func() { repairExecutable = originalExecutable })
878 if err := os.WriteFile(target, []byte("old"), 0o700); err != nil {
879 t.Fatal(err)
880 }
881 first, err := PrepareFileUpdate("v1", "v2", target)
882 if err != nil {
883 t.Fatal(err)
884 }
885 second := *first
886 second.CreatedAt = time.Now().UTC().Add(time.Second).Format(time.RFC3339Nano)
887 if err := overwritePendingUpdateForTest(&second); err != nil {
888 t.Fatal(err)
889 }
890 if err := CancelPendingUpdateMatching(first.ToVersion, first.CreatedAt); err != nil {
891 t.Fatal(err)
892 }
893 current, err := ReadPendingUpdate()
894 if err != nil || current.CreatedAt != second.CreatedAt {
895 t.Fatalf("stale cancel changed current transaction: %+v, %v", current, err)
896 }
897 })
898
899 t.Run("healthy", func(t *testing.T) {
900 home := t.TempDir()
901 t.Setenv("REASONIX_HOME", home)
902 dir, err := filepath.EvalSymlinks(t.TempDir())
903 if err != nil {
904 t.Fatal(err)
905 }
906 target := filepath.Join(dir, "reasonix-desktop")
907 guard := filepath.Join(dir, "reasonix-guard")
908 originalExecutable := repairExecutable
909 repairExecutable = func() (string, error) { return guard, nil }
910 t.Cleanup(func() { repairExecutable = originalExecutable })
911 if err := os.WriteFile(target, []byte("old"), 0o700); err != nil {
912 t.Fatal(err)
913 }
914 first, err := PrepareFileUpdate("v1", "v2", target)
915 if err != nil {
916 t.Fatal(err)
917 }
918 second := *first
919 second.CreatedAt = time.Now().UTC().Add(time.Second).Format(time.RFC3339Nano)
920 if err := overwritePendingUpdateForTest(&second); err != nil {
921 t.Fatal(err)
922 }
923 if _, err := RecordClaimedFileUpdateInstalled(&second, fileUpdateInstallReceiptsForTest(t, &second)...); err != nil {
924 t.Fatal(err)
925 }
926 if err := MarkUpdateHealthyMatching("v2", first.CreatedAt); err != nil {
927 t.Fatal(err)
928 }
929 current, err := ReadPendingUpdate()
930 if err != nil || current.CreatedAt != second.CreatedAt {
931 t.Fatalf("stale health commit changed current transaction: %+v, %v", current, err)
932 }
933 if err := MarkUpdateHealthyMatching("v2", second.CreatedAt); err != nil {
934 t.Fatal(err)
935 }
936 if _, err := ReadPendingUpdate(); !os.IsNotExist(err) {
937 t.Fatalf("matching health commit left pending transaction: %v", err)
938 }
939 })
940
941 t.Run("rollback", func(t *testing.T) {
942 home := t.TempDir()
943 t.Setenv("REASONIX_HOME", home)
944 dir, err := filepath.EvalSymlinks(t.TempDir())
945 if err != nil {
946 t.Fatal(err)
947 }
948 target := filepath.Join(dir, "reasonix-desktop")
949 guard := filepath.Join(dir, "reasonix-guard")
950 originalExecutable := repairExecutable
951 repairExecutable = func() (string, error) { return guard, nil }
952 t.Cleanup(func() { repairExecutable = originalExecutable })
953 if err := os.WriteFile(target, []byte("old"), 0o700); err != nil {
954 t.Fatal(err)
955 }
956 first, err := PrepareFileUpdate("v1", "v2", target)
957 if err != nil {
958 t.Fatal(err)
959 }
960 second := *first
961 second.CreatedAt = time.Now().UTC().Add(time.Second).Format(time.RFC3339Nano)
962 if err := overwritePendingUpdateForTest(&second); err != nil {
963 t.Fatal(err)
964 }
965 if err := os.WriteFile(target, []byte("new"), 0o700); err != nil {
966 t.Fatal(err)
967 }
968 result, err := RollbackPendingUpdateMatching(first.ToVersion, first.CreatedAt)
969 if err != nil || result.RolledBack {
970 t.Fatalf("stale rollback = %+v, %v", result, err)
971 }
972 if got, err := os.ReadFile(target); err != nil || string(got) != "new" {
973 t.Fatalf("stale rollback changed target: %q, %v", got, err)
974 }
975 result, err = RollbackPendingUpdateMatching(second.ToVersion, second.CreatedAt)
976 if err != nil || !result.RolledBack {
977 t.Fatalf("matching rollback = %+v, %v", result, err)
978 }
979 })
980 }
981
982 func TestUpdateTransitionsRecheckPendingAfterTargetLock(t *testing.T) {
983 t.Run("rollback", func(t *testing.T) {
984 home := t.TempDir()
985 t.Setenv("REASONIX_HOME", home)
986 dir, err := filepath.EvalSymlinks(t.TempDir())
987 if err != nil {
988 t.Fatal(err)
989 }
990 target := filepath.Join(dir, "reasonix-desktop")
991 guard := filepath.Join(dir, "reasonix-guard")
992 originalExecutable := repairExecutable
993 repairExecutable = func() (string, error) { return guard, nil }
994 t.Cleanup(func() { repairExecutable = originalExecutable })
995 if err := os.WriteFile(target, []byte("old"), 0o700); err != nil {
996 t.Fatal(err)
997 }
998 tx, err := PrepareFileUpdate("v1", "v2", target)
999 if err != nil {
1000 t.Fatal(err)
1001 }
1002 if err := os.WriteFile(target, []byte("new"), 0o700); err != nil {
1003 t.Fatal(err)
1004 }
1005 changed := *tx
1006 changed.FromVersion = "rewritten"
1007 originalHook := repairMutationBeforeLock
1008 var once sync.Once
1009 repairMutationBeforeLock = func([]string) {
1010 once.Do(func() {
1011 if err := overwritePendingUpdateForTest(&changed); err != nil {
1012 t.Fatal(err)
1013 }
1014 })
1015 }
1016 t.Cleanup(func() { repairMutationBeforeLock = originalHook })
1017
1018 result, err := RollbackPendingUpdateExact(tx)
1019 if err == nil || result.RolledBack || !strings.Contains(err.Error(), "changed while waiting") {
1020 t.Fatalf("rollback after pending rewrite = %+v, %v", result, err)
1021 }
1022 if got, err := os.ReadFile(target); err != nil || string(got) != "new" {
1023 t.Fatalf("rollback touched target after pending rewrite: %q, %v", got, err)
1024 }
1025 current, err := ReadPendingUpdate()
1026 if err != nil || current.FromVersion != changed.FromVersion {
1027 t.Fatalf("rewritten pending transaction = %+v, %v", current, err)
1028 }
1029 })
1030
1031 t.Run("healthy", func(t *testing.T) {
1032 home := t.TempDir()
1033 t.Setenv("REASONIX_HOME", home)
1034 dir, err := filepath.EvalSymlinks(t.TempDir())
1035 if err != nil {
1036 t.Fatal(err)
1037 }
1038 target := filepath.Join(dir, "reasonix-desktop")
1039 guard := filepath.Join(dir, "reasonix-guard")
1040 originalExecutable := repairExecutable
1041 repairExecutable = func() (string, error) { return guard, nil }
1042 t.Cleanup(func() { repairExecutable = originalExecutable })
1043 if err := os.WriteFile(target, []byte("old"), 0o700); err != nil {
1044 t.Fatal(err)
1045 }
1046 tx, err := PrepareFileUpdate("v1", "v2", target)
1047 if err != nil {
1048 t.Fatal(err)
1049 }
1050 if err := os.WriteFile(target, []byte("new"), 0o700); err != nil {
1051 t.Fatal(err)
1052 }
1053 changed := *tx
1054 changed.FromVersion = "rewritten"
1055 originalHook := repairMutationBeforeLock
1056 var once sync.Once
1057 repairMutationBeforeLock = func([]string) {
1058 once.Do(func() {
1059 if err := overwritePendingUpdateForTest(&changed); err != nil {
1060 t.Fatal(err)
1061 }
1062 })
1063 }
1064 t.Cleanup(func() { repairMutationBeforeLock = originalHook })
1065
1066 if err := MarkUpdateHealthyMatching(tx.ToVersion, tx.CreatedAt); err == nil ||
1067 !strings.Contains(err.Error(), "changed while waiting") {
1068 t.Fatalf("healthy commit after pending rewrite = %v", err)
1069 }
1070 if _, err := ReadPendingUpdate(); err != nil {
1071 t.Fatalf("healthy commit removed rewritten transaction: %v", err)
1072 }
1073 if _, err := os.Stat(tx.BackupPath); err != nil {
1074 t.Fatalf("healthy commit removed backup after pending rewrite: %v", err)
1075 }
1076 })
1077 }
1078
1079 func TestGenericUpdateTransitionsBindPendingBeforeWaitingForLock(t *testing.T) {
1080 t.Run("rollback", func(t *testing.T) {
1081 t.Setenv("REASONIX_HOME", t.TempDir())
1082 dir := t.TempDir()
1083 target := filepath.Join(dir, "reasonix-desktop")
1084 guard := filepath.Join(dir, "reasonix-guard")
1085 originalExecutable := repairExecutable
1086 repairExecutable = func() (string, error) { return guard, nil }
1087 t.Cleanup(func() { repairExecutable = originalExecutable })
1088 if err := os.WriteFile(target, []byte("old"), 0o700); err != nil {
1089 t.Fatal(err)
1090 }
1091 tx, err := PrepareFileUpdate("v1", "v2", target)
1092 if err != nil {
1093 t.Fatal(err)
1094 }
1095 if err := os.WriteFile(target, []byte("new"), 0o700); err != nil {
1096 t.Fatal(err)
1097 }
1098 replacement := *tx
1099 replacement.FromVersion = "replacement"
1100 originalAcquire := acquirePendingUpdateLock
1101 acquirePendingUpdateLock = func() (func(), error) {
1102 if err := overwritePendingUpdateForTest(&replacement); err != nil {
1103 t.Fatal(err)
1104 }
1105 return func() {}, nil
1106 }
1107 t.Cleanup(func() { acquirePendingUpdateLock = originalAcquire })
1108
1109 result, err := RollbackPendingUpdate()
1110 if err == nil || result.RolledBack ||
1111 !strings.Contains(err.Error(), "pending transaction changed") {
1112 t.Fatalf("generic rollback after pending-lock replacement = %+v, %v", result, err)
1113 }
1114 if got, err := os.ReadFile(target); err != nil || string(got) != "new" {
1115 t.Fatalf("replacement transaction target changed: %q, %v", got, err)
1116 }
1117 })
1118
1119 t.Run("healthy", func(t *testing.T) {
1120 t.Setenv("REASONIX_HOME", t.TempDir())
1121 dir := t.TempDir()
1122 target := filepath.Join(dir, "reasonix-desktop")
1123 guard := filepath.Join(dir, "reasonix-guard")
1124 originalExecutable := repairExecutable
1125 repairExecutable = func() (string, error) { return guard, nil }
1126 t.Cleanup(func() { repairExecutable = originalExecutable })
1127 if err := os.WriteFile(target, []byte("old"), 0o700); err != nil {
1128 t.Fatal(err)
1129 }
1130 tx, err := PrepareFileUpdate("v1", "v2", target)
1131 if err != nil {
1132 t.Fatal(err)
1133 }
1134 if err := os.WriteFile(target, []byte("new"), 0o700); err != nil {
1135 t.Fatal(err)
1136 }
1137 if _, err := RecordClaimedFileUpdateInstalled(tx, fileUpdateInstallReceiptsForTest(t, tx)...); err != nil {
1138 t.Fatal(err)
1139 }
1140 replacement := *tx
1141 replacement.FromVersion = "replacement"
1142 originalAcquire := acquirePendingUpdateLock
1143 acquirePendingUpdateLock = func() (func(), error) {
1144 if err := overwritePendingUpdateForTest(&replacement); err != nil {
1145 t.Fatal(err)
1146 }
1147 return func() {}, nil
1148 }
1149 t.Cleanup(func() { acquirePendingUpdateLock = originalAcquire })
1150
1151 if err := MarkUpdateHealthy("v2"); err == nil ||
1152 !strings.Contains(err.Error(), "pending transaction changed") {
1153 t.Fatalf("generic healthy commit after pending-lock replacement = %v", err)
1154 }
1155 if _, err := os.Stat(tx.BackupPath); err != nil {
1156 t.Fatalf("generic healthy commit removed prior backup: %v", err)
1157 }
1158 })
1159
1160 t.Run("cancel", func(t *testing.T) {
1161 t.Setenv("REASONIX_HOME", t.TempDir())
1162 dir := t.TempDir()
1163 target := filepath.Join(dir, "reasonix-desktop")
1164 guard := filepath.Join(dir, "reasonix-guard")
1165 originalExecutable := repairExecutable
1166 repairExecutable = func() (string, error) { return guard, nil }
1167 t.Cleanup(func() { repairExecutable = originalExecutable })
1168 if err := os.WriteFile(target, []byte("old"), 0o700); err != nil {
1169 t.Fatal(err)
1170 }
1171 tx, err := PrepareFileUpdate("v1", "v2", target)
1172 if err != nil {
1173 t.Fatal(err)
1174 }
1175 replacement := *tx
1176 replacement.FromVersion = "replacement"
1177 originalAcquire := acquirePendingUpdateLock
1178 acquirePendingUpdateLock = func() (func(), error) {
1179 if err := overwritePendingUpdateForTest(&replacement); err != nil {
1180 t.Fatal(err)
1181 }
1182 return func() {}, nil
1183 }
1184 t.Cleanup(func() { acquirePendingUpdateLock = originalAcquire })
1185
1186 if err := CancelPendingUpdate("v2"); err == nil ||
1187 !strings.Contains(err.Error(), "pending transaction changed") {
1188 t.Fatalf("generic cancel after pending-lock replacement = %v", err)
1189 }
1190 if _, err := os.Stat(tx.BackupPath); err != nil {
1191 t.Fatalf("generic cancel removed prior backup: %v", err)
1192 }
1193 })
1194 }
1195
1196 func TestCancelPendingFileUpdateRejectsLiveDrift(t *testing.T) {
1197 home := t.TempDir()
1198 t.Setenv("REASONIX_HOME", home)
1199 dir, err := filepath.EvalSymlinks(t.TempDir())
1200 if err != nil {
1201 t.Fatal(err)
1202 }
1203 target := filepath.Join(dir, "reasonix-desktop")
1204 guard := filepath.Join(dir, "reasonix-guard")
1205 originalExecutable := repairExecutable
1206 repairExecutable = func() (string, error) { return guard, nil }
1207 t.Cleanup(func() { repairExecutable = originalExecutable })
1208 if err := os.WriteFile(target, []byte("old"), 0o700); err != nil {
1209 t.Fatal(err)
1210 }
1211 tx, err := PrepareFileUpdate("v1", "v2", target)
1212 if err != nil {
1213 t.Fatal(err)
1214 }
1215 if err := os.WriteFile(target, []byte("unconfirmed"), 0o700); err != nil {
1216 t.Fatal(err)
1217 }
1218
1219 if err := CancelPendingUpdateExact(tx); err == nil {
1220 t.Fatal("cancel accepted a release file changed after prepare")
1221 }
1222 if got, err := os.ReadFile(target); err != nil || string(got) != "unconfirmed" {
1223 t.Fatalf("cancel changed drifted release file: %q, %v", got, err)
1224 }
1225 if _, err := ReadPendingUpdate(); err != nil {
1226 t.Fatalf("cancel removed pending recovery state: %v", err)
1227 }
1228 if _, err := os.Stat(tx.BackupPath); err != nil {
1229 t.Fatalf("cancel removed rollback backup: %v", err)
1230 }
1231 }
1232
1233 func TestRecoverFailedInstallRejectsStaleSameVersionIdentity(t *testing.T) {
1234 home := t.TempDir()
1235 t.Setenv("REASONIX_HOME", home)
1236 dir, err := filepath.EvalSymlinks(t.TempDir())
1237 if err != nil {
1238 t.Fatal(err)
1239 }
1240 target := filepath.Join(dir, "reasonix-desktop")
1241 guard := filepath.Join(dir, "reasonix-guard")
1242 originalExecutable := repairExecutable
1243 repairExecutable = func() (string, error) { return guard, nil }
1244 t.Cleanup(func() { repairExecutable = originalExecutable })
1245 if err := os.WriteFile(target, []byte("old"), 0o700); err != nil {
1246 t.Fatal(err)
1247 }
1248 first, err := PrepareFileUpdate("v1", "v2", target)
1249 if err != nil {
1250 t.Fatal(err)
1251 }
1252 second := *first
1253 second.CreatedAt = time.Now().UTC().Add(-time.Second).Format(time.RFC3339Nano)
1254 if err := overwritePendingUpdateForTest(&second); err != nil {
1255 t.Fatal(err)
1256 }
1257 if err := os.WriteFile(target, []byte("new"), 0o700); err != nil {
1258 t.Fatal(err)
1259 }
1260 if err := markUpdateApplyFailed(
1261 first.ToVersion,
1262 first.CreatedAt,
1263 UpdateTransactionID(first),
1264 "stale failure",
1265 ); err != nil {
1266 t.Fatal(err)
1267 }
1268 result, failure, err := RecoverFailedInstall()
1269 if err != nil || failure == nil || result.RolledBack {
1270 t.Fatalf("recover stale marker = %+v, %+v, %v", result, failure, err)
1271 }
1272 if got, err := os.ReadFile(target); err != nil || string(got) != "new" {
1273 t.Fatalf("stale marker rolled back current transaction: %q, %v", got, err)
1274 }
1275 current, err := ReadPendingUpdate()
1276 if err != nil || current.CreatedAt != second.CreatedAt {
1277 t.Fatalf("current transaction after stale marker = %+v, %v", current, err)
1278 }
1279 if _, ok := ReadUpdateApplyFailure(); ok {
1280 t.Fatal("stale exact marker was not cleared")
1281 }
1282 }
1283
1284 func TestMarkUpdateHealthyExactRejectsRewrittenTransaction(t *testing.T) {
1285 t.Setenv("REASONIX_HOME", t.TempDir())
1286 dir, err := filepath.EvalSymlinks(t.TempDir())
1287 if err != nil {
1288 t.Fatal(err)
1289 }
1290 target := filepath.Join(dir, "reasonix-desktop")
1291 guard := filepath.Join(dir, "reasonix-guard")
1292 originalExecutable := repairExecutable
1293 repairExecutable = func() (string, error) { return guard, nil }
1294 t.Cleanup(func() { repairExecutable = originalExecutable })
1295 if err := os.WriteFile(target, []byte("old"), 0o700); err != nil {
1296 t.Fatal(err)
1297 }
1298 tx, err := PrepareFileUpdate("v1", "v2", target)
1299 if err != nil {
1300 t.Fatal(err)
1301 }
1302 changed := *tx
1303 changed.FromVersion = "rewritten"
1304 if err := overwritePendingUpdateForTest(&changed); err != nil {
1305 t.Fatal(err)
1306 }
1307
1308 if err := MarkUpdateHealthyExact(tx.ToVersion, tx.CreatedAt, UpdateTransactionID(tx)); err == nil ||
1309 !strings.Contains(err.Error(), "pending transaction changed") {
1310 t.Fatalf("healthy exact error = %v, want full transaction rejection", err)
1311 }
1312 current, err := ReadPendingUpdate()
1313 if err != nil || current.FromVersion != changed.FromVersion {
1314 t.Fatalf("rewritten transaction = %+v, %v", current, err)
1315 }
1316 if _, err := os.Stat(tx.BackupPath); err != nil {
1317 t.Fatalf("exact health rejection removed backup: %v", err)
1318 }
1319 }
1320
1321 func TestRecoverFailedInstallRejectsRewrittenExactTransaction(t *testing.T) {
1322 t.Setenv("REASONIX_HOME", t.TempDir())
1323 dir, err := filepath.EvalSymlinks(t.TempDir())
1324 if err != nil {
1325 t.Fatal(err)
1326 }
1327 target := filepath.Join(dir, "reasonix-desktop")
1328 guard := filepath.Join(dir, "reasonix-guard")
1329 originalExecutable := repairExecutable
1330 repairExecutable = func() (string, error) { return guard, nil }
1331 t.Cleanup(func() { repairExecutable = originalExecutable })
1332 if err := os.WriteFile(target, []byte("old"), 0o700); err != nil {
1333 t.Fatal(err)
1334 }
1335 tx, err := PrepareFileUpdate("v1", "v2", target)
1336 if err != nil {
1337 t.Fatal(err)
1338 }
1339 if err := MarkUpdateApplyFailedExact(tx, "installer failed"); err != nil {
1340 t.Fatal(err)
1341 }
1342 changed := *tx
1343 changed.FromVersion = "rewritten"
1344 if err := overwritePendingUpdateForTest(&changed); err != nil {
1345 t.Fatal(err)
1346 }
1347 if err := os.WriteFile(target, []byte("new"), 0o700); err != nil {
1348 t.Fatal(err)
1349 }
1350
1351 result, failure, err := RecoverFailedInstall()
1352 if err != nil || failure == nil || result.RolledBack {
1353 t.Fatalf("recover rewritten marker = %+v, %+v, %v", result, failure, err)
1354 }
1355 if got, err := os.ReadFile(target); err != nil || string(got) != "new" {
1356 t.Fatalf("stale marker rolled back rewritten transaction: %q, %v", got, err)
1357 }
1358 current, err := ReadPendingUpdate()
1359 if err != nil || current.FromVersion != changed.FromVersion {
1360 t.Fatalf("rewritten transaction after stale marker = %+v, %v", current, err)
1361 }
1362 if _, ok := ReadUpdateApplyFailure(); ok {
1363 t.Fatal("stale full-identity marker was not cleared")
1364 }
1365 }
1366
1367 func TestClearUpdateApplyFailureExactPreservesDifferentTransactionMarker(t *testing.T) {
1368 t.Setenv("REASONIX_HOME", t.TempDir())
1369 first := &UpdateTransaction{
1370 SchemaVersion: 1,
1371 ToVersion: "v2",
1372 Platform: "windows/amd64",
1373 TargetKind: "file",
1374 TargetPath: filepath.Join(t.TempDir(), "reasonix-desktop"),
1375 CreatedAt: "2026-07-29T00:00:00Z",
1376 }
1377 second := *first
1378 second.CreatedAt = "2026-07-29T00:00:01Z"
1379 if err := MarkUpdateApplyFailedExact(first, "publish interrupted"); err != nil {
1380 t.Fatal(err)
1381 }
1382 if err := ClearUpdateApplyFailureExact(&second); err == nil {
1383 t.Fatal("different transaction cleared update failure marker")
1384 }
1385 if failure, ok := ReadUpdateApplyFailure(); !ok ||
1386 failure.UpdateTransactionID != UpdateTransactionID(first) {
1387 t.Fatalf("preserved failure marker = %+v, present=%v", failure, ok)
1388 }
1389 if err := ClearUpdateApplyFailureExact(first); err != nil {
1390 t.Fatal(err)
1391 }
1392 if _, ok := ReadUpdateApplyFailure(); ok {
1393 t.Fatal("matching transaction marker was not cleared")
1394 }
1395 }
1396
1397 // TestRecoverFailedInstallRollsBackAndClearsMarker pins the Windows helper
1398 // handoff contract: an installer failure recorded by the update helper makes
1399 // Guard restore the release unit on its next launch, clearing both the marker
1400 // and the pending transaction.
1401 func TestRecoverFailedInstallRollsBackAndClearsMarker(t *testing.T) {
1402 home := t.TempDir()
1403 t.Setenv("REASONIX_HOME", home)
1404 dir, err := filepath.EvalSymlinks(t.TempDir())
1405 if err != nil {
1406 t.Fatal(err)
1407 }
1408 target := filepath.Join(dir, "reasonix-desktop")
1409 guard := filepath.Join(dir, "reasonix-guard")
1410 originalExecutable := repairExecutable
1411 repairExecutable = func() (string, error) { return guard, nil }
1412 t.Cleanup(func() { repairExecutable = originalExecutable })
1413 for path, body := range map[string]string{target: "old-desktop", guard: "old-guard"} {
1414 if err := os.WriteFile(path, []byte(body), 0o700); err != nil {
1415 t.Fatal(err)
1416 }
1417 }
1418 if _, err := PrepareFileUpdate("v1", "v2", target, guard); err != nil {
1419 t.Fatal(err)
1420 }
1421 // Simulate a partial NSIS run followed by the helper's failure marker.
1422 if err := os.WriteFile(guard, []byte("new-guard"), 0o700); err != nil {
1423 t.Fatal(err)
1424 }
1425 if err := MarkUpdateApplyFailed("v2", "installer exited with 1"); err != nil {
1426 t.Fatal(err)
1427 }
1428 result, failure, err := RecoverFailedInstall()
1429 if err != nil {
1430 t.Fatal(err)
1431 }
1432 if failure == nil || !result.RolledBack || result.ToVersion != "v1" {
1433 t.Fatalf("recover result = %+v failure = %+v", result, failure)
1434 }
1435 for path, want := range map[string]string{target: "old-desktop", guard: "old-guard"} {
1436 got, err := os.ReadFile(path)
1437 if err != nil || string(got) != want {
1438 t.Fatalf("restored %s = %q (%v), want %q", filepath.Base(path), got, err, want)
1439 }
1440 }
1441 if _, ok := ReadUpdateApplyFailure(); ok {
1442 t.Fatal("failure marker survived a successful rollback")
1443 }
1444 if HasPendingUpdate() {
1445 t.Fatal("pending update survived the rollback")
1446 }
1447 // Subsequent launches are a no-op.
1448 if result, failure, err := RecoverFailedInstall(); err != nil || failure != nil || result.RolledBack {
1449 t.Fatalf("second recover = %+v %+v %v", result, failure, err)
1450 }
1451 }
1452
1453 func TestRecoverFailedInstallAfterInstalledSidecarCommit(t *testing.T) {
1454 t.Setenv("REASONIX_HOME", t.TempDir())
1455 dir, err := filepath.EvalSymlinks(t.TempDir())
1456 if err != nil {
1457 t.Fatal(err)
1458 }
1459 target := filepath.Join(dir, "reasonix-desktop")
1460 guard := filepath.Join(dir, "reasonix-guard")
1461 originalExecutable := repairExecutable
1462 repairExecutable = func() (string, error) { return guard, nil }
1463 t.Cleanup(func() { repairExecutable = originalExecutable })
1464 for path, body := range map[string]string{target: "old-desktop", guard: "old-guard"} {
1465 if err := os.WriteFile(path, []byte(body), 0o700); err != nil {
1466 t.Fatal(err)
1467 }
1468 }
1469 prepared, err := PrepareFileUpdate("v1", "v2", target, guard)
1470 if err != nil {
1471 t.Fatal(err)
1472 }
1473 for path, body := range map[string]string{target: "new-desktop", guard: "new-guard"} {
1474 if err := os.WriteFile(path, []byte(body), 0o700); err != nil {
1475 t.Fatal(err)
1476 }
1477 }
1478 if err := MarkUpdateApplyFailedExact(prepared, "helper crashed after publish"); err != nil {
1479 t.Fatal(err)
1480 }
1481 recorded, err := RecordClaimedFileUpdateInstalled(prepared, fileUpdateInstallReceiptsForTest(t, prepared)...)
1482 if err != nil {
1483 t.Fatal(err)
1484 }
1485 sidecar := installedFileUpdateStatePath(recorded)
1486 if _, err := os.Stat(sidecar); err != nil {
1487 t.Fatalf("installed-state sidecar was not committed: %v", err)
1488 }
1489 if _, ok := ReadUpdateApplyFailure(); !ok {
1490 t.Fatal("exact failure marker was not committed")
1491 }
1492 if !HasPendingUpdate() {
1493 t.Fatal("pending transaction disappeared before recovery")
1494 }
1495
1496 result, failure, err := RecoverFailedInstall()
1497 if err != nil {
1498 t.Fatal(err)
1499 }
1500 if failure == nil || !result.RolledBack || result.ToVersion != "v1" {
1501 t.Fatalf("recover result = %+v failure = %+v", result, failure)
1502 }
1503 for path, want := range map[string]string{target: "old-desktop", guard: "old-guard"} {
1504 got, err := os.ReadFile(path)
1505 if err != nil || string(got) != want {
1506 t.Fatalf("restored %s = %q (%v), want %q", filepath.Base(path), got, err, want)
1507 }
1508 }
1509 if HasPendingUpdate() {
1510 t.Fatal("pending transaction survived sidecar-backed recovery")
1511 }
1512 if _, ok := ReadUpdateApplyFailure(); ok {
1513 t.Fatal("failure marker survived sidecar-backed recovery")
1514 }
1515 if _, err := os.Stat(sidecar); !os.IsNotExist(err) {
1516 t.Fatalf("installed-state sidecar survived recovery: %v", err)
1517 }
1518 }
1519
1520 // A stale marker with nothing to roll back must be cleared, not retried
1521 // forever.
1522 func TestRecoverFailedInstallClearsStaleMarker(t *testing.T) {
1523 home := t.TempDir()
1524 t.Setenv("REASONIX_HOME", home)
1525 if err := MarkUpdateApplyFailed("v2", "installer exited with 1"); err != nil {
1526 t.Fatal(err)
1527 }
1528 result, failure, err := RecoverFailedInstall()
1529 if err != nil || failure == nil || result.RolledBack {
1530 t.Fatalf("recover = %+v %+v %v", result, failure, err)
1531 }
1532 if _, ok := ReadUpdateApplyFailure(); ok {
1533 t.Fatal("stale marker was not cleared")
1534 }
1535 }
1536
1537 func TestRecoverFailedInstallPreservesMarkerRecreatedDuringCleanup(t *testing.T) {
1538 t.Setenv("REASONIX_HOME", t.TempDir())
1539 if err := MarkUpdateApplyFailed("v2", "old failure"); err != nil {
1540 t.Fatal(err)
1541 }
1542 originalHook := updateCleanupAfterRename
1543 injected := false
1544 updateCleanupAfterRename = func(oldpath, _ string) {
1545 if !injected && oldpath == updateApplyFailurePath() {
1546 injected = true
1547 if err := markUpdateApplyFailed("v3", "", "", "new failure"); err != nil {
1548 t.Errorf("recreate failure marker: %v", err)
1549 }
1550 }
1551 }
1552 t.Cleanup(func() { updateCleanupAfterRename = originalHook })
1553
1554 if _, failure, err := RecoverFailedInstall(); err != nil || failure == nil {
1555 t.Fatalf("recover = %+v, %v", failure, err)
1556 }
1557 current, ok := ReadUpdateApplyFailure()
1558 if !ok || current.ToVersion != "v3" || current.Reason != "new failure" {
1559 t.Fatalf("concurrent failure marker = %+v, present=%v", current, ok)
1560 }
1561 }
1562
1563 func TestRecoverFailedInstallIgnoresMarkerForAnotherVersion(t *testing.T) {
1564 home := t.TempDir()
1565 t.Setenv("REASONIX_HOME", home)
1566 dir, err := filepath.EvalSymlinks(t.TempDir())
1567 if err != nil {
1568 t.Fatal(err)
1569 }
1570 target := filepath.Join(dir, "reasonix-desktop")
1571 guard := filepath.Join(dir, "reasonix-guard")
1572 originalExecutable := repairExecutable
1573 repairExecutable = func() (string, error) { return guard, nil }
1574 t.Cleanup(func() { repairExecutable = originalExecutable })
1575 if err := os.WriteFile(target, []byte("v2"), 0o700); err != nil {
1576 t.Fatal(err)
1577 }
1578 if _, err := PrepareFileUpdate("v2", "v3", target); err != nil {
1579 t.Fatal(err)
1580 }
1581 if err := os.WriteFile(target, []byte("v3"), 0o700); err != nil {
1582 t.Fatal(err)
1583 }
1584 if err := markUpdateApplyFailed("v2", "", "", "stale installer failure"); err != nil {
1585 t.Fatal(err)
1586 }
1587 result, failure, err := RecoverFailedInstall()
1588 if err != nil || failure == nil || result.RolledBack {
1589 t.Fatalf("recover = %+v %+v %v", result, failure, err)
1590 }
1591 got, err := os.ReadFile(target)
1592 if err != nil || string(got) != "v3" {
1593 t.Fatalf("unrelated update was rolled back: %q (%v)", got, err)
1594 }
1595 if !HasPendingUpdate() {
1596 t.Fatal("unrelated pending update was removed")
1597 }
1598 if _, ok := ReadUpdateApplyFailure(); ok {
1599 t.Fatal("stale failure marker was not cleared")
1600 }
1601 }
1602
1603 func TestMarkUpdateApplyFailedPreservesNewMarkerAfterPendingReplacement(t *testing.T) {
1604 t.Setenv("REASONIX_HOME", t.TempDir())
1605 dir := t.TempDir()
1606 target := filepath.Join(dir, "reasonix-desktop")
1607 guard := filepath.Join(dir, "reasonix-guard")
1608 originalExecutable := repairExecutable
1609 repairExecutable = func() (string, error) { return guard, nil }
1610 t.Cleanup(func() { repairExecutable = originalExecutable })
1611 if err := os.WriteFile(target, []byte("v1"), 0o700); err != nil {
1612 t.Fatal(err)
1613 }
1614 first, err := PrepareFileUpdate("v1", "v2", target)
1615 if err != nil {
1616 t.Fatal(err)
1617 }
1618 second := *first
1619 second.CreatedAt = time.Now().UTC().Add(time.Second).Format(time.RFC3339Nano)
1620
1621 originalLock := acquirePendingUpdateLock
1622 var hookErr error
1623 acquirePendingUpdateLock = func() (func(), error) {
1624 if hookErr = overwritePendingUpdateForTest(&second); hookErr == nil {
1625 hookErr = MarkUpdateApplyFailedExact(&second, "new transaction failed")
1626 }
1627 return func() {}, nil
1628 }
1629 t.Cleanup(func() { acquirePendingUpdateLock = originalLock })
1630
1631 if err := MarkUpdateApplyFailed(first.ToVersion, "old transaction failed"); err == nil ||
1632 !strings.Contains(err.Error(), "changed while waiting") {
1633 t.Fatalf("stale failure marker creation = %v", err)
1634 }
1635 if hookErr != nil {
1636 t.Fatalf("replace pending transaction and marker: %v", hookErr)
1637 }
1638 failure, ok := ReadUpdateApplyFailure()
1639 if !ok || failure.UpdateTransactionID != UpdateTransactionID(&second) ||
1640 failure.Reason != "new transaction failed" {
1641 t.Fatalf("new transaction marker = %+v, present=%v", failure, ok)
1642 }
1643 current, err := ReadPendingUpdate()
1644 if err != nil || UpdateTransactionID(current) != UpdateTransactionID(&second) {
1645 t.Fatalf("pending transaction = %+v, %v", current, err)
1646 }
1647 }
1648
1649 func TestRecoverFailedInstallRejectsLegacyMarkerRegardlessVersionAndTime(t *testing.T) {
1650 for _, tc := range []struct {
1651 name string
1652 markerOlder bool
1653 wantContents string
1654 fromVersion string
1655 targetVersion string
1656 markerVersion string
1657 }{
1658 {name: "older marker is stale", markerOlder: true, wantContents: "v3", fromVersion: "v2", targetVersion: "v3"},
1659 {name: "newer matching marker is still unbound", wantContents: "v2", fromVersion: "v1", targetVersion: "v2", markerVersion: "v2"},
1660 {name: "newer versionless marker is incomplete", wantContents: "v2", fromVersion: "v1", targetVersion: "v2"},
1661 {name: "older same-version marker is stale", markerOlder: true, wantContents: "v3", fromVersion: "v2", targetVersion: "v3", markerVersion: "v3"},
1662 } {
1663 t.Run(tc.name, func(t *testing.T) {
1664 home := t.TempDir()
1665 t.Setenv("REASONIX_HOME", home)
1666 dir, err := filepath.EvalSymlinks(t.TempDir())
1667 if err != nil {
1668 t.Fatal(err)
1669 }
1670 target := filepath.Join(dir, "reasonix-desktop")
1671 guard := filepath.Join(dir, "reasonix-guard")
1672 originalExecutable := repairExecutable
1673 repairExecutable = func() (string, error) { return guard, nil }
1674 t.Cleanup(func() { repairExecutable = originalExecutable })
1675 if err := os.WriteFile(target, []byte(tc.fromVersion), 0o700); err != nil {
1676 t.Fatal(err)
1677 }
1678 tx, err := PrepareFileUpdate(tc.fromVersion, tc.targetVersion, target)
1679 if err != nil {
1680 t.Fatal(err)
1681 }
1682 if err := os.WriteFile(target, []byte(tc.targetVersion), 0o700); err != nil {
1683 t.Fatal(err)
1684 }
1685 if err := markUpdateApplyFailed(tc.markerVersion, "", "", "installer failure"); err != nil {
1686 t.Fatal(err)
1687 }
1688 failure, ok := ReadUpdateApplyFailure()
1689 if !ok {
1690 t.Fatal("legacy failure marker missing")
1691 }
1692 txAt, err := time.Parse(time.RFC3339Nano, tx.CreatedAt)
1693 if err != nil {
1694 t.Fatal(err)
1695 }
1696 if tc.markerOlder {
1697 failure.RecordedAt = txAt.Add(-time.Second).Format(time.RFC3339Nano)
1698 } else {
1699 failure.RecordedAt = txAt.Add(time.Second).Format(time.RFC3339Nano)
1700 }
1701 b, err := json.Marshal(failure)
1702 if err != nil {
1703 t.Fatal(err)
1704 }
1705 if err := os.WriteFile(updateApplyFailurePath(), append(b, '\n'), 0o600); err != nil {
1706 t.Fatal(err)
1707 }
1708 result, failure, err := RecoverFailedInstall()
1709 if err != nil || failure == nil || result.RolledBack {
1710 t.Fatalf("recover = %+v %+v %v", result, failure, err)
1711 }
1712 got, err := os.ReadFile(target)
1713 if err != nil || string(got) != tc.wantContents {
1714 t.Fatalf("target = %q (%v), want %q", got, err, tc.wantContents)
1715 }
1716 })
1717 }
1718 }
1719
1720 func TestHealthyUpdateRemovesBackup(t *testing.T) {
1721 home := t.TempDir()
1722 t.Setenv("REASONIX_HOME", home)
1723 target := filepath.Join(t.TempDir(), "reasonix-desktop")
1724 originalExecutable := repairExecutable
1725 repairExecutable = func() (string, error) { return filepath.Join(filepath.Dir(target), "reasonix-guard"), nil }
1726 t.Cleanup(func() { repairExecutable = originalExecutable })
1727 if err := os.WriteFile(target, []byte("old"), 0o700); err != nil {
1728 t.Fatal(err)
1729 }
1730 tx, err := PrepareFileUpdate("v1", "v2", target)
1731 if err != nil {
1732 t.Fatal(err)
1733 }
1734 if _, err := RecordClaimedFileUpdateInstalled(tx, fileUpdateInstallReceiptsForTest(t, tx)...); err != nil {
1735 t.Fatal(err)
1736 }
1737 if err := MarkUpdateHealthy("v1"); err != nil {
1738 t.Fatal(err)
1739 }
1740 if !HasPendingUpdate() {
1741 t.Fatal("mismatched version committed pending update")
1742 }
1743 if err := MarkUpdateHealthy("v2"); err != nil {
1744 t.Fatal(err)
1745 }
1746 if HasPendingUpdate() {
1747 t.Fatal("pending update remains after health confirmation")
1748 }
1749 if _, err := os.Stat(tx.BackupPath); !os.IsNotExist(err) {
1750 t.Fatalf("backup still exists: %v", err)
1751 }
1752 }
1753
1754 func TestMarkUpdateHealthyRejectsFileTransactionWithoutInstalledBinding(t *testing.T) {
1755 t.Setenv("REASONIX_HOME", t.TempDir())
1756 dir := t.TempDir()
1757 target := filepath.Join(dir, "reasonix-desktop")
1758 guard := filepath.Join(dir, "reasonix-guard")
1759 originalExecutable := repairExecutable
1760 repairExecutable = func() (string, error) { return guard, nil }
1761 t.Cleanup(func() { repairExecutable = originalExecutable })
1762 if err := os.WriteFile(target, []byte("old"), 0o700); err != nil {
1763 t.Fatal(err)
1764 }
1765 tx, err := PrepareFileUpdate("v1", "v2", target)
1766 if err != nil {
1767 t.Fatal(err)
1768 }
1769 if err := os.WriteFile(target, []byte("new"), 0o700); err != nil {
1770 t.Fatal(err)
1771 }
1772
1773 if err := MarkUpdateHealthyExact(tx.ToVersion, tx.CreatedAt, UpdateTransactionID(tx)); err == nil ||
1774 !strings.Contains(err.Error(), "installed release-unit state is missing") {
1775 t.Fatalf("healthy commit without installed binding = %v", err)
1776 }
1777 if _, err := ReadPendingUpdate(); err != nil {
1778 t.Fatalf("healthy rejection removed pending state: %v", err)
1779 }
1780 if _, err := os.Stat(tx.BackupPath); err != nil {
1781 t.Fatalf("healthy rejection removed backup: %v", err)
1782 }
1783 }
1784
1785 func TestRecordClaimedFileUpdateInstalledBindsCompleteReleaseUnit(t *testing.T) {
1786 home := t.TempDir()
1787 t.Setenv("REASONIX_HOME", home)
1788 dir, err := filepath.EvalSymlinks(t.TempDir())
1789 if err != nil {
1790 t.Fatal(err)
1791 }
1792 target := filepath.Join(dir, "reasonix-desktop")
1793 guard := filepath.Join(dir, "reasonix-guard")
1794 originalExecutable := repairExecutable
1795 repairExecutable = func() (string, error) { return guard, nil }
1796 t.Cleanup(func() { repairExecutable = originalExecutable })
1797 for path, body := range map[string]string{target: "old-desktop", guard: "old-guard"} {
1798 if err := os.WriteFile(path, []byte(body), 0o700); err != nil {
1799 t.Fatal(err)
1800 }
1801 }
1802 prepared, err := PrepareFileUpdate("v1", "v2", target, guard)
1803 if err != nil {
1804 t.Fatal(err)
1805 }
1806 for path, body := range map[string]string{target: "new-desktop", guard: "new-guard"} {
1807 if err := os.WriteFile(path, []byte(body), 0o700); err != nil {
1808 t.Fatal(err)
1809 }
1810 }
1811 recorded, err := RecordClaimedFileUpdateInstalled(prepared, fileUpdateInstallReceiptsForTest(t, prepared)...)
1812 if err != nil {
1813 t.Fatal(err)
1814 }
1815 if len(recorded.Files) != 2 {
1816 t.Fatalf("recorded release unit = %+v", recorded.Files)
1817 }
1818 record, err := readInstalledFileUpdateState(recorded)
1819 if err != nil {
1820 t.Fatal(err)
1821 }
1822 if len(record.InstalledStateIDs) != len(recorded.Files) {
1823 t.Fatalf("installed sidecar = %+v", record)
1824 }
1825 for i, stateID := range record.InstalledStateIDs {
1826 if stateID == "" {
1827 t.Fatalf("installed state missing for %s", recorded.Files[i].TargetPath)
1828 }
1829 }
1830 persisted, err := ReadPendingUpdate()
1831 if err != nil || UpdateTransactionID(persisted) != UpdateTransactionID(recorded) {
1832 t.Fatalf("persisted installed release unit = %+v, %v", persisted, err)
1833 }
1834
1835 if err := os.WriteFile(guard, []byte("drifted-guard"), 0o700); err != nil {
1836 t.Fatal(err)
1837 }
1838 if err := MarkUpdateHealthyExact(recorded.ToVersion, recorded.CreatedAt, UpdateTransactionID(recorded)); err == nil ||
1839 !strings.Contains(err.Error(), "installed release file") {
1840 t.Fatalf("healthy error = %v, want installed sibling drift rejection", err)
1841 }
1842 if !HasPendingUpdate() {
1843 t.Fatal("drifted installed release unit removed pending state")
1844 }
1845 if _, err := os.Stat(prepared.BackupPath); err != nil {
1846 t.Fatalf("drifted installed release unit removed rollback backup: %v", err)
1847 }
1848
1849 if err := os.WriteFile(guard, []byte("new-guard"), 0o700); err != nil {
1850 t.Fatal(err)
1851 }
1852 if err := MarkUpdateHealthyExact(recorded.ToVersion, recorded.CreatedAt, UpdateTransactionID(recorded)); err != nil {
1853 t.Fatalf("healthy exact after restoring installed state: %v", err)
1854 }
1855 if HasPendingUpdate() {
1856 t.Fatal("verified installed release unit retained pending state")
1857 }
1858 if _, err := os.Stat(installedFileUpdateStatePath(recorded)); !os.IsNotExist(err) {
1859 t.Fatalf("installed-state sidecar survived healthy commit: %v", err)
1860 }
1861 }
1862
1863 func TestRecordClaimedFileUpdateInstalledBindsOptionalMissingSibling(t *testing.T) {
1864 t.Setenv("REASONIX_HOME", t.TempDir())
1865 dir, err := filepath.EvalSymlinks(t.TempDir())
1866 if err != nil {
1867 t.Fatal(err)
1868 }
1869 target := filepath.Join(dir, "reasonix-desktop")
1870 guard := filepath.Join(dir, "reasonix-guard")
1871 optionalCLI := filepath.Join(dir, "reasonix")
1872 originalExecutable := repairExecutable
1873 repairExecutable = func() (string, error) { return guard, nil }
1874 t.Cleanup(func() { repairExecutable = originalExecutable })
1875 for path, body := range map[string]string{target: "old-desktop", guard: "old-guard"} {
1876 if err := os.WriteFile(path, []byte(body), 0o700); err != nil {
1877 t.Fatal(err)
1878 }
1879 }
1880 prepared, err := PrepareFileUpdate("v1", "v2", target, guard, optionalCLI)
1881 if err != nil {
1882 t.Fatal(err)
1883 }
1884 for path, body := range map[string]string{target: "new-desktop", guard: "new-guard"} {
1885 if err := os.WriteFile(path, []byte(body), 0o700); err != nil {
1886 t.Fatal(err)
1887 }
1888 }
1889 recorded, err := RecordClaimedFileUpdateInstalled(prepared, fileUpdateInstallReceiptsForTest(t, prepared)...)
1890 if err != nil {
1891 t.Fatal(err)
1892 }
1893 record, err := readInstalledFileUpdateState(recorded)
1894 if err != nil {
1895 t.Fatal(err)
1896 }
1897 if len(recorded.Files) != 3 || !recorded.Files[2].MissingBefore ||
1898 len(record.InstalledStateIDs) != 3 || record.InstalledStateIDs[2] == "" {
1899 t.Fatalf("optional installed release state = %+v", recorded.Files)
1900 }
1901 if err := MarkUpdateHealthyExact(recorded.ToVersion, recorded.CreatedAt, UpdateTransactionID(recorded)); err != nil {
1902 t.Fatalf("healthy exact with still-missing optional sibling: %v", err)
1903 }
1904 }
1905
1906 func TestReadPendingUpdateRejectsPartialInstalledReleaseUnitState(t *testing.T) {
1907 t.Setenv("REASONIX_HOME", t.TempDir())
1908 dir, err := filepath.EvalSymlinks(t.TempDir())
1909 if err != nil {
1910 t.Fatal(err)
1911 }
1912 target := filepath.Join(dir, "reasonix-desktop")
1913 guard := filepath.Join(dir, "reasonix-guard")
1914 originalExecutable := repairExecutable
1915 repairExecutable = func() (string, error) { return guard, nil }
1916 t.Cleanup(func() { repairExecutable = originalExecutable })
1917 for _, path := range []string{target, guard} {
1918 if err := os.WriteFile(path, []byte("old"), 0o700); err != nil {
1919 t.Fatal(err)
1920 }
1921 }
1922 tx, err := PrepareFileUpdate("v1", "v2", target, guard)
1923 if err != nil {
1924 t.Fatal(err)
1925 }
1926 tx.Files[0].InstalledStateID = strings.Repeat("a", 64)
1927 if err := overwritePendingUpdateForTest(tx); err != nil {
1928 t.Fatal(err)
1929 }
1930 if _, err := ReadPendingUpdate(); err == nil ||
1931 !strings.Contains(err.Error(), "installed release-unit state is incomplete") {
1932 t.Fatalf("ReadPendingUpdate error = %v, want partial installed-state rejection", err)
1933 }
1934 }
1935
1936 func TestHealthyFileUpdateRejectsBackupDrift(t *testing.T) {
1937 home := t.TempDir()
1938 t.Setenv("REASONIX_HOME", home)
1939 target := filepath.Join(t.TempDir(), "reasonix-desktop")
1940 originalExecutable := repairExecutable
1941 repairExecutable = func() (string, error) { return filepath.Join(filepath.Dir(target), "reasonix-guard"), nil }
1942 t.Cleanup(func() { repairExecutable = originalExecutable })
1943 if err := os.WriteFile(target, []byte("old"), 0o700); err != nil {
1944 t.Fatal(err)
1945 }
1946 tx, err := PrepareFileUpdate("v1", "v2", target)
1947 if err != nil {
1948 t.Fatal(err)
1949 }
1950 if err := os.WriteFile(target, []byte("new"), 0o700); err != nil {
1951 t.Fatal(err)
1952 }
1953 if _, err := RecordClaimedFileUpdateInstalled(tx, fileUpdateInstallReceiptsForTest(t, tx)...); err != nil {
1954 t.Fatal(err)
1955 }
1956 if err := os.WriteFile(tx.BackupPath, []byte("unrelated"), 0o700); err != nil {
1957 t.Fatal(err)
1958 }
1959
1960 if err := MarkUpdateHealthyMatching(tx.ToVersion, tx.CreatedAt); err == nil {
1961 t.Fatal("healthy commit accepted a changed file backup")
1962 }
1963 if _, err := ReadPendingUpdate(); err != nil {
1964 t.Fatalf("rejected healthy commit removed pending transaction: %v", err)
1965 }
1966 if got, err := os.ReadFile(tx.BackupPath); err != nil || string(got) != "unrelated" {
1967 t.Fatalf("changed backup was removed: %q, %v", got, err)
1968 }
1969 }
1970
1971 func TestHealthyAppUpdateRejectsBackupDrift(t *testing.T) {
1972 home := t.TempDir()
1973 t.Setenv("REASONIX_HOME", home)
1974 dir, err := filepath.EvalSymlinks(t.TempDir())
1975 if err != nil {
1976 t.Fatal(err)
1977 }
1978 app := filepath.Join(dir, "Reasonix.app")
1979 exe := filepath.Join(app, "Contents", "MacOS", "Reasonix")
1980 if err := os.MkdirAll(filepath.Dir(exe), 0o700); err != nil {
1981 t.Fatal(err)
1982 }
1983 if err := os.WriteFile(exe, []byte("old"), 0o700); err != nil {
1984 t.Fatal(err)
1985 }
1986 originalExecutable := repairExecutable
1987 repairExecutable = func() (string, error) { return exe, nil }
1988 t.Cleanup(func() { repairExecutable = originalExecutable })
1989
1990 backup := app + ".reasonix-update-backup"
1991 tx, err := PrepareAppBundleUpdate("v1", "v2", app, backup)
1992 if err != nil {
1993 t.Fatal(err)
1994 }
1995 if err := os.Rename(app, backup); err != nil {
1996 t.Fatal(err)
1997 }
1998 if err := os.MkdirAll(filepath.Dir(exe), 0o700); err != nil {
1999 t.Fatal(err)
2000 }
2001 if err := os.WriteFile(exe, []byte("new"), 0o700); err != nil {
2002 t.Fatal(err)
2003 }
2004 if err := os.WriteFile(filepath.Join(backup, "Contents", "MacOS", "Reasonix"), []byte("unrelated"), 0o700); err != nil {
2005 t.Fatal(err)
2006 }
2007
2008 if err := MarkUpdateHealthyMatching(tx.ToVersion, tx.CreatedAt); err == nil {
2009 t.Fatal("healthy commit accepted a changed app backup")
2010 }
2011 if _, err := ReadPendingUpdate(); err != nil {
2012 t.Fatalf("rejected healthy commit removed pending transaction: %v", err)
2013 }
2014 if got, err := os.ReadFile(filepath.Join(backup, "Contents", "MacOS", "Reasonix")); err != nil || string(got) != "unrelated" {
2015 t.Fatalf("changed app backup was removed: %q, %v", got, err)
2016 }
2017 }
2018
2019 func TestMarkUpdateHealthyPreservesPendingRecreatedDuringCleanup(t *testing.T) {
2020 t.Setenv("REASONIX_HOME", t.TempDir())
2021 dir, err := filepath.EvalSymlinks(t.TempDir())
2022 if err != nil {
2023 t.Fatal(err)
2024 }
2025 target := filepath.Join(dir, "reasonix-desktop")
2026 guard := filepath.Join(dir, "reasonix-guard")
2027 originalExecutable := repairExecutable
2028 repairExecutable = func() (string, error) { return guard, nil }
2029 t.Cleanup(func() { repairExecutable = originalExecutable })
2030 if err := os.WriteFile(target, []byte("old"), 0o700); err != nil {
2031 t.Fatal(err)
2032 }
2033 tx, err := PrepareFileUpdate("v1", "v2", target)
2034 if err != nil {
2035 t.Fatal(err)
2036 }
2037 if _, err := RecordClaimedFileUpdateInstalled(tx, fileUpdateInstallReceiptsForTest(t, tx)...); err != nil {
2038 t.Fatal(err)
2039 }
2040 replacement := *tx
2041 replacement.CreatedAt = time.Now().UTC().Add(time.Second).Format(time.RFC3339Nano)
2042
2043 originalHook := updateCleanupAfterRename
2044 injected := false
2045 updateCleanupAfterRename = func(oldpath, _ string) {
2046 if !injected && oldpath == PendingUpdatePath() {
2047 injected = true
2048 if err := overwritePendingUpdateForTest(&replacement); err != nil {
2049 t.Errorf("recreate pending transaction: %v", err)
2050 }
2051 }
2052 }
2053 t.Cleanup(func() { updateCleanupAfterRename = originalHook })
2054
2055 if err := MarkUpdateHealthyExact(tx.ToVersion, tx.CreatedAt, UpdateTransactionID(tx)); err != nil {
2056 t.Fatal(err)
2057 }
2058 current, err := readPendingUpdateUnchecked()
2059 if err != nil || current.CreatedAt != replacement.CreatedAt {
2060 t.Fatalf("concurrent pending transaction = %+v, %v", current, err)
2061 }
2062 }
2063
2064 func TestMarkUpdateHealthyPreservesBackupRecreatedDuringCleanup(t *testing.T) {
2065 t.Setenv("REASONIX_HOME", t.TempDir())
2066 dir, err := filepath.EvalSymlinks(t.TempDir())
2067 if err != nil {
2068 t.Fatal(err)
2069 }
2070 target := filepath.Join(dir, "reasonix-desktop")
2071 guard := filepath.Join(dir, "reasonix-guard")
2072 originalExecutable := repairExecutable
2073 repairExecutable = func() (string, error) { return guard, nil }
2074 t.Cleanup(func() { repairExecutable = originalExecutable })
2075 if err := os.WriteFile(target, []byte("old"), 0o700); err != nil {
2076 t.Fatal(err)
2077 }
2078 tx, err := PrepareFileUpdate("v1", "v2", target)
2079 if err != nil {
2080 t.Fatal(err)
2081 }
2082 if _, err := RecordClaimedFileUpdateInstalled(tx, fileUpdateInstallReceiptsForTest(t, tx)...); err != nil {
2083 t.Fatal(err)
2084 }
2085
2086 originalHook := updateCleanupAfterRename
2087 injected := false
2088 updateCleanupAfterRename = func(oldpath, _ string) {
2089 if !injected && oldpath == tx.BackupPath {
2090 injected = true
2091 if err := os.WriteFile(oldpath, []byte("concurrent"), 0o700); err != nil {
2092 t.Errorf("recreate backup path: %v", err)
2093 }
2094 }
2095 }
2096 t.Cleanup(func() { updateCleanupAfterRename = originalHook })
2097
2098 if err := MarkUpdateHealthyExact(tx.ToVersion, tx.CreatedAt, UpdateTransactionID(tx)); err != nil {
2099 t.Fatal(err)
2100 }
2101 if got, err := os.ReadFile(tx.BackupPath); err != nil || string(got) != "concurrent" {
2102 t.Fatalf("concurrent backup path = %q, %v", got, err)
2103 }
2104 }
2105
2106 // TestFileUpdateRollbackCompensatesOnPartialFailure pins the release-unit
2107 // contract: when restoring a later binary fails, the already-restored ones are
2108 // renamed back so the install stays a coherent new-version unit (never mixed),
2109 // the pending transaction survives, and a later rollback attempt succeeds.
2110 func TestFileUpdateRollbackCompensatesOnPartialFailure(t *testing.T) {
2111 home := t.TempDir()
2112 t.Setenv("REASONIX_HOME", home)
2113 dir, err := filepath.EvalSymlinks(t.TempDir())
2114 if err != nil {
2115 t.Fatal(err)
2116 }
2117 target := filepath.Join(dir, "reasonix-desktop")
2118 guard := filepath.Join(dir, "reasonix-guard")
2119 added := filepath.Join(dir, "reasonix-update-helper.exe")
2120 originalExecutable := repairExecutable
2121 repairExecutable = func() (string, error) { return guard, nil }
2122 t.Cleanup(func() { repairExecutable = originalExecutable })
2123 for path, content := range map[string]string{target: "old-desktop", guard: "old-guard"} {
2124 if err := os.WriteFile(path, []byte(content), 0o700); err != nil {
2125 t.Fatal(err)
2126 }
2127 }
2128 if _, err := PrepareFileUpdate("v1", "v2", target, added, guard); err != nil {
2129 t.Fatal(err)
2130 }
2131 for path, content := range map[string]string{target: "new-desktop", guard: "new-guard", added: "new-helper"} {
2132 if err := os.WriteFile(path, []byte(content), 0o700); err != nil {
2133 t.Fatal(err)
2134 }
2135 }
2136
2137 originalPublish := rollbackPublishStage
2138 rollbackPublishStage = func(oldpath, newpath string) error {
2139 if strings.HasPrefix(oldpath, guard+".reasonix-rollback-stage-") && newpath == guard {
2140 return errors.New("injected rename failure")
2141 }
2142 return renameRepairNodeNoReplace(oldpath, newpath)
2143 }
2144 t.Cleanup(func() { rollbackPublishStage = originalPublish })
2145
2146 result, err := RollbackPendingUpdate()
2147 if err == nil {
2148 t.Fatal("rollback with injected failure should error")
2149 }
2150 if result.RolledBack {
2151 t.Fatalf("rollback result = %+v", result)
2152 }
2153 if result.MixedInstall {
2154 t.Fatalf("compensated rollback must not report a mixed install: %+v", result)
2155 }
2156 for path, want := range map[string]string{target: "new-desktop", guard: "new-guard", added: "new-helper"} {
2157 got, err := os.ReadFile(path)
2158 if err != nil {
2159 t.Fatal(err)
2160 }
2161 if string(got) != want {
2162 t.Fatalf("compensated %s = %q, want %q", filepath.Base(path), got, want)
2163 }
2164 }
2165 leftovers, err := filepath.Glob(filepath.Join(dir, "*.reasonix-rollback-*"))
2166 if err != nil || len(leftovers) != 0 {
2167 t.Fatalf("rollback left staging files behind: %v (err=%v)", leftovers, err)
2168 }
2169 if !HasPendingUpdate() {
2170 t.Fatal("pending update must survive a failed rollback for a retry")
2171 }
2172
2173 rollbackPublishStage = originalPublish
2174 retry, err := RollbackPendingUpdate()
2175 if err != nil {
2176 t.Fatal(err)
2177 }
2178 if !retry.RolledBack {
2179 t.Fatalf("retry result = %+v", retry)
2180 }
2181 for path, want := range map[string]string{target: "old-desktop", guard: "old-guard"} {
2182 got, err := os.ReadFile(path)
2183 if err != nil {
2184 t.Fatal(err)
2185 }
2186 if string(got) != want {
2187 t.Fatalf("retried %s = %q, want %q", filepath.Base(path), got, want)
2188 }
2189 }
2190 if _, err := os.Stat(added); !os.IsNotExist(err) {
2191 t.Fatalf("new-release-only helper survived retried rollback: %v", err)
2192 }
2193 }
2194
2195 func TestFileUpdateRollbackRejectsStageChangedDuringPublish(t *testing.T) {
2196 t.Setenv("REASONIX_HOME", t.TempDir())
2197 dir, err := filepath.EvalSymlinks(t.TempDir())
2198 if err != nil {
2199 t.Fatal(err)
2200 }
2201 target := filepath.Join(dir, "reasonix-desktop")
2202 guard := filepath.Join(dir, "reasonix-guard")
2203 originalExecutable := repairExecutable
2204 repairExecutable = func() (string, error) { return guard, nil }
2205 t.Cleanup(func() { repairExecutable = originalExecutable })
2206 if err := os.WriteFile(target, []byte("old"), 0o700); err != nil {
2207 t.Fatal(err)
2208 }
2209 if _, err := PrepareFileUpdate("v1", "v2", target); err != nil {
2210 t.Fatal(err)
2211 }
2212 if err := os.WriteFile(target, []byte("new"), 0o700); err != nil {
2213 t.Fatal(err)
2214 }
2215
2216 originalPublish := rollbackPublishStage
2217 rollbackPublishStage = func(stage, destination string) error {
2218 if err := os.WriteFile(stage, []byte("tampered"), 0o700); err != nil {
2219 return err
2220 }
2221 return renameRepairNodeNoReplace(stage, destination)
2222 }
2223 t.Cleanup(func() { rollbackPublishStage = originalPublish })
2224
2225 result, err := RollbackPendingUpdate()
2226 if err == nil || result.RolledBack || result.MixedInstall {
2227 t.Fatalf("rollback = %+v, %v; want compensated hash rejection", result, err)
2228 }
2229 if got, err := os.ReadFile(target); err != nil || string(got) != "new" {
2230 t.Fatalf("compensated target = %q, %v; want pre-rollback bytes", got, err)
2231 }
2232 if !HasPendingUpdate() {
2233 t.Fatal("failed rollback removed pending transaction")
2234 }
2235 }
2236
2237 func TestFileUpdateRollbackPreservesAsideReplacedBeforeCleanup(t *testing.T) {
2238 t.Setenv("REASONIX_HOME", t.TempDir())
2239 dir, err := filepath.EvalSymlinks(t.TempDir())
2240 if err != nil {
2241 t.Fatal(err)
2242 }
2243 target := filepath.Join(dir, "reasonix-desktop")
2244 guard := filepath.Join(dir, "reasonix-guard")
2245 originalExecutable := repairExecutable
2246 repairExecutable = func() (string, error) { return guard, nil }
2247 t.Cleanup(func() { repairExecutable = originalExecutable })
2248 if err := os.WriteFile(target, []byte("old"), 0o700); err != nil {
2249 t.Fatal(err)
2250 }
2251 if _, err := PrepareFileUpdate("v1", "v2", target); err != nil {
2252 t.Fatal(err)
2253 }
2254 if err := os.WriteFile(target, []byte("new"), 0o700); err != nil {
2255 t.Fatal(err)
2256 }
2257
2258 originalPublish := rollbackPublishStage
2259 rollbackPublishStage = func(oldpath, newpath string) error {
2260 if err := renameRepairNodeNoReplace(oldpath, newpath); err != nil {
2261 return err
2262 }
2263 return os.WriteFile(newpath+".reasonix-rollback-aside", []byte("concurrent-aside"), 0o700)
2264 }
2265 t.Cleanup(func() { rollbackPublishStage = originalPublish })
2266
2267 result, err := RollbackPendingUpdate()
2268 if err != nil || !result.RolledBack {
2269 t.Fatalf("rollback = %+v, %v", result, err)
2270 }
2271 if got, err := os.ReadFile(target); err != nil || string(got) != "old" {
2272 t.Fatalf("restored target = %q, %v", got, err)
2273 }
2274 aside := target + ".reasonix-rollback-aside"
2275 if got, err := os.ReadFile(aside); err != nil || string(got) != "concurrent-aside" {
2276 t.Fatalf("concurrent aside = %q, %v", got, err)
2277 }
2278 }
2279
2280 func TestFileUpdateRollbackCompensationPreservesRecreatedTarget(t *testing.T) {
2281 t.Setenv("REASONIX_HOME", t.TempDir())
2282 dir, err := filepath.EvalSymlinks(t.TempDir())
2283 if err != nil {
2284 t.Fatal(err)
2285 }
2286 target := filepath.Join(dir, "reasonix-desktop")
2287 guard := filepath.Join(dir, "reasonix-guard")
2288 originalExecutable := repairExecutable
2289 repairExecutable = func() (string, error) { return guard, nil }
2290 t.Cleanup(func() { repairExecutable = originalExecutable })
2291 for path, content := range map[string]string{target: "old-desktop", guard: "old-guard"} {
2292 if err := os.WriteFile(path, []byte(content), 0o700); err != nil {
2293 t.Fatal(err)
2294 }
2295 }
2296 if _, err := PrepareFileUpdate("v1", "v2", target, guard); err != nil {
2297 t.Fatal(err)
2298 }
2299 for path, content := range map[string]string{target: "new-desktop", guard: "new-guard"} {
2300 if err := os.WriteFile(path, []byte(content), 0o700); err != nil {
2301 t.Fatal(err)
2302 }
2303 }
2304
2305 originalPublish := rollbackPublishStage
2306 rollbackPublishStage = func(oldpath, newpath string) error {
2307 if newpath == guard {
2308 if err := os.WriteFile(target, []byte("concurrent"), 0o700); err != nil {
2309 return err
2310 }
2311 return errors.New("injected publish failure")
2312 }
2313 return renameRepairNodeNoReplace(oldpath, newpath)
2314 }
2315 t.Cleanup(func() { rollbackPublishStage = originalPublish })
2316
2317 result, err := RollbackPendingUpdate()
2318 if err == nil || !result.MixedInstall || result.RolledBack {
2319 t.Fatalf("rollback = %+v, %v", result, err)
2320 }
2321 if got, err := os.ReadFile(target); err != nil || string(got) != "concurrent" {
2322 t.Fatalf("concurrent target = %q, %v", got, err)
2323 }
2324 aside := target + ".reasonix-rollback-aside"
2325 if got, err := os.ReadFile(aside); err != nil || string(got) != "new-desktop" {
2326 t.Fatalf("retained pre-rollback target = %q, %v", got, err)
2327 }
2328 if !HasPendingUpdate() {
2329 t.Fatal("failed compensation removed pending transaction")
2330 }
2331 }
2332
2333 func TestFileUpdateRollbackRetryPreservesRetainedNewBinary(t *testing.T) {
2334 home := t.TempDir()
2335 t.Setenv("REASONIX_HOME", home)
2336 dir, err := filepath.EvalSymlinks(t.TempDir())
2337 if err != nil {
2338 t.Fatal(err)
2339 }
2340 target := filepath.Join(dir, "reasonix-desktop")
2341 guard := filepath.Join(dir, "reasonix-guard")
2342 originalExecutable := repairExecutable
2343 repairExecutable = func() (string, error) { return guard, nil }
2344 t.Cleanup(func() { repairExecutable = originalExecutable })
2345 for path, content := range map[string]string{target: "old-desktop", guard: "old-guard"} {
2346 if err := os.WriteFile(path, []byte(content), 0o700); err != nil {
2347 t.Fatal(err)
2348 }
2349 }
2350 tx, err := PrepareFileUpdate("v1", "v2", target, guard)
2351 if err != nil {
2352 t.Fatal(err)
2353 }
2354 for path, content := range map[string]string{target: "new-desktop", guard: "new-guard"} {
2355 if err := os.WriteFile(path, []byte(content), 0o700); err != nil {
2356 t.Fatal(err)
2357 }
2358 }
2359
2360 // Simulate a process crash after the first old binary was installed but
2361 // before its retained new-version aside was removed.
2362 targetAside := target + ".reasonix-rollback-aside"
2363 if err := os.Rename(target, targetAside); err != nil {
2364 t.Fatal(err)
2365 }
2366 if _, err := copyFileWithHash(tx.Files[0].BackupPath, target, 0o700); err != nil {
2367 t.Fatal(err)
2368 }
2369
2370 originalPublish := rollbackPublishStage
2371 rollbackPublishStage = func(oldpath, newpath string) error {
2372 if strings.HasPrefix(oldpath, guard+".reasonix-rollback-stage-") && newpath == guard {
2373 return errors.New("injected rename failure")
2374 }
2375 return renameRepairNodeNoReplace(oldpath, newpath)
2376 }
2377 t.Cleanup(func() { rollbackPublishStage = originalPublish })
2378
2379 result, err := RollbackPendingUpdate()
2380 if err == nil || !result.MixedInstall || result.RolledBack {
2381 t.Fatalf("compensated crash retry = %+v, %v", result, err)
2382 }
2383 for path, want := range map[string]string{target: "old-desktop", guard: "new-guard"} {
2384 got, err := os.ReadFile(path)
2385 if err != nil || string(got) != want {
2386 t.Fatalf("compensated %s = %q (%v), want %q", filepath.Base(path), got, err, want)
2387 }
2388 }
2389 if got, err := os.ReadFile(targetAside); err != nil || string(got) != "new-desktop" {
2390 t.Fatalf("untrusted inherited aside was consumed: %q, %v", got, err)
2391 }
2392 if !HasPendingUpdate() {
2393 t.Fatal("failed crash retry removed pending transaction")
2394 }
2395
2396 rollbackPublishStage = originalPublish
2397 retry, err := RollbackPendingUpdate()
2398 if err != nil || !retry.RolledBack {
2399 t.Fatalf("retry after compensation = %+v, %v", retry, err)
2400 }
2401 for path, want := range map[string]string{target: "old-desktop", guard: "old-guard"} {
2402 got, err := os.ReadFile(path)
2403 if err != nil || string(got) != want {
2404 t.Fatalf("retried %s = %q (%v), want %q", filepath.Base(path), got, err, want)
2405 }
2406 }
2407 }
2408
2409 func TestFileUpdateRollbackRechecksWholeReleaseUnitBeforeCommit(t *testing.T) {
2410 home := t.TempDir()
2411 t.Setenv("REASONIX_HOME", home)
2412 dir, err := filepath.EvalSymlinks(t.TempDir())
2413 if err != nil {
2414 t.Fatal(err)
2415 }
2416 target := filepath.Join(dir, "reasonix-desktop")
2417 guard := filepath.Join(dir, "reasonix-guard")
2418 originalExecutable := repairExecutable
2419 repairExecutable = func() (string, error) { return guard, nil }
2420 t.Cleanup(func() { repairExecutable = originalExecutable })
2421 for path, body := range map[string]string{target: "old-desktop", guard: "old-guard"} {
2422 if err := os.WriteFile(path, []byte(body), 0o700); err != nil {
2423 t.Fatal(err)
2424 }
2425 }
2426 if _, err := PrepareFileUpdate("v1", "v2", target, guard); err != nil {
2427 t.Fatal(err)
2428 }
2429 for path, body := range map[string]string{target: "new-desktop", guard: "new-guard"} {
2430 if err := os.WriteFile(path, []byte(body), 0o700); err != nil {
2431 t.Fatal(err)
2432 }
2433 }
2434
2435 originalSwap := rollbackSwapRename
2436 injected := false
2437 rollbackSwapRename = func(oldPath, newPath string) error {
2438 if !injected && oldPath == guard && newPath == guard+".reasonix-rollback-aside" {
2439 injected = true
2440 if err := os.WriteFile(target, []byte("uncooperative-after-first-publish"), 0o700); err != nil {
2441 return err
2442 }
2443 }
2444 return renameRepairNodeNoReplace(oldPath, newPath)
2445 }
2446 t.Cleanup(func() { rollbackSwapRename = originalSwap })
2447
2448 result, err := RollbackPendingUpdate()
2449 if err == nil || !strings.Contains(err.Error(), "verify restored release unit") {
2450 t.Fatalf("rollback error = %v, want final release-unit verification", err)
2451 }
2452 if !injected {
2453 t.Fatal("test did not mutate the first restored member")
2454 }
2455 if !result.MixedInstall {
2456 t.Fatalf("uncooperative drift was not reported as mixed: %+v", result)
2457 }
2458 if got, err := os.ReadFile(target); err != nil || string(got) != "uncooperative-after-first-publish" {
2459 t.Fatalf("uncooperative target was overwritten: %q, %v", got, err)
2460 }
2461 if got, err := os.ReadFile(guard); err != nil || string(got) != "new-guard" {
2462 t.Fatalf("cooperative sibling was not compensated: %q, %v", got, err)
2463 }
2464 if got, err := os.ReadFile(target + ".reasonix-rollback-aside"); err != nil || string(got) != "new-desktop" {
2465 t.Fatalf("prior target recovery material was not preserved: %q, %v", got, err)
2466 }
2467 if !HasPendingUpdate() {
2468 t.Fatal("failed release-unit verification removed pending state")
2469 }
2470 }
2471
2472 func TestRollbackPendingUpdateStateRechecksLiveUnitAfterStaging(t *testing.T) {
2473 home := t.TempDir()
2474 t.Setenv("REASONIX_HOME", home)
2475 dir, err := filepath.EvalSymlinks(t.TempDir())
2476 if err != nil {
2477 t.Fatal(err)
2478 }
2479 target := filepath.Join(dir, "reasonix-desktop")
2480 guard := filepath.Join(dir, "reasonix-guard")
2481 originalExecutable := repairExecutable
2482 repairExecutable = func() (string, error) { return guard, nil }
2483 t.Cleanup(func() { repairExecutable = originalExecutable })
2484 if err := os.WriteFile(target, []byte("old"), 0o700); err != nil {
2485 t.Fatal(err)
2486 }
2487 tx, err := PrepareFileUpdate("v1", "v2", target)
2488 if err != nil {
2489 t.Fatal(err)
2490 }
2491 if err := os.WriteFile(target, []byte("confirmed-new"), 0o700); err != nil {
2492 t.Fatal(err)
2493 }
2494 expectedState, expectedFiles := pendingUpdateBoundPreview(tx)
2495
2496 originalCopy := rollbackStageCopy
2497 changed := false
2498 rollbackStageCopy = func(src, dst string, mode os.FileMode) (string, error) {
2499 hash, err := copyFileWithHash(src, dst, mode)
2500 if err == nil && !changed {
2501 changed = true
2502 err = os.WriteFile(target, []byte("concurrent-new"), 0o700)
2503 }
2504 return hash, err
2505 }
2506 t.Cleanup(func() { rollbackStageCopy = originalCopy })
2507
2508 result, err := rollbackPendingUpdateState(expectedState, expectedFiles)
2509 if err == nil || result.RolledBack || !strings.Contains(err.Error(), "preview changed since confirmation") {
2510 t.Fatalf("rollback after live drift = %+v, %v", result, err)
2511 }
2512 if got, err := os.ReadFile(target); err != nil || string(got) != "concurrent-new" {
2513 t.Fatalf("rollback overwrote unconfirmed live target: %q, %v", got, err)
2514 }
2515 if !HasPendingUpdate() {
2516 t.Fatal("rejected confirmed rollback removed pending transaction")
2517 }
2518 }
2519
2520 func TestRollbackPendingUpdateStateRejectsDriftInsideRetainRename(t *testing.T) {
2521 home := t.TempDir()
2522 t.Setenv("REASONIX_HOME", home)
2523 dir, err := filepath.EvalSymlinks(t.TempDir())
2524 if err != nil {
2525 t.Fatal(err)
2526 }
2527 target := filepath.Join(dir, "reasonix-desktop")
2528 guard := filepath.Join(dir, "reasonix-guard")
2529 originalExecutable := repairExecutable
2530 repairExecutable = func() (string, error) { return guard, nil }
2531 t.Cleanup(func() { repairExecutable = originalExecutable })
2532 if err := os.WriteFile(target, []byte("old"), 0o700); err != nil {
2533 t.Fatal(err)
2534 }
2535 tx, err := PrepareFileUpdate("v1", "v2", target)
2536 if err != nil {
2537 t.Fatal(err)
2538 }
2539 if err := os.WriteFile(target, []byte("confirmed-new"), 0o700); err != nil {
2540 t.Fatal(err)
2541 }
2542 expectedState, expectedFiles := pendingUpdateBoundPreview(tx)
2543
2544 originalRename := rollbackSwapRename
2545 injected := false
2546 rollbackSwapRename = func(oldpath, newpath string) error {
2547 if !injected && oldpath == target && newpath == target+".reasonix-rollback-aside" {
2548 injected = true
2549 if err := os.WriteFile(target, []byte("concurrent-new"), 0o700); err != nil {
2550 return err
2551 }
2552 }
2553 return os.Rename(oldpath, newpath)
2554 }
2555 t.Cleanup(func() { rollbackSwapRename = originalRename })
2556
2557 result, err := rollbackPendingUpdateState(expectedState, expectedFiles)
2558 if err == nil || result.RolledBack || !strings.Contains(err.Error(), "preview changed since confirmation") {
2559 t.Fatalf("rollback after retain-window drift = %+v, %v", result, err)
2560 }
2561 if got, err := os.ReadFile(target); err != nil || string(got) != "concurrent-new" {
2562 t.Fatalf("rollback lost concurrent live target: %q, %v", got, err)
2563 }
2564 if !HasPendingUpdate() {
2565 t.Fatal("rejected retain-window drift removed pending transaction")
2566 }
2567 }
2568
2569 func TestRollbackPendingUpdateStatePreservesRecreateBeforeStagePublish(t *testing.T) {
2570 home := t.TempDir()
2571 t.Setenv("REASONIX_HOME", home)
2572 dir, err := filepath.EvalSymlinks(t.TempDir())
2573 if err != nil {
2574 t.Fatal(err)
2575 }
2576 target := filepath.Join(dir, "reasonix-desktop")
2577 guard := filepath.Join(dir, "reasonix-guard")
2578 originalExecutable := repairExecutable
2579 repairExecutable = func() (string, error) { return guard, nil }
2580 t.Cleanup(func() { repairExecutable = originalExecutable })
2581 if err := os.WriteFile(target, []byte("old"), 0o700); err != nil {
2582 t.Fatal(err)
2583 }
2584 tx, err := PrepareFileUpdate("v1", "v2", target)
2585 if err != nil {
2586 t.Fatal(err)
2587 }
2588 if err := os.WriteFile(target, []byte("confirmed-new"), 0o700); err != nil {
2589 t.Fatal(err)
2590 }
2591 expectedState, expectedFiles := pendingUpdateBoundPreview(tx)
2592
2593 originalPublish := rollbackPublishStage
2594 injected := false
2595 rollbackPublishStage = func(oldpath, newpath string) error {
2596 if !injected && newpath == target {
2597 injected = true
2598 if err := os.WriteFile(target, []byte("concurrent-new"), 0o700); err != nil {
2599 return err
2600 }
2601 }
2602 return renameRepairNodeNoReplace(oldpath, newpath)
2603 }
2604 t.Cleanup(func() { rollbackPublishStage = originalPublish })
2605
2606 result, err := rollbackPendingUpdateState(expectedState, expectedFiles)
2607 if err == nil || result.RolledBack || !result.MixedInstall {
2608 t.Fatalf("rollback after pre-publish recreate = %+v, %v", result, err)
2609 }
2610 if got, err := os.ReadFile(target); err != nil || string(got) != "concurrent-new" {
2611 t.Fatalf("rollback overwrote concurrent recreation: %q, %v", got, err)
2612 }
2613 aside := target + ".reasonix-rollback-aside"
2614 if got, err := os.ReadFile(aside); err != nil || string(got) != "confirmed-new" {
2615 t.Fatalf("rollback lost confirmed live target: %q, %v", got, err)
2616 }
2617 if !HasPendingUpdate() {
2618 t.Fatal("failed pre-publish recreate removed pending transaction")
2619 }
2620 }
2621
2622 func TestRollbackPendingAppBundleStateRejectsDriftInsideRetainRename(t *testing.T) {
2623 home := t.TempDir()
2624 t.Setenv("REASONIX_HOME", home)
2625 dir, err := filepath.EvalSymlinks(t.TempDir())
2626 if err != nil {
2627 t.Fatal(err)
2628 }
2629 app := filepath.Join(dir, "Reasonix.app")
2630 exe := filepath.Join(app, "Contents", "MacOS", "Reasonix")
2631 if err := os.MkdirAll(filepath.Dir(exe), 0o700); err != nil {
2632 t.Fatal(err)
2633 }
2634 if err := os.WriteFile(exe, []byte("old"), 0o700); err != nil {
2635 t.Fatal(err)
2636 }
2637 originalExecutable := repairExecutable
2638 repairExecutable = func() (string, error) { return exe, nil }
2639 t.Cleanup(func() { repairExecutable = originalExecutable })
2640
2641 backup := app + ".reasonix-update-backup"
2642 tx, err := PrepareAppBundleUpdate("v1", "v2", app, backup)
2643 if err != nil {
2644 t.Fatal(err)
2645 }
2646 if err := os.Rename(app, backup); err != nil {
2647 t.Fatal(err)
2648 }
2649 if err := os.MkdirAll(filepath.Dir(exe), 0o700); err != nil {
2650 t.Fatal(err)
2651 }
2652 if err := os.WriteFile(exe, []byte("confirmed-new"), 0o700); err != nil {
2653 t.Fatal(err)
2654 }
2655 expectedState, expectedFiles := pendingUpdateBoundPreview(tx)
2656
2657 originalRename := rollbackSwapRename
2658 injected := false
2659 rollbackSwapRename = func(oldpath, newpath string) error {
2660 if !injected && oldpath == app && strings.Contains(newpath, ".reasonix-failed-") {
2661 injected = true
2662 if err := os.WriteFile(exe, []byte("concurrent-new"), 0o700); err != nil {
2663 return err
2664 }
2665 }
2666 return os.Rename(oldpath, newpath)
2667 }
2668 t.Cleanup(func() { rollbackSwapRename = originalRename })
2669
2670 result, err := rollbackPendingUpdateState(expectedState, expectedFiles)
2671 if err == nil || result.RolledBack || !strings.Contains(err.Error(), "preview changed since confirmation") {
2672 t.Fatalf("bundle rollback after retain-window drift = %+v, %v", result, err)
2673 }
2674 if got, err := os.ReadFile(exe); err != nil || string(got) != "concurrent-new" {
2675 t.Fatalf("bundle rollback lost concurrent live tree: %q, %v", got, err)
2676 }
2677 if _, err := os.Stat(backup); err != nil {
2678 t.Fatalf("rejected bundle rollback lost backup: %v", err)
2679 }
2680 if !HasPendingUpdate() {
2681 t.Fatal("rejected bundle retain-window drift removed pending transaction")
2682 }
2683 }
2684
2685 func TestRollbackReportsPendingCleanupFailureAndRemainsRetryable(t *testing.T) {
2686 home := t.TempDir()
2687 t.Setenv("REASONIX_HOME", home)
2688 dir, err := filepath.EvalSymlinks(t.TempDir())
2689 if err != nil {
2690 t.Fatal(err)
2691 }
2692 target := filepath.Join(dir, "reasonix-desktop")
2693 guard := filepath.Join(dir, "reasonix-guard")
2694 originalExecutable := repairExecutable
2695 repairExecutable = func() (string, error) { return guard, nil }
2696 t.Cleanup(func() { repairExecutable = originalExecutable })
2697 if err := os.WriteFile(target, []byte("old"), 0o700); err != nil {
2698 t.Fatal(err)
2699 }
2700 if _, err := PrepareFileUpdate("v1", "v2", target); err != nil {
2701 t.Fatal(err)
2702 }
2703 if err := os.WriteFile(target, []byte("new"), 0o700); err != nil {
2704 t.Fatal(err)
2705 }
2706
2707 originalRemove := removePendingUpdateFile
2708 removePendingUpdateFile = func(string) error { return errors.New("injected remove failure") }
2709 t.Cleanup(func() { removePendingUpdateFile = originalRemove })
2710 result, err := RollbackPendingUpdate()
2711 if err == nil || !result.RolledBack || !strings.Contains(err.Error(), "clear pending transaction") {
2712 t.Fatalf("rollback cleanup failure = %+v, %v", result, err)
2713 }
2714 if got, err := os.ReadFile(target); err != nil || string(got) != "old" {
2715 t.Fatalf("restored target after cleanup failure = %q, %v", got, err)
2716 }
2717 if !HasPendingUpdate() {
2718 t.Fatal("injected cleanup failure unexpectedly removed pending transaction")
2719 }
2720
2721 removePendingUpdateFile = originalRemove
2722 retry, err := RollbackPendingUpdate()
2723 if err != nil || !retry.RolledBack {
2724 t.Fatalf("retry after cleanup failure = %+v, %v", retry, err)
2725 }
2726 if HasPendingUpdate() {
2727 t.Fatal("retry left pending transaction")
2728 }
2729 }
2730
2731 // TestFileUpdateRollbackStageFailureLeavesInstallUntouched pins that a failure
2732 // while staging (before any binary is swapped) leaves the live release unit
2733 // exactly as it was.
2734 func TestFileUpdateRollbackStageFailureLeavesInstallUntouched(t *testing.T) {
2735 home := t.TempDir()
2736 t.Setenv("REASONIX_HOME", home)
2737 dir, err := filepath.EvalSymlinks(t.TempDir())
2738 if err != nil {
2739 t.Fatal(err)
2740 }
2741 target := filepath.Join(dir, "reasonix-desktop")
2742 guard := filepath.Join(dir, "reasonix-guard")
2743 originalExecutable := repairExecutable
2744 repairExecutable = func() (string, error) { return guard, nil }
2745 t.Cleanup(func() { repairExecutable = originalExecutable })
2746 for path, content := range map[string]string{target: "old-desktop", guard: "old-guard"} {
2747 if err := os.WriteFile(path, []byte(content), 0o700); err != nil {
2748 t.Fatal(err)
2749 }
2750 }
2751 if _, err := PrepareFileUpdate("v1", "v2", target, guard); err != nil {
2752 t.Fatal(err)
2753 }
2754 for path, content := range map[string]string{target: "new-desktop", guard: "new-guard"} {
2755 if err := os.WriteFile(path, []byte(content), 0o700); err != nil {
2756 t.Fatal(err)
2757 }
2758 }
2759
2760 originalCopy := rollbackStageCopy
2761 rollbackStageCopy = func(src, dst string, mode os.FileMode) (string, error) {
2762 if strings.HasPrefix(dst, guard+".reasonix-rollback-stage-") {
2763 return "", errors.New("injected copy failure")
2764 }
2765 return copyFileWithHashCreate(src, dst, mode)
2766 }
2767 t.Cleanup(func() { rollbackStageCopy = originalCopy })
2768
2769 result, err := RollbackPendingUpdate()
2770 if err == nil {
2771 t.Fatal("rollback with injected stage failure should error")
2772 }
2773 if result.RolledBack || result.MixedInstall {
2774 t.Fatalf("rollback result = %+v", result)
2775 }
2776 for path, want := range map[string]string{target: "new-desktop", guard: "new-guard"} {
2777 got, err := os.ReadFile(path)
2778 if err != nil {
2779 t.Fatal(err)
2780 }
2781 if string(got) != want {
2782 t.Fatalf("%s = %q, want untouched %q", filepath.Base(path), got, want)
2783 }
2784 }
2785 leftovers, err := filepath.Glob(filepath.Join(dir, "*.reasonix-rollback-*"))
2786 if err != nil || len(leftovers) != 0 {
2787 t.Fatalf("stage failure left staging files behind: %v (err=%v)", leftovers, err)
2788 }
2789 }
2790
2791 func TestPrepareFileUpdateDoesNotOverwriteLegacyBackupName(t *testing.T) {
2792 home := t.TempDir()
2793 t.Setenv("REASONIX_HOME", home)
2794 dir := t.TempDir()
2795 target := filepath.Join(dir, "reasonix-desktop")
2796 guard := filepath.Join(dir, "reasonix-guard")
2797 originalExecutable := repairExecutable
2798 repairExecutable = func() (string, error) { return guard, nil }
2799 t.Cleanup(func() { repairExecutable = originalExecutable })
2800 if err := os.WriteFile(target, []byte("old"), 0o700); err != nil {
2801 t.Fatal(err)
2802 }
2803 backupDir := filepath.Join(home, "repair", "updates")
2804 if err := os.MkdirAll(backupDir, 0o700); err != nil {
2805 t.Fatal(err)
2806 }
2807 legacyBackup := filepath.Join(backupDir, "reasonix-desktop.previous")
2808 if err := os.WriteFile(legacyBackup, []byte("unowned"), 0o600); err != nil {
2809 t.Fatal(err)
2810 }
2811
2812 tx, err := PrepareFileUpdate("v1", "v2", target)
2813 if err != nil {
2814 t.Fatal(err)
2815 }
2816 if tx.BackupPath == legacyBackup {
2817 t.Fatal("new transaction reused the legacy fixed backup path")
2818 }
2819 if got, err := os.ReadFile(legacyBackup); err != nil || string(got) != "unowned" {
2820 t.Fatalf("legacy backup was overwritten: %q, %v", got, err)
2821 }
2822 if got, err := os.ReadFile(tx.BackupPath); err != nil || string(got) != "old" {
2823 t.Fatalf("transaction backup = %q, %v", got, err)
2824 }
2825 }
2826
2827 func TestFileUpdateRollbackBypassesAndPreservesCrashedStage(t *testing.T) {
2828 home := t.TempDir()
2829 t.Setenv("REASONIX_HOME", home)
2830 dir := t.TempDir()
2831 target := filepath.Join(dir, "reasonix-desktop")
2832 guard := filepath.Join(dir, "reasonix-guard")
2833 originalExecutable := repairExecutable
2834 repairExecutable = func() (string, error) { return guard, nil }
2835 t.Cleanup(func() { repairExecutable = originalExecutable })
2836 if err := os.WriteFile(target, []byte("old"), 0o700); err != nil {
2837 t.Fatal(err)
2838 }
2839 if _, err := PrepareFileUpdate("v1", "v2", target); err != nil {
2840 t.Fatal(err)
2841 }
2842 if err := os.WriteFile(target, []byte("new"), 0o700); err != nil {
2843 t.Fatal(err)
2844 }
2845 stage := target + ".reasonix-rollback-stage-crashed"
2846 if err := os.WriteFile(stage, []byte("unowned-stage"), 0o600); err != nil {
2847 t.Fatal(err)
2848 }
2849
2850 result, err := RollbackPendingUpdate()
2851 if err != nil || !result.RolledBack || result.MixedInstall {
2852 t.Fatalf("rollback with crashed stage = %+v, %v", result, err)
2853 }
2854 if got, err := os.ReadFile(target); err != nil || string(got) != "old" {
2855 t.Fatalf("restored target = %q, %v", got, err)
2856 }
2857 if got, err := os.ReadFile(stage); err != nil || string(got) != "unowned-stage" {
2858 t.Fatalf("preexisting stage was overwritten: %q, %v", got, err)
2859 }
2860 if HasPendingUpdate() {
2861 t.Fatal("successful rollback left pending recovery state")
2862 }
2863 }
2864
2865 func TestRecordClaimedFileUpdateInstalledKeepsPendingPublicDuringSidecarCommit(t *testing.T) {
2866 home := t.TempDir()
2867 t.Setenv("REASONIX_HOME", home)
2868 dir := t.TempDir()
2869 target := filepath.Join(dir, "reasonix-desktop")
2870 guard := filepath.Join(dir, "reasonix-guard")
2871 originalExecutable := repairExecutable
2872 repairExecutable = func() (string, error) { return guard, nil }
2873 t.Cleanup(func() { repairExecutable = originalExecutable })
2874 if err := os.WriteFile(target, []byte("old"), 0o700); err != nil {
2875 t.Fatal(err)
2876 }
2877 prepared, err := PrepareFileUpdate("v1", "v2", target)
2878 if err != nil {
2879 t.Fatal(err)
2880 }
2881 if err := os.WriteFile(target, []byte("new"), 0o700); err != nil {
2882 t.Fatal(err)
2883 }
2884 concurrent := *prepared
2885 concurrent.FromVersion = "concurrent"
2886 originalHook := installedUpdateAfterCreate
2887 var hookErr error
2888 var pendingBeforeRewrite *UpdateTransaction
2889 installedUpdateAfterCreate = func(string) {
2890 pendingBeforeRewrite, hookErr = ReadPendingUpdate()
2891 if hookErr == nil {
2892 hookErr = overwritePendingUpdateForTest(&concurrent)
2893 }
2894 }
2895 t.Cleanup(func() { installedUpdateAfterCreate = originalHook })
2896
2897 if _, err := RecordClaimedFileUpdateInstalled(prepared, fileUpdateInstallReceiptsForTest(t, prepared)...); err == nil {
2898 t.Fatal("record installed update overwrote a concurrent pending rewrite")
2899 }
2900 if hookErr != nil {
2901 t.Fatalf("write concurrent pending transaction: %v", hookErr)
2902 }
2903 if pendingBeforeRewrite == nil || UpdateTransactionID(pendingBeforeRewrite) != UpdateTransactionID(prepared) {
2904 t.Fatalf("public pending transaction disappeared during sidecar commit: %+v", pendingBeforeRewrite)
2905 }
2906 current, err := ReadPendingUpdate()
2907 if err != nil {
2908 t.Fatal(err)
2909 }
2910 if current.FromVersion != "concurrent" {
2911 t.Fatalf("public pending transaction = %+v", current)
2912 }
2913 if _, err := os.Stat(installedFileUpdateStatePath(prepared)); err != nil {
2914 t.Fatalf("transaction recovery sidecar was not retained: %v", err)
2915 }
2916 }
2917
2918 func TestMarkUpdateHealthyRechecksReleaseUnitAfterPendingDisplacement(t *testing.T) {
2919 home := t.TempDir()
2920 t.Setenv("REASONIX_HOME", home)
2921 dir := t.TempDir()
2922 target := filepath.Join(dir, "reasonix-desktop")
2923 guard := filepath.Join(dir, "reasonix-guard")
2924 originalExecutable := repairExecutable
2925 repairExecutable = func() (string, error) { return guard, nil }
2926 t.Cleanup(func() { repairExecutable = originalExecutable })
2927 if err := os.WriteFile(target, []byte("old"), 0o700); err != nil {
2928 t.Fatal(err)
2929 }
2930 prepared, err := PrepareFileUpdate("v1", "v2", target)
2931 if err != nil {
2932 t.Fatal(err)
2933 }
2934 if err := os.WriteFile(target, []byte("new"), 0o700); err != nil {
2935 t.Fatal(err)
2936 }
2937 recorded, err := RecordClaimedFileUpdateInstalled(prepared, fileUpdateInstallReceiptsForTest(t, prepared)...)
2938 if err != nil {
2939 t.Fatal(err)
2940 }
2941 originalHook := updateCleanupAfterRename
2942 var hookErr error
2943 updateCleanupAfterRename = func(path, _ string) {
2944 if path == PendingUpdatePath() {
2945 hookErr = os.WriteFile(target, []byte("concurrent"), 0o700)
2946 }
2947 }
2948 t.Cleanup(func() { updateCleanupAfterRename = originalHook })
2949
2950 if err := MarkUpdateHealthyExact(recorded.ToVersion, recorded.CreatedAt, UpdateTransactionID(recorded)); err == nil {
2951 t.Fatal("healthy commit accepted release-unit drift after pending displacement")
2952 }
2953 if hookErr != nil {
2954 t.Fatalf("write concurrent release file: %v", hookErr)
2955 }
2956 if got, err := os.ReadFile(target); err != nil || string(got) != "concurrent" {
2957 t.Fatalf("concurrent release file = %q, %v", got, err)
2958 }
2959 current, err := ReadPendingUpdate()
2960 if err != nil {
2961 t.Fatal(err)
2962 }
2963 if UpdateTransactionID(current) != UpdateTransactionID(recorded) {
2964 t.Fatalf("restored pending transaction = %+v", current)
2965 }
2966 if _, err := os.Stat(recorded.BackupPath); err != nil {
2967 t.Fatalf("healthy rejection removed rollback backup: %v", err)
2968 }
2969 }
2970
2971 func TestMarkUpdateHealthyFailsClosedWhenPendingDisappearsBeforeCommit(t *testing.T) {
2972 t.Setenv("REASONIX_HOME", t.TempDir())
2973 dir := t.TempDir()
2974 target := filepath.Join(dir, "reasonix-desktop")
2975 guard := filepath.Join(dir, "reasonix-guard")
2976 originalExecutable := repairExecutable
2977 repairExecutable = func() (string, error) { return guard, nil }
2978 t.Cleanup(func() { repairExecutable = originalExecutable })
2979 if err := os.WriteFile(target, []byte("old"), 0o700); err != nil {
2980 t.Fatal(err)
2981 }
2982 tx, err := PrepareFileUpdate("v1", "v2", target)
2983 if err != nil {
2984 t.Fatal(err)
2985 }
2986 if _, err := RecordClaimedFileUpdateInstalled(tx, fileUpdateInstallReceiptsForTest(t, tx)...); err != nil {
2987 t.Fatal(err)
2988 }
2989
2990 originalHook := pendingUpdateBeforeCleanup
2991 var removeErr error
2992 pendingUpdateBeforeCleanup = func(path string) {
2993 removeErr = os.Remove(path)
2994 }
2995 t.Cleanup(func() { pendingUpdateBeforeCleanup = originalHook })
2996
2997 if err := MarkUpdateHealthy("v2"); err == nil ||
2998 !strings.Contains(err.Error(), "disappeared before commit") {
2999 t.Fatalf("healthy commit after pending disappearance = %v", err)
3000 }
3001 if removeErr != nil {
3002 t.Fatalf("remove pending transaction: %v", removeErr)
3003 }
3004 if _, err := os.Stat(tx.BackupPath); err != nil {
3005 t.Fatalf("failed healthy commit removed rollback backup: %v", err)
3006 }
3007 }
3008
3009 func TestFileUpdateRollbackRechecksReleaseUnitAfterPendingDisplacement(t *testing.T) {
3010 home := t.TempDir()
3011 t.Setenv("REASONIX_HOME", home)
3012 dir := t.TempDir()
3013 target := filepath.Join(dir, "reasonix-desktop")
3014 guard := filepath.Join(dir, "reasonix-guard")
3015 originalExecutable := repairExecutable
3016 repairExecutable = func() (string, error) { return guard, nil }
3017 t.Cleanup(func() { repairExecutable = originalExecutable })
3018 if err := os.WriteFile(target, []byte("old"), 0o700); err != nil {
3019 t.Fatal(err)
3020 }
3021 if _, err := PrepareFileUpdate("v1", "v2", target); err != nil {
3022 t.Fatal(err)
3023 }
3024 if err := os.WriteFile(target, []byte("new"), 0o700); err != nil {
3025 t.Fatal(err)
3026 }
3027 originalHook := updateCleanupAfterRename
3028 var hookErr error
3029 updateCleanupAfterRename = func(path, _ string) {
3030 if path == PendingUpdatePath() {
3031 hookErr = os.WriteFile(target, []byte("concurrent"), 0o700)
3032 }
3033 }
3034 t.Cleanup(func() { updateCleanupAfterRename = originalHook })
3035
3036 result, err := RollbackPendingUpdate()
3037 if err == nil || !result.RolledBack {
3038 t.Fatalf("rollback commit with late release drift = %+v, %v", result, err)
3039 }
3040 if hookErr != nil {
3041 t.Fatalf("write concurrent release file: %v", hookErr)
3042 }
3043 if got, err := os.ReadFile(target); err != nil || string(got) != "concurrent" {
3044 t.Fatalf("concurrent release file = %q, %v", got, err)
3045 }
3046 if !HasPendingUpdate() {
3047 t.Fatal("late rollback drift removed pending recovery state")
3048 }
3049 }
3050
3051 func TestRollbackPendingUpdateDirectRejectsDriftWhileWaitingForTargetLock(t *testing.T) {
3052 t.Setenv("REASONIX_HOME", t.TempDir())
3053 dir := t.TempDir()
3054 target := filepath.Join(dir, "reasonix-desktop")
3055 guard := filepath.Join(dir, "reasonix-guard")
3056 originalExecutable := repairExecutable
3057 repairExecutable = func() (string, error) { return guard, nil }
3058 t.Cleanup(func() { repairExecutable = originalExecutable })
3059 if err := os.WriteFile(target, []byte("old"), 0o700); err != nil {
3060 t.Fatal(err)
3061 }
3062 if _, err := PrepareFileUpdate("v1", "v2", target); err != nil {
3063 t.Fatal(err)
3064 }
3065 if err := os.WriteFile(target, []byte("initial-new"), 0o700); err != nil {
3066 t.Fatal(err)
3067 }
3068 holder, err := lockRepairMutations(target)
3069 if err != nil {
3070 t.Fatal(err)
3071 }
3072 reachedTargetLock := make(chan struct{})
3073 originalHook := repairMutationBeforeLock
3074 targetKey := canonicalRepairPath(target)
3075 repairMutationBeforeLock = func(paths []string) {
3076 if len(paths) == 1 && paths[0] == targetKey {
3077 select {
3078 case <-reachedTargetLock:
3079 default:
3080 close(reachedTargetLock)
3081 }
3082 }
3083 }
3084 t.Cleanup(func() { repairMutationBeforeLock = originalHook })
3085 type rollbackResult struct {
3086 result UpdateRollbackResult
3087 err error
3088 }
3089 done := make(chan rollbackResult, 1)
3090 go func() {
3091 result, err := RollbackPendingUpdate()
3092 done <- rollbackResult{result: result, err: err}
3093 }()
3094 <-reachedTargetLock
3095 if err := os.WriteFile(target, []byte("changed-while-waiting"), 0o700); err != nil {
3096 t.Fatal(err)
3097 }
3098 holder()
3099 got := <-done
3100 if got.err == nil || got.result.RolledBack ||
3101 !strings.Contains(got.err.Error(), "preview changed since confirmation") {
3102 t.Fatalf("direct rollback after drift = %+v, %v", got.result, got.err)
3103 }
3104 if body, err := os.ReadFile(target); err != nil || string(body) != "changed-while-waiting" {
3105 t.Fatalf("drifted live target = %q, %v", body, err)
3106 }
3107 if !HasPendingUpdate() {
3108 t.Fatal("rejected direct rollback removed pending transaction")
3109 }
3110 }
3111
3112 func TestRetainUpdateRollbackNodeRetriesNameCollision(t *testing.T) {
3113 target := filepath.Join(t.TempDir(), "Reasonix.app")
3114 if err := os.Mkdir(target, 0o700); err != nil {
3115 t.Fatal(err)
3116 }
3117 originalRename := rollbackSwapRename
3118 calls := 0
3119 rollbackSwapRename = func(oldPath, newPath string) error {
3120 calls++
3121 if calls == 1 {
3122 return os.ErrExist
3123 }
3124 return os.Rename(oldPath, newPath)
3125 }
3126 t.Cleanup(func() { rollbackSwapRename = originalRename })
3127
3128 retained, err := retainUpdateRollbackNode(target, "reasonix-failed")
3129 if err != nil {
3130 t.Fatal(err)
3131 }
3132 if calls != 2 || !strings.Contains(retained, ".reasonix-failed-") {
3133 t.Fatalf("retain calls=%d path=%q", calls, retained)
3134 }
3135 if _, err := os.Stat(retained); err != nil {
3136 t.Fatalf("retained app bundle: %v", err)
3137 }
3138 }
3139
3140 func TestFileUpdateRollbackPreservesLiveNodeWithoutInstalledOwnership(t *testing.T) {
3141 home := t.TempDir()
3142 t.Setenv("REASONIX_HOME", home)
3143 dir := t.TempDir()
3144 target := filepath.Join(dir, "reasonix-desktop")
3145 guard := filepath.Join(dir, "reasonix-guard")
3146 originalExecutable := repairExecutable
3147 repairExecutable = func() (string, error) { return guard, nil }
3148 t.Cleanup(func() { repairExecutable = originalExecutable })
3149 if err := os.WriteFile(target, []byte("old"), 0o700); err != nil {
3150 t.Fatal(err)
3151 }
3152 if _, err := PrepareFileUpdate("v1", "v2", target); err != nil {
3153 t.Fatal(err)
3154 }
3155 if err := os.WriteFile(target, []byte("unbound-live"), 0o700); err != nil {
3156 t.Fatal(err)
3157 }
3158
3159 result, err := RollbackPendingUpdate()
3160 if err != nil || !result.RolledBack {
3161 t.Fatalf("rollback = %+v, %v", result, err)
3162 }
3163 if got, err := os.ReadFile(target); err != nil || string(got) != "old" {
3164 t.Fatalf("restored target = %q, %v", got, err)
3165 }
3166 aside := target + ".reasonix-rollback-aside"
3167 if got, err := os.ReadFile(aside); err != nil || string(got) != "unbound-live" {
3168 t.Fatalf("unbound live node was not preserved: %q, %v", got, err)
3169 }
3170 }
3171
3172 func TestPublishClaimedFileUpdateMemberUsesCompareAndPublish(t *testing.T) {
3173 home := t.TempDir()
3174 t.Setenv("REASONIX_HOME", home)
3175 dir := t.TempDir()
3176 target := filepath.Join(dir, "reasonix-desktop")
3177 guard := filepath.Join(dir, "reasonix-guard")
3178 originalExecutable := repairExecutable
3179 repairExecutable = func() (string, error) { return guard, nil }
3180 t.Cleanup(func() { repairExecutable = originalExecutable })
3181 if err := os.WriteFile(target, []byte("old"), 0o700); err != nil {
3182 t.Fatal(err)
3183 }
3184 claimed, err := PrepareFileUpdate("v1", "v2", target)
3185 if err != nil {
3186 t.Fatal(err)
3187 }
3188
3189 if err := PublishClaimedFileUpdateMember(claimed, target, []byte("new"), 0o700); err != nil {
3190 t.Fatal(err)
3191 }
3192 if got, err := os.ReadFile(target); err != nil || string(got) != "new" {
3193 t.Fatalf("published target = %q, %v", got, err)
3194 }
3195 aside := target + ".reasonix-update-aside-" + UpdateTransactionID(claimed)[:16]
3196 if _, err := os.Stat(aside); !os.IsNotExist(err) {
3197 t.Fatalf("verified prior target survived successful publish: %v", err)
3198 }
3199 current, err := ReadPendingUpdate()
3200 if err != nil {
3201 t.Fatal(err)
3202 }
3203 if !reflect.DeepEqual(current, claimed) {
3204 t.Fatalf("member publish rewrote pending transaction: %+v", current)
3205 }
3206 }
3207
3208 func TestRecordClaimedFileUpdateInstalledRejectsDriftAfterPublish(t *testing.T) {
3209 home := t.TempDir()
3210 t.Setenv("REASONIX_HOME", home)
3211 dir := t.TempDir()
3212 target := filepath.Join(dir, "reasonix-desktop")
3213 guard := filepath.Join(dir, "reasonix-guard")
3214 originalExecutable := repairExecutable
3215 repairExecutable = func() (string, error) { return guard, nil }
3216 t.Cleanup(func() { repairExecutable = originalExecutable })
3217 if err := os.WriteFile(target, []byte("old"), 0o700); err != nil {
3218 t.Fatal(err)
3219 }
3220 claimed, err := PrepareFileUpdate("v1", "v2", target)
3221 if err != nil {
3222 t.Fatal(err)
3223 }
3224 receipt, err := PublishClaimedFileUpdateMemberExact(claimed, target, []byte("new"), 0o700)
3225 if err != nil {
3226 t.Fatal(err)
3227 }
3228
3229 if err := os.WriteFile(target, []byte("concurrent"), 0o700); err != nil {
3230 t.Fatal(err)
3231 }
3232 if _, err := RecordClaimedFileUpdateInstalled(claimed, receipt); err == nil ||
3233 !strings.Contains(err.Error(), "release unit changed while recording") {
3234 t.Fatalf("record after post-publish drift = %v", err)
3235 }
3236 if _, err := os.Lstat(installedFileUpdateStatePath(claimed)); !os.IsNotExist(err) {
3237 t.Fatalf("post-publish drift created installed sidecar: %v", err)
3238 }
3239 if !HasPendingUpdate() {
3240 t.Fatal("post-publish drift removed pending recovery state")
3241 }
3242 }
3243
3244 func TestRecordClaimedFileUpdateInstalledRequiresCompletePublishReceipts(t *testing.T) {
3245 home := t.TempDir()
3246 t.Setenv("REASONIX_HOME", home)
3247 dir := t.TempDir()
3248 target := filepath.Join(dir, "reasonix-desktop")
3249 guard := filepath.Join(dir, "reasonix-guard")
3250 originalExecutable := repairExecutable
3251 repairExecutable = func() (string, error) { return guard, nil }
3252 t.Cleanup(func() { repairExecutable = originalExecutable })
3253 if err := os.WriteFile(target, []byte("old"), 0o700); err != nil {
3254 t.Fatal(err)
3255 }
3256 claimed, err := PrepareFileUpdate("v1", "v2", target)
3257 if err != nil {
3258 t.Fatal(err)
3259 }
3260 if err := os.WriteFile(target, []byte("new"), 0o700); err != nil {
3261 t.Fatal(err)
3262 }
3263
3264 if _, err := RecordClaimedFileUpdateInstalled(claimed); err == nil ||
3265 !strings.Contains(err.Error(), "publish receipt is missing") {
3266 t.Fatalf("record without publish receipt = %v", err)
3267 }
3268 receipt := fileUpdateInstallReceiptsForTest(t, claimed)[0]
3269 if _, err := RecordClaimedFileUpdateInstalled(claimed, receipt, receipt); err == nil ||
3270 !strings.Contains(err.Error(), "duplicate publish receipt") {
3271 t.Fatalf("record with duplicate publish receipt = %v", err)
3272 }
3273 foreign := receipt
3274 foreign.UpdateTransactionID = strings.Repeat("0", 64)
3275 if _, err := RecordClaimedFileUpdateInstalled(claimed, foreign); err == nil ||
3276 !strings.Contains(err.Error(), "different transaction") {
3277 t.Fatalf("record with foreign publish receipt = %v", err)
3278 }
3279 }
3280
3281 func TestPublishClaimedFileUpdateMemberPreservesConcurrentRecreate(t *testing.T) {
3282 home := t.TempDir()
3283 t.Setenv("REASONIX_HOME", home)
3284 dir := t.TempDir()
3285 target := filepath.Join(dir, "reasonix-desktop")
3286 guard := filepath.Join(dir, "reasonix-guard")
3287 originalExecutable := repairExecutable
3288 repairExecutable = func() (string, error) { return guard, nil }
3289 t.Cleanup(func() { repairExecutable = originalExecutable })
3290 if err := os.WriteFile(target, []byte("old"), 0o700); err != nil {
3291 t.Fatal(err)
3292 }
3293 claimed, err := PrepareFileUpdate("v1", "v2", target)
3294 if err != nil {
3295 t.Fatal(err)
3296 }
3297 originalHook := fileUpdateAfterRetain
3298 var hookErr error
3299 fileUpdateAfterRetain = func(publicPath, _ string) {
3300 hookErr = os.WriteFile(publicPath, []byte("concurrent"), 0o700)
3301 }
3302 t.Cleanup(func() { fileUpdateAfterRetain = originalHook })
3303
3304 if err := PublishClaimedFileUpdateMember(claimed, target, []byte("new"), 0o700); err == nil {
3305 t.Fatal("member publish overwrote a concurrent target recreation")
3306 }
3307 if hookErr != nil {
3308 t.Fatalf("write concurrent target: %v", hookErr)
3309 }
3310 if got, err := os.ReadFile(target); err != nil || string(got) != "concurrent" {
3311 t.Fatalf("concurrent target = %q, %v", got, err)
3312 }
3313 aside := target + ".reasonix-update-aside-" + UpdateTransactionID(claimed)[:16]
3314 if got, err := os.ReadFile(aside); err != nil || string(got) != "old" {
3315 t.Fatalf("verified prior target recovery material = %q, %v", got, err)
3316 }
3317 if !HasPendingUpdate() {
3318 t.Fatal("failed member publish removed pending recovery state")
3319 }
3320 }
3321
3322 func TestPublishClaimedFileUpdateMemberRestoresPreparedFileWhenStageSwapped(t *testing.T) {
3323 home := t.TempDir()
3324 t.Setenv("REASONIX_HOME", home)
3325 dir := t.TempDir()
3326 target := filepath.Join(dir, "reasonix-desktop")
3327 guard := filepath.Join(dir, "reasonix-guard")
3328 originalExecutable := repairExecutable
3329 repairExecutable = func() (string, error) { return guard, nil }
3330 t.Cleanup(func() { repairExecutable = originalExecutable })
3331 if err := os.WriteFile(target, []byte("old"), 0o700); err != nil {
3332 t.Fatal(err)
3333 }
3334 claimed, err := PrepareFileUpdate("v1", "v2", target)
3335 if err != nil {
3336 t.Fatal(err)
3337 }
3338
3339 originalHook := fileUpdateAfterRetain
3340 var hookErr error
3341 fileUpdateAfterRetain = func(_, _ string) {
3342 stages, err := filepath.Glob(filepath.Join(dir, ".reasonix-desktop.reasonix-update-stage-*"))
3343 if err != nil {
3344 hookErr = err
3345 return
3346 }
3347 if len(stages) != 1 {
3348 hookErr = errors.New("unexpected staged update file count")
3349 return
3350 }
3351 if err := os.Remove(stages[0]); err != nil {
3352 hookErr = err
3353 return
3354 }
3355 hookErr = os.WriteFile(stages[0], []byte("tampered-stage"), 0o700)
3356 }
3357 t.Cleanup(func() { fileUpdateAfterRetain = originalHook })
3358
3359 err = PublishClaimedFileUpdateMember(claimed, target, []byte("new"), 0o700)
3360 if err == nil || !strings.Contains(err.Error(), "installed reasonix-desktop changed") {
3361 t.Fatalf("stage-swapped member publish = %v", err)
3362 }
3363 if hookErr != nil {
3364 t.Fatalf("replace staged update: %v", hookErr)
3365 }
3366 if got, err := os.ReadFile(target); err != nil || string(got) != "old" {
3367 t.Fatalf("restored target = %q, %v", got, err)
3368 }
3369 rejected, err := filepath.Glob(target + ".reasonix-cleanup-*")
3370 if err != nil {
3371 t.Fatal(err)
3372 }
3373 if len(rejected) != 1 {
3374 t.Fatalf("retained rejected files = %v", rejected)
3375 }
3376 if got, err := os.ReadFile(rejected[0]); err != nil || string(got) != "tampered-stage" {
3377 t.Fatalf("retained rejected file = %q, %v", got, err)
3378 }
3379 aside := target + ".reasonix-update-aside-" + UpdateTransactionID(claimed)[:16]
3380 if _, err := os.Lstat(aside); !os.IsNotExist(err) {
3381 t.Fatalf("restored prior target remained aside: %v", err)
3382 }
3383 if !HasPendingUpdate() {
3384 t.Fatal("stage-swapped member publish removed pending recovery state")
3385 }
3386 }
3387
3388 func TestPublishClaimedFileUpdateMemberRejectsSameContentStageSymlink(t *testing.T) {
3389 home := t.TempDir()
3390 t.Setenv("REASONIX_HOME", home)
3391 dir := t.TempDir()
3392 target := filepath.Join(dir, "reasonix-desktop")
3393 guard := filepath.Join(dir, "reasonix-guard")
3394 originalExecutable := repairExecutable
3395 repairExecutable = func() (string, error) { return guard, nil }
3396 t.Cleanup(func() { repairExecutable = originalExecutable })
3397 if err := os.WriteFile(target, []byte("old"), 0o700); err != nil {
3398 t.Fatal(err)
3399 }
3400 claimed, err := PrepareFileUpdate("v1", "v2", target)
3401 if err != nil {
3402 t.Fatal(err)
3403 }
3404 external := filepath.Join(t.TempDir(), "external-new")
3405 if err := os.WriteFile(external, []byte("new"), 0o700); err != nil {
3406 t.Fatal(err)
3407 }
3408
3409 originalHook := fileUpdateAfterRetain
3410 var hookErr error
3411 fileUpdateAfterRetain = func(_, _ string) {
3412 stages, err := filepath.Glob(filepath.Join(dir, ".reasonix-desktop.reasonix-update-stage-*"))
3413 if err != nil {
3414 hookErr = err
3415 return
3416 }
3417 if len(stages) != 1 {
3418 hookErr = errors.New("unexpected staged update file count")
3419 return
3420 }
3421 if err := os.Remove(stages[0]); err != nil {
3422 hookErr = err
3423 return
3424 }
3425 hookErr = os.Symlink(external, stages[0])
3426 }
3427 t.Cleanup(func() { fileUpdateAfterRetain = originalHook })
3428
3429 err = PublishClaimedFileUpdateMember(claimed, target, []byte("new"), 0o700)
3430 if hookErr != nil {
3431 t.Skipf("replace staged update with symlink: %v", hookErr)
3432 }
3433 if err == nil || !strings.Contains(err.Error(), "installed reasonix-desktop changed") {
3434 t.Fatalf("symlink-swapped member publish = %v", err)
3435 }
3436 if got, err := os.ReadFile(target); err != nil || string(got) != "old" {
3437 t.Fatalf("restored target = %q, %v", got, err)
3438 }
3439 rejected, err := filepath.Glob(target + ".reasonix-cleanup-*")
3440 if err != nil || len(rejected) != 1 {
3441 t.Fatalf("retained rejected symlinks = %v, %v", rejected, err)
3442 }
3443 if got, err := os.Readlink(rejected[0]); err != nil || got != external {
3444 t.Fatalf("retained rejected symlink = %q, %v", got, err)
3445 }
3446 if got, err := os.ReadFile(external); err != nil || string(got) != "new" {
3447 t.Fatalf("external file changed = %q, %v", got, err)
3448 }
3449 if !HasPendingUpdate() {
3450 t.Fatal("symlink-swapped member publish removed pending recovery state")
3451 }
3452 }
3453
3454 func TestFileUpdateRollbackCleansInstalledNodeBoundToTransaction(t *testing.T) {
3455 home := t.TempDir()
3456 t.Setenv("REASONIX_HOME", home)
3457 dir := t.TempDir()
3458 target := filepath.Join(dir, "reasonix-desktop")
3459 guard := filepath.Join(dir, "reasonix-guard")
3460 originalExecutable := repairExecutable
3461 repairExecutable = func() (string, error) { return guard, nil }
3462 t.Cleanup(func() { repairExecutable = originalExecutable })
3463 if err := os.WriteFile(target, []byte("old"), 0o700); err != nil {
3464 t.Fatal(err)
3465 }
3466 prepared, err := PrepareFileUpdate("v1", "v2", target)
3467 if err != nil {
3468 t.Fatal(err)
3469 }
3470 if err := os.WriteFile(target, []byte("installed"), 0o700); err != nil {
3471 t.Fatal(err)
3472 }
3473 if _, err := RecordClaimedFileUpdateInstalled(prepared, fileUpdateInstallReceiptsForTest(t, prepared)...); err != nil {
3474 t.Fatal(err)
3475 }
3476
3477 result, err := RollbackPendingUpdate()
3478 if err != nil || !result.RolledBack {
3479 t.Fatalf("rollback = %+v, %v", result, err)
3480 }
3481 if got, err := os.ReadFile(target); err != nil || string(got) != "old" {
3482 t.Fatalf("restored target = %q, %v", got, err)
3483 }
3484 if _, err := os.Stat(target + ".reasonix-rollback-aside"); !os.IsNotExist(err) {
3485 t.Fatalf("transaction-owned installed node survived cleanup: %v", err)
3486 }
3487 }
3488
3489 func TestAppBundleRollbackPreservesLiveBundleWithoutReplacementOwnership(t *testing.T) {
3490 home := t.TempDir()
3491 t.Setenv("REASONIX_HOME", home)
3492 dir, err := filepath.EvalSymlinks(t.TempDir())
3493 if err != nil {
3494 t.Fatal(err)
3495 }
3496 app := filepath.Join(dir, "Reasonix.app")
3497 exe := filepath.Join(app, "Contents", "MacOS", "Reasonix")
3498 if err := os.MkdirAll(filepath.Dir(exe), 0o700); err != nil {
3499 t.Fatal(err)
3500 }
3501 if err := os.WriteFile(exe, []byte("old"), 0o700); err != nil {
3502 t.Fatal(err)
3503 }
3504 originalExecutable := repairExecutable
3505 repairExecutable = func() (string, error) { return exe, nil }
3506 t.Cleanup(func() { repairExecutable = originalExecutable })
3507 backup := app + ".reasonix-update-backup"
3508 if _, err := PrepareAppBundleUpdate("v1", "v2", app, backup); err != nil {
3509 t.Fatal(err)
3510 }
3511 if err := os.Rename(app, backup); err != nil {
3512 t.Fatal(err)
3513 }
3514 if err := os.MkdirAll(filepath.Dir(exe), 0o700); err != nil {
3515 t.Fatal(err)
3516 }
3517 if err := os.WriteFile(exe, []byte("unbound-live"), 0o700); err != nil {
3518 t.Fatal(err)
3519 }
3520
3521 result, err := RollbackPendingUpdate()
3522 if err != nil || !result.RolledBack {
3523 t.Fatalf("rollback = %+v, %v", result, err)
3524 }
3525 if got, err := os.ReadFile(exe); err != nil || string(got) != "old" {
3526 t.Fatalf("restored bundle = %q, %v", got, err)
3527 }
3528 failed, err := filepath.Glob(app + ".reasonix-failed-*")
3529 if err != nil || len(failed) != 1 {
3530 t.Fatalf("retained unbound bundle = %v, %v", failed, err)
3531 }
3532 if got, err := os.ReadFile(filepath.Join(failed[0], "Contents", "MacOS", "Reasonix")); err != nil || string(got) != "unbound-live" {
3533 t.Fatalf("unbound bundle was not preserved: %q, %v", got, err)
3534 }
3535 }
3536
3537 func TestAppBundleRollbackCleansReplacementBoundToStagingTree(t *testing.T) {
3538 home := t.TempDir()
3539 t.Setenv("REASONIX_HOME", home)
3540 dir, err := filepath.EvalSymlinks(t.TempDir())
3541 if err != nil {
3542 t.Fatal(err)
3543 }
3544 app := filepath.Join(dir, "Reasonix.app")
3545 exe := filepath.Join(app, "Contents", "MacOS", "Reasonix")
3546 if err := os.MkdirAll(filepath.Dir(exe), 0o700); err != nil {
3547 t.Fatal(err)
3548 }
3549 if err := os.WriteFile(exe, []byte("old"), 0o700); err != nil {
3550 t.Fatal(err)
3551 }
3552 originalExecutable := repairExecutable
3553 repairExecutable = func() (string, error) { return exe, nil }
3554 t.Cleanup(func() { repairExecutable = originalExecutable })
3555 staging, err := os.MkdirTemp("", "reasonix-mac-update-*")
3556 if err != nil {
3557 t.Fatal(err)
3558 }
3559 t.Cleanup(func() { _ = os.RemoveAll(staging) })
3560 stagedApp := filepath.Join(staging, "Reasonix.app")
3561 stagedExe := filepath.Join(stagedApp, "Contents", "MacOS", "Reasonix")
3562 if err := os.MkdirAll(filepath.Dir(stagedExe), 0o700); err != nil {
3563 t.Fatal(err)
3564 }
3565 if err := os.WriteFile(stagedExe, []byte("installed"), 0o700); err != nil {
3566 t.Fatal(err)
3567 }
3568 backup := app + ".reasonix-update-backup"
3569 if _, err := PrepareAppBundleUpdateHandoff("v1", "v2", app, backup, stagedApp, staging, os.Getpid()); err != nil {
3570 t.Fatal(err)
3571 }
3572 if err := os.Rename(app, backup); err != nil {
3573 t.Fatal(err)
3574 }
3575 if err := os.MkdirAll(filepath.Dir(exe), 0o700); err != nil {
3576 t.Fatal(err)
3577 }
3578 if err := os.WriteFile(exe, []byte("installed"), 0o700); err != nil {
3579 t.Fatal(err)
3580 }
3581
3582 result, err := RollbackPendingUpdate()
3583 if err != nil || !result.RolledBack {
3584 t.Fatalf("rollback = %+v, %v", result, err)
3585 }
3586 if got, err := os.ReadFile(exe); err != nil || string(got) != "old" {
3587 t.Fatalf("restored bundle = %q, %v", got, err)
3588 }
3589 failed, err := filepath.Glob(app + ".reasonix-failed-*")
3590 if err != nil || len(failed) != 0 {
3591 t.Fatalf("transaction-owned replacement survived cleanup: %v, %v", failed, err)
3592 }
3593 }
3594
3594 lines GO