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