返回 DeepSeek-Reasonix
updater_mac_test.go
根目录 / desktop / updater_mac_test.go
1 //go:build darwin
2
3 package main
4
5 import (
6 "fmt"
7 "os"
8 "os/exec"
9 "path/filepath"
10 "strings"
11 "sync"
12 "testing"
13 "time"
14
15 "reasonix/internal/repair"
16 )
17
18 func installMacHandoffTestDeps(
19 t *testing.T,
20 tx *repair.UpdateTransaction,
21 pendingPath string,
22 logPath string,
23 claim func(string, string, string, time.Duration) (*repair.UpdateTransaction, func(), error),
24 ) {
25 t.Helper()
26 if tx.HandoffAppTreeID == "" {
27 digest, err := repair.AppBundleTreeDigest(tx.HandoffAppPath)
28 if err != nil {
29 t.Fatal(err)
30 }
31 tx.HandoffAppTreeID = digest
32 }
33 if tx.BackupTreeID == "" {
34 digest, err := repair.AppBundleTreeDigest(tx.TargetPath)
35 if err != nil {
36 t.Fatal(err)
37 }
38 tx.BackupTreeID = digest
39 }
40 if tx.HandoffStagingTreeID == "" {
41 digest, err := repair.AppBundleTreeDigest(tx.HandoffStagingPath)
42 if err != nil {
43 t.Fatal(err)
44 }
45 tx.HandoffStagingTreeID = digest
46 }
47 originalRead := readMacUpdateHandoff
48 originalClaim := claimMacUpdateHandoff
49 originalCancel := cancelMacUpdateHandoff
50 originalClear := clearMacUpdateHandoff
51 originalVerify := verifyMacHandoffApp
52 originalCleanupStaging := cleanupMacHandoffStaging
53 originalCleanupReplacement := cleanupMacHandoffReplacement
54 originalCopy := macHandoffCopy
55 originalLogPath := macHandoffLogPath
56 readMacUpdateHandoff = func() (*repair.UpdateTransaction, error) {
57 copy := *tx
58 return &copy, nil
59 }
60 if claim == nil {
61 claim = func(string, string, string, time.Duration) (*repair.UpdateTransaction, func(), error) {
62 copy := *tx
63 return &copy, func() {}, nil
64 }
65 }
66 claimMacUpdateHandoff = claim
67 cancelMacUpdateHandoff = func(*repair.UpdateTransaction, time.Duration) (*repair.UpdateTransaction, error) {
68 copy := *tx
69 _ = os.Remove(pendingPath)
70 return &copy, nil
71 }
72 clearMacUpdateHandoff = func(*repair.UpdateTransaction) error {
73 return os.Remove(pendingPath)
74 }
75 verifyMacHandoffApp = func(string) error { return nil }
76 cleanupMacHandoffStaging = func(tx *repair.UpdateTransaction) error {
77 return os.RemoveAll(tx.HandoffStagingPath)
78 }
79 cleanupMacHandoffReplacement = func(_ *repair.UpdateTransaction, path string) error {
80 return os.RemoveAll(path)
81 }
82 macHandoffCopy = func(oldPath, newPath string) error {
83 return exec.Command("ditto", oldPath, newPath).Run()
84 }
85 macHandoffLogPath = func() string { return logPath }
86 t.Cleanup(func() {
87 readMacUpdateHandoff = originalRead
88 claimMacUpdateHandoff = originalClaim
89 cancelMacUpdateHandoff = originalCancel
90 clearMacUpdateHandoff = originalClear
91 verifyMacHandoffApp = originalVerify
92 cleanupMacHandoffStaging = originalCleanupStaging
93 cleanupMacHandoffReplacement = originalCleanupReplacement
94 macHandoffCopy = originalCopy
95 macHandoffLogPath = originalLogPath
96 })
97 }
98
99 func macHandoffConfigFor(tx *repair.UpdateTransaction) macUpdateHandoffConfig {
100 return macUpdateHandoffConfig{
101 ToVersion: tx.ToVersion,
102 CreatedAt: tx.CreatedAt,
103 TransactionID: repair.UpdateTransactionID(tx),
104 }
105 }
106
107 func TestMacUpdateHandoffWaitsForExactProcessAndRollsBackLaunchFailure(t *testing.T) {
108 root := t.TempDir()
109 oldApp := filepath.Join(root, "Reasonix.app")
110 newApp := filepath.Join(root, "staging", "Reasonix.app")
111 backupApp := oldApp + ".reasonix-update-backup"
112 pending := filepath.Join(root, "pending.json")
113 logPath := filepath.Join(root, "update.log")
114 for _, dir := range []string{oldApp, newApp} {
115 if err := os.MkdirAll(dir, 0o700); err != nil {
116 t.Fatal(err)
117 }
118 }
119 if err := os.WriteFile(filepath.Join(oldApp, "marker"), []byte("old"), 0o600); err != nil {
120 t.Fatal(err)
121 }
122 if err := os.WriteFile(filepath.Join(newApp, "marker"), []byte("new"), 0o600); err != nil {
123 t.Fatal(err)
124 }
125 if err := os.WriteFile(pending, []byte("pending"), 0o600); err != nil {
126 t.Fatal(err)
127 }
128 tx := &repair.UpdateTransaction{
129 ToVersion: "v2",
130 CreatedAt: "2026-07-28T00:00:00Z",
131 TargetKind: "app-bundle",
132 TargetPath: oldApp,
133 BackupPath: backupApp,
134 HandoffAppPath: newApp,
135 HandoffStagingPath: filepath.Dir(newApp),
136 HandoffOwnerPID: 99999999,
137 }
138 installMacHandoffTestDeps(t, tx, pending, logPath, nil)
139
140 originalOpen := openCommand
141 openCommand = func(args ...string) *exec.Cmd {
142 // Force LaunchServices rejection so handoff rolls back under the lock.
143 return exec.Command("/bin/sh", "-c", "exit 1")
144 }
145 t.Cleanup(func() { openCommand = originalOpen })
146
147 code := runMacUpdateHandoff(macHandoffConfigFor(tx))
148 if code == 0 {
149 t.Fatal("handoff should fail when LaunchServices rejects the replacement")
150 }
151
152 marker, err := os.ReadFile(filepath.Join(oldApp, "marker"))
153 if err != nil {
154 t.Fatalf("read restored marker: %v", err)
155 }
156 if string(marker) != "old" {
157 t.Fatalf("restored marker = %q, want old", marker)
158 }
159 if _, err := os.Stat(pending); !os.IsNotExist(err) {
160 t.Fatalf("pending transaction was not cleared: %v", err)
161 }
162 logData, err := os.ReadFile(logPath)
163 if err != nil {
164 t.Fatal(err)
165 }
166 logText := string(logData)
167 if !strings.Contains(logText, "PID 99999999") || !strings.Contains(logText, "rolling back") {
168 t.Fatalf("handoff log lacks PID/rollback diagnostics: %s", logText)
169 }
170 }
171
172 func TestMacUpdateHandoffRetainsRecoveryStateWhenRollbackRestoreFails(t *testing.T) {
173 root := t.TempDir()
174 oldApp := filepath.Join(root, "Reasonix.app")
175 newApp := filepath.Join(root, "staging", "Reasonix.app")
176 backupApp := oldApp + ".reasonix-update-backup"
177 pending := filepath.Join(root, "pending.json")
178 logPath := filepath.Join(root, "update.log")
179 for _, dir := range []string{oldApp, newApp} {
180 if err := os.MkdirAll(dir, 0o700); err != nil {
181 t.Fatal(err)
182 }
183 }
184 if err := os.WriteFile(filepath.Join(oldApp, "marker"), []byte("old"), 0o600); err != nil {
185 t.Fatal(err)
186 }
187 if err := os.WriteFile(filepath.Join(newApp, "marker"), []byte("new"), 0o600); err != nil {
188 t.Fatal(err)
189 }
190 if err := os.WriteFile(pending, []byte("pending"), 0o600); err != nil {
191 t.Fatal(err)
192 }
193 tx := &repair.UpdateTransaction{
194 ToVersion: "v2",
195 CreatedAt: "2026-07-28T00:00:00Z",
196 TargetKind: "app-bundle",
197 TargetPath: oldApp,
198 BackupPath: backupApp,
199 HandoffAppPath: newApp,
200 HandoffStagingPath: filepath.Dir(newApp),
201 HandoffOwnerPID: 99999999,
202 }
203 installMacHandoffTestDeps(t, tx, pending, logPath, nil)
204
205 originalOpen := openCommand
206 openCalls := 0
207 openCommand = func(args ...string) *exec.Cmd {
208 openCalls++
209 return exec.Command("/bin/sh", "-c", "exit 1")
210 }
211 t.Cleanup(func() { openCommand = originalOpen })
212 originalRename := macHandoffRename
213 macHandoffRename = func(oldPath, newPath string) error {
214 if oldPath == backupApp && newPath == oldApp {
215 return fmt.Errorf("injected restore failure")
216 }
217 return os.Rename(oldPath, newPath)
218 }
219 t.Cleanup(func() { macHandoffRename = originalRename })
220
221 code := runMacUpdateHandoff(macHandoffConfigFor(tx))
222 if code == 0 {
223 t.Fatal("handoff should fail when rollback cannot restore the backup")
224 }
225 if _, err := os.Stat(pending); err != nil {
226 t.Fatalf("failed rollback removed pending recovery state: %v", err)
227 }
228 if got, err := os.ReadFile(filepath.Join(backupApp, "marker")); err != nil || string(got) != "old" {
229 t.Fatalf("backup recovery bundle = %q, %v", got, err)
230 }
231 if _, err := os.Stat(filepath.Dir(newApp)); err != nil {
232 t.Fatalf("failed rollback removed staging recovery state: %v", err)
233 }
234 if got, err := os.ReadFile(filepath.Join(oldApp, "marker")); err != nil || string(got) != "new" {
235 t.Fatalf("failed replacement was not compensated back to live path: %q, %v", got, err)
236 }
237 if openCalls != 1 {
238 t.Fatalf("open calls = %d, want only the failed replacement launch", openCalls)
239 }
240 logData, err := os.ReadFile(logPath)
241 if err != nil {
242 t.Fatal(err)
243 }
244 if !strings.Contains(string(logData), "failed to restore backup bundle") {
245 t.Fatalf("handoff log lacks restore failure: %s", logData)
246 }
247 }
248
249 func TestMacUpdateHandoffRestoresOriginalWhenReplacementChangesDuringRollback(t *testing.T) {
250 root := t.TempDir()
251 oldApp := filepath.Join(root, "Reasonix.app")
252 newApp := filepath.Join(root, "staging", "Reasonix.app")
253 backupApp := oldApp + ".reasonix-update-backup"
254 failedApp := ""
255 pending := filepath.Join(root, "pending.json")
256 for _, dir := range []string{oldApp, newApp} {
257 if err := os.MkdirAll(dir, 0o700); err != nil {
258 t.Fatal(err)
259 }
260 }
261 if err := os.WriteFile(filepath.Join(oldApp, "marker"), []byte("old"), 0o600); err != nil {
262 t.Fatal(err)
263 }
264 if err := os.WriteFile(filepath.Join(newApp, "marker"), []byte("new"), 0o600); err != nil {
265 t.Fatal(err)
266 }
267 if err := os.WriteFile(pending, []byte("pending"), 0o600); err != nil {
268 t.Fatal(err)
269 }
270 tx := &repair.UpdateTransaction{
271 ToVersion: "v2",
272 CreatedAt: "2026-07-28T00:00:00Z",
273 TargetKind: "app-bundle",
274 TargetPath: oldApp,
275 BackupPath: backupApp,
276 HandoffAppPath: newApp,
277 HandoffStagingPath: filepath.Dir(newApp),
278 HandoffOwnerPID: 99999999,
279 }
280 installMacHandoffTestDeps(t, tx, pending, filepath.Join(root, "update.log"), nil)
281
282 originalOpen := openCommand
283 openCommand = func(args ...string) *exec.Cmd {
284 return exec.Command("/bin/sh", "-c", "exit 1")
285 }
286 t.Cleanup(func() { openCommand = originalOpen })
287 originalRename := macHandoffRename
288 macHandoffRename = func(oldPath, newPath string) error {
289 if err := originalRename(oldPath, newPath); err != nil {
290 return err
291 }
292 if oldPath == oldApp && strings.Contains(newPath, ".reasonix-update-failed-") {
293 failedApp = newPath
294 return os.WriteFile(filepath.Join(newPath, "marker"), []byte("changed-after-publish"), 0o600)
295 }
296 return nil
297 }
298 t.Cleanup(func() { macHandoffRename = originalRename })
299
300 if code := runMacUpdateHandoff(macHandoffConfigFor(tx)); code == 0 {
301 t.Fatal("handoff should report the failed replacement launch")
302 }
303 if got, err := os.ReadFile(filepath.Join(oldApp, "marker")); err != nil || string(got) != "old" {
304 t.Fatalf("verified original bundle was not restored: %q, %v", got, err)
305 }
306 if failedApp == "" {
307 t.Fatal("failed replacement bundle was not retained")
308 }
309 if got, err := os.ReadFile(filepath.Join(failedApp, "marker")); err != nil || string(got) != "changed-after-publish" {
310 t.Fatalf("changed replacement was not preserved: %q, %v", got, err)
311 }
312 if _, err := os.Stat(pending); !os.IsNotExist(err) {
313 t.Fatalf("verified rollback did not clear pending state: %v", err)
314 }
315 }
316
317 func TestMacUpdateHandoffRejectsBackupChangedDuringRollbackPublish(t *testing.T) {
318 root := t.TempDir()
319 oldApp := filepath.Join(root, "Reasonix.app")
320 newApp := filepath.Join(root, "staging", "Reasonix.app")
321 backupApp := oldApp + ".reasonix-update-backup"
322 pending := filepath.Join(root, "pending.json")
323 for _, dir := range []string{oldApp, newApp} {
324 if err := os.MkdirAll(dir, 0o700); err != nil {
325 t.Fatal(err)
326 }
327 }
328 if err := os.WriteFile(filepath.Join(oldApp, "marker"), []byte("old"), 0o600); err != nil {
329 t.Fatal(err)
330 }
331 if err := os.WriteFile(filepath.Join(newApp, "marker"), []byte("new"), 0o600); err != nil {
332 t.Fatal(err)
333 }
334 if err := os.WriteFile(pending, []byte("pending"), 0o600); err != nil {
335 t.Fatal(err)
336 }
337 tx := &repair.UpdateTransaction{
338 ToVersion: "v2",
339 CreatedAt: "2026-07-28T00:00:00Z",
340 TargetKind: "app-bundle",
341 TargetPath: oldApp,
342 BackupPath: backupApp,
343 HandoffAppPath: newApp,
344 HandoffStagingPath: filepath.Dir(newApp),
345 HandoffOwnerPID: 99999999,
346 }
347 installMacHandoffTestDeps(t, tx, pending, filepath.Join(root, "update.log"), nil)
348
349 originalOpen := openCommand
350 openCommand = func(args ...string) *exec.Cmd {
351 return exec.Command("/bin/sh", "-c", "exit 1")
352 }
353 t.Cleanup(func() { openCommand = originalOpen })
354 originalRename := macHandoffRename
355 macHandoffRename = func(oldPath, newPath string) error {
356 if err := originalRename(oldPath, newPath); err != nil {
357 return err
358 }
359 if oldPath == backupApp && newPath == oldApp {
360 return os.WriteFile(filepath.Join(oldApp, "marker"), []byte("tampered-backup"), 0o600)
361 }
362 return nil
363 }
364 t.Cleanup(func() { macHandoffRename = originalRename })
365
366 if code := runMacUpdateHandoff(macHandoffConfigFor(tx)); code == 0 {
367 t.Fatal("handoff accepted a backup changed during rollback publish")
368 }
369 if got, err := os.ReadFile(filepath.Join(oldApp, "marker")); err != nil || string(got) != "new" {
370 t.Fatalf("verified prior live bundle was not compensated: %q, %v", got, err)
371 }
372 rejected, err := filepath.Glob(oldApp + ".reasonix-update-rejected-*")
373 if err != nil || len(rejected) != 1 {
374 t.Fatalf("rejected backup bundle = %v, %v", rejected, err)
375 }
376 if got, err := os.ReadFile(filepath.Join(rejected[0], "marker")); err != nil || string(got) != "tampered-backup" {
377 t.Fatalf("changed backup was not preserved: %q, %v", got, err)
378 }
379 if _, err := os.Stat(pending); err != nil {
380 t.Fatalf("failed rollback removed pending recovery state: %v", err)
381 }
382 }
383
384 func TestMacUpdateHandoffHoldsMutationLockDuringSwap(t *testing.T) {
385 root := t.TempDir()
386 oldApp := filepath.Join(root, "Reasonix.app")
387 newApp := filepath.Join(root, "staging", "Reasonix.app")
388 backupApp := oldApp + ".reasonix-update-backup"
389 pending := filepath.Join(root, "pending.json")
390 for _, dir := range []string{oldApp, newApp} {
391 if err := os.MkdirAll(dir, 0o700); err != nil {
392 t.Fatal(err)
393 }
394 }
395 if err := os.WriteFile(filepath.Join(oldApp, "marker"), []byte("old"), 0o600); err != nil {
396 t.Fatal(err)
397 }
398 if err := os.WriteFile(filepath.Join(newApp, "marker"), []byte("new"), 0o600); err != nil {
399 t.Fatal(err)
400 }
401 if err := os.WriteFile(pending, []byte("pending"), 0o600); err != nil {
402 t.Fatal(err)
403 }
404 tx := &repair.UpdateTransaction{
405 ToVersion: "v2",
406 CreatedAt: "2026-07-28T00:00:00Z",
407 TargetKind: "app-bundle",
408 TargetPath: oldApp,
409 BackupPath: backupApp,
410 HandoffAppPath: newApp,
411 HandoffStagingPath: filepath.Dir(newApp),
412 HandoffOwnerPID: 99999999,
413 }
414 installMacHandoffTestDeps(
415 t,
416 tx,
417 pending,
418 filepath.Join(root, "update.log"),
419 func(string, string, string, time.Duration) (*repair.UpdateTransaction, func(), error) {
420 unlock, err := repair.LockRepairMutationsTimeout(2*time.Second, oldApp, backupApp)
421 if err != nil {
422 return nil, nil, err
423 }
424 copy := *tx
425 return &copy, unlock, nil
426 },
427 )
428
429 // Hold the target lock first: handoff must wait, not race past Guard.
430 holder, err := repair.LockRepairMutations(oldApp)
431 if err != nil {
432 t.Fatal(err)
433 }
434
435 originalOpen := openCommand
436 openCommand = func(args ...string) *exec.Cmd {
437 return exec.Command("/bin/sh", "-c", "exit 0")
438 }
439 t.Cleanup(func() { openCommand = originalOpen })
440
441 started := make(chan struct{})
442 done := make(chan int, 1)
443 go func() {
444 close(started)
445 done <- runMacUpdateHandoff(macHandoffConfigFor(tx))
446 }()
447 <-started
448 select {
449 case code := <-done:
450 holder()
451 t.Fatalf("handoff completed while mutation lock held: code=%d", code)
452 case <-time.After(300 * time.Millisecond):
453 }
454
455 // Bundle must still be the original while the lock is held.
456 if got, err := os.ReadFile(filepath.Join(oldApp, "marker")); err != nil || string(got) != "old" {
457 holder()
458 t.Fatalf("bundle changed while locked: %q, %v", got, err)
459 }
460 holder()
461 code := <-done
462 if code != 0 {
463 t.Fatalf("handoff after unlock: exit %d", code)
464 }
465 if got, err := os.ReadFile(filepath.Join(oldApp, "marker")); err != nil || string(got) != "new" {
466 t.Fatalf("bundle after handoff = %q, %v", got, err)
467 }
468 }
469
470 func TestMacUpdateHandoffReverifiesStagedBundleBeforeSwap(t *testing.T) {
471 root := t.TempDir()
472 oldApp := filepath.Join(root, "Reasonix.app")
473 newApp := filepath.Join(root, "staging", "Reasonix.app")
474 backupApp := oldApp + ".reasonix-update-backup"
475 pending := filepath.Join(root, "pending.json")
476 for _, dir := range []string{oldApp, newApp} {
477 if err := os.MkdirAll(dir, 0o700); err != nil {
478 t.Fatal(err)
479 }
480 }
481 if err := os.WriteFile(filepath.Join(oldApp, "marker"), []byte("old"), 0o600); err != nil {
482 t.Fatal(err)
483 }
484 if err := os.WriteFile(filepath.Join(newApp, "marker"), []byte("new"), 0o600); err != nil {
485 t.Fatal(err)
486 }
487 if err := os.WriteFile(pending, []byte("pending"), 0o600); err != nil {
488 t.Fatal(err)
489 }
490 tx := &repair.UpdateTransaction{
491 ToVersion: "v2",
492 CreatedAt: "2026-07-28T00:00:00Z",
493 TargetKind: "app-bundle",
494 TargetPath: oldApp,
495 BackupPath: backupApp,
496 HandoffAppPath: newApp,
497 HandoffStagingPath: filepath.Dir(newApp),
498 HandoffOwnerPID: 99999999,
499 }
500 installMacHandoffTestDeps(t, tx, pending, filepath.Join(root, "update.log"), nil)
501 verifyMacHandoffApp = func(string) error { return fmt.Errorf("signature changed") }
502
503 code := runMacUpdateHandoff(macHandoffConfigFor(tx))
504 if code == 0 {
505 t.Fatal("handoff accepted a staged bundle that failed re-verification")
506 }
507 if got, err := os.ReadFile(filepath.Join(oldApp, "marker")); err != nil || string(got) != "old" {
508 t.Fatalf("installed bundle changed after verification failure: %q, %v", got, err)
509 }
510 if _, err := os.Stat(backupApp); !os.IsNotExist(err) {
511 t.Fatalf("backup was created after verification failure: %v", err)
512 }
513 if _, err := os.Stat(pending); !os.IsNotExist(err) {
514 t.Fatalf("pending transaction was not cleared: %v", err)
515 }
516 }
517
518 func TestMacUpdateHandoffPreservesStateWhenSafeClearFails(t *testing.T) {
519 root := t.TempDir()
520 oldApp := filepath.Join(root, "Reasonix.app")
521 newApp := filepath.Join(root, "staging", "Reasonix.app")
522 backupApp := oldApp + ".reasonix-update-backup"
523 pending := filepath.Join(root, "pending.json")
524 for _, dir := range []string{oldApp, newApp} {
525 if err := os.MkdirAll(dir, 0o700); err != nil {
526 t.Fatal(err)
527 }
528 }
529 if err := os.WriteFile(filepath.Join(oldApp, "marker"), []byte("old"), 0o600); err != nil {
530 t.Fatal(err)
531 }
532 if err := os.WriteFile(filepath.Join(newApp, "marker"), []byte("new"), 0o600); err != nil {
533 t.Fatal(err)
534 }
535 if err := os.WriteFile(pending, []byte("pending"), 0o600); err != nil {
536 t.Fatal(err)
537 }
538 tx := &repair.UpdateTransaction{
539 ToVersion: "v2",
540 CreatedAt: "2026-07-28T00:00:00Z",
541 TargetKind: "app-bundle",
542 TargetPath: oldApp,
543 BackupPath: backupApp,
544 HandoffAppPath: newApp,
545 HandoffStagingPath: filepath.Dir(newApp),
546 HandoffOwnerPID: 99999999,
547 }
548 installMacHandoffTestDeps(t, tx, pending, filepath.Join(root, "update.log"), nil)
549 verifyMacHandoffApp = func(path string) error {
550 if path == newApp {
551 return fmt.Errorf("signature changed")
552 }
553 return nil
554 }
555 clearMacUpdateHandoff = func(*repair.UpdateTransaction) error {
556 return fmt.Errorf("original bundle changed")
557 }
558 opened := false
559 originalOpen := openCommand
560 openCommand = func(args ...string) *exec.Cmd {
561 opened = true
562 return exec.Command("/bin/sh", "-c", "exit 0")
563 }
564 t.Cleanup(func() { openCommand = originalOpen })
565
566 if code := runMacUpdateHandoff(macHandoffConfigFor(tx)); code == 0 {
567 t.Fatal("handoff returned success after safe clear failed")
568 }
569 if opened {
570 t.Fatal("handoff launched an app after safe clear failed")
571 }
572 if _, err := os.Stat(pending); err != nil {
573 t.Fatalf("failed safe clear lost pending transaction: %v", err)
574 }
575 if _, err := os.Stat(filepath.Dir(newApp)); err != nil {
576 t.Fatalf("failed safe clear lost staging: %v", err)
577 }
578 }
579
580 func TestMacUpdateHandoffRestartsOriginalAfterRejectedClaim(t *testing.T) {
581 root := t.TempDir()
582 oldApp := filepath.Join(root, "Reasonix.app")
583 newApp := filepath.Join(root, "staging", "Reasonix.app")
584 backupApp := oldApp + ".reasonix-update-backup"
585 pending := filepath.Join(root, "pending.json")
586 for _, dir := range []string{oldApp, newApp} {
587 if err := os.MkdirAll(dir, 0o700); err != nil {
588 t.Fatal(err)
589 }
590 }
591 if err := os.WriteFile(filepath.Join(oldApp, "marker"), []byte("old"), 0o600); err != nil {
592 t.Fatal(err)
593 }
594 if err := os.WriteFile(filepath.Join(newApp, "marker"), []byte("new"), 0o600); err != nil {
595 t.Fatal(err)
596 }
597 if err := os.WriteFile(pending, []byte("pending"), 0o600); err != nil {
598 t.Fatal(err)
599 }
600 tx := &repair.UpdateTransaction{
601 ToVersion: "v2",
602 CreatedAt: "2026-07-28T00:00:00Z",
603 TargetKind: "app-bundle",
604 TargetPath: oldApp,
605 BackupPath: backupApp,
606 HandoffAppPath: newApp,
607 HandoffStagingPath: filepath.Dir(newApp),
608 HandoffOwnerPID: 99999999,
609 }
610 installMacHandoffTestDeps(
611 t,
612 tx,
613 pending,
614 filepath.Join(root, "update.log"),
615 func(string, string, string, time.Duration) (*repair.UpdateTransaction, func(), error) {
616 return nil, nil, fmt.Errorf("staged bundle changed")
617 },
618 )
619 opened := make(chan string, 1)
620 originalOpen := openCommand
621 openCommand = func(args ...string) *exec.Cmd {
622 if len(args) > 0 {
623 opened <- args[len(args)-1]
624 }
625 return exec.Command("/bin/sh", "-c", "exit 0")
626 }
627 t.Cleanup(func() { openCommand = originalOpen })
628
629 if code := runMacUpdateHandoff(macHandoffConfigFor(tx)); code == 0 {
630 t.Fatal("rejected handoff returned success")
631 }
632 select {
633 case got := <-opened:
634 if got != oldApp {
635 t.Fatalf("reopened app = %q, want %q", got, oldApp)
636 }
637 default:
638 t.Fatal("original app was not restarted after safe cancellation")
639 }
640 if _, err := os.Stat(pending); !os.IsNotExist(err) {
641 t.Fatalf("pending transaction survived safe cancellation: %v", err)
642 }
643 if _, err := os.Stat(filepath.Dir(newApp)); !os.IsNotExist(err) {
644 t.Fatalf("staging survived safe cancellation: %v", err)
645 }
646 }
647
648 func TestMacUpdateHandoffRollsBackWhenInstalledBundleFailsVerification(t *testing.T) {
649 root := t.TempDir()
650 oldApp := filepath.Join(root, "Reasonix.app")
651 newApp := filepath.Join(root, "staging", "Reasonix.app")
652 backupApp := oldApp + ".reasonix-update-backup"
653 pending := filepath.Join(root, "pending.json")
654 for _, dir := range []string{oldApp, newApp} {
655 if err := os.MkdirAll(dir, 0o700); err != nil {
656 t.Fatal(err)
657 }
658 }
659 if err := os.WriteFile(filepath.Join(oldApp, "marker"), []byte("old"), 0o600); err != nil {
660 t.Fatal(err)
661 }
662 if err := os.WriteFile(filepath.Join(newApp, "marker"), []byte("new"), 0o600); err != nil {
663 t.Fatal(err)
664 }
665 if err := os.WriteFile(pending, []byte("pending"), 0o600); err != nil {
666 t.Fatal(err)
667 }
668 tx := &repair.UpdateTransaction{
669 ToVersion: "v2",
670 CreatedAt: "2026-07-28T00:00:00Z",
671 TargetKind: "app-bundle",
672 TargetPath: oldApp,
673 BackupPath: backupApp,
674 HandoffAppPath: newApp,
675 HandoffStagingPath: filepath.Dir(newApp),
676 HandoffOwnerPID: 99999999,
677 }
678 installMacHandoffTestDeps(t, tx, pending, filepath.Join(root, "update.log"), nil)
679 verifyCalls := 0
680 verifyMacHandoffApp = func(path string) error {
681 verifyCalls++
682 if path == oldApp {
683 return fmt.Errorf("installed signature changed")
684 }
685 return nil
686 }
687 originalOpen := openCommand
688 openCommand = func(args ...string) *exec.Cmd {
689 return exec.Command("/bin/sh", "-c", "exit 0")
690 }
691 t.Cleanup(func() { openCommand = originalOpen })
692
693 code := runMacUpdateHandoff(macHandoffConfigFor(tx))
694 if code == 0 {
695 t.Fatal("handoff accepted an installed bundle that failed verification")
696 }
697 if verifyCalls != 3 {
698 t.Fatalf("bundle verification calls = %d, want source, sibling staging, and installed target", verifyCalls)
699 }
700 if got, err := os.ReadFile(filepath.Join(oldApp, "marker")); err != nil || string(got) != "old" {
701 t.Fatalf("rollback after installed verification failure = %q, %v", got, err)
702 }
703 if _, err := os.Stat(pending); !os.IsNotExist(err) {
704 t.Fatalf("pending transaction survived completed rollback: %v", err)
705 }
706 }
707
708 func TestMacUpdateHandoffRestoresOriginalWhenItChangesDuringRename(t *testing.T) {
709 root := t.TempDir()
710 oldApp := filepath.Join(root, "Reasonix.app")
711 newApp := filepath.Join(root, "staging", "Reasonix.app")
712 backupApp := oldApp + ".reasonix-update-backup"
713 pending := filepath.Join(root, "pending.json")
714 for _, dir := range []string{oldApp, newApp} {
715 if err := os.MkdirAll(dir, 0o700); err != nil {
716 t.Fatal(err)
717 }
718 }
719 oldMarker := filepath.Join(oldApp, "marker")
720 if err := os.WriteFile(oldMarker, []byte("old"), 0o600); err != nil {
721 t.Fatal(err)
722 }
723 if err := os.WriteFile(filepath.Join(newApp, "marker"), []byte("new"), 0o600); err != nil {
724 t.Fatal(err)
725 }
726 if err := os.WriteFile(pending, []byte("pending"), 0o600); err != nil {
727 t.Fatal(err)
728 }
729 tx := &repair.UpdateTransaction{
730 ToVersion: "v2",
731 CreatedAt: "2026-07-28T00:00:00Z",
732 TargetKind: "app-bundle",
733 TargetPath: oldApp,
734 BackupPath: backupApp,
735 HandoffAppPath: newApp,
736 HandoffStagingPath: filepath.Dir(newApp),
737 HandoffOwnerPID: 99999999,
738 }
739 installMacHandoffTestDeps(t, tx, pending, filepath.Join(root, "update.log"), nil)
740 originalRename := macHandoffRename
741 changed := false
742 macHandoffRename = func(from, to string) error {
743 if !changed && from == oldApp && to == backupApp {
744 changed = true
745 if err := os.WriteFile(oldMarker, []byte("concurrent"), 0o600); err != nil {
746 return err
747 }
748 }
749 return originalRename(from, to)
750 }
751 t.Cleanup(func() { macHandoffRename = originalRename })
752 originalOpen := openCommand
753 openCommand = func(args ...string) *exec.Cmd {
754 return exec.Command("/bin/sh", "-c", "exit 0")
755 }
756 t.Cleanup(func() { openCommand = originalOpen })
757
758 code := runMacUpdateHandoff(macHandoffConfigFor(tx))
759 if code == 0 {
760 t.Fatal("handoff accepted an original bundle that changed during rename")
761 }
762 if _, err := os.Stat(oldApp); !os.IsNotExist(err) {
763 t.Fatalf("unverified original was restored to the live path: %v", err)
764 }
765 if _, err := os.Stat(backupApp); !os.IsNotExist(err) {
766 t.Fatalf("changed backup remained at the rollback path: %v", err)
767 }
768 rejected, err := filepath.Glob(oldApp + ".reasonix-update-rejected-*")
769 if err != nil || len(rejected) != 1 {
770 t.Fatalf("rejected original bundle = %v, %v", rejected, err)
771 }
772 if got, err := os.ReadFile(filepath.Join(rejected[0], "marker")); err != nil || string(got) != "concurrent" {
773 t.Fatalf("changed original was not preserved: %q, %v", got, err)
774 }
775 if _, err := os.Stat(pending); err != nil {
776 t.Fatalf("failed rollback removed pending recovery state: %v", err)
777 }
778 if got, err := os.ReadFile(filepath.Join(newApp, "marker")); err != nil || string(got) != "new" {
779 t.Fatalf("failed rollback removed replacement recovery state: %q, %v", got, err)
780 }
781 }
782
783 func TestMacUpdateHandoffRejectsTransactionChangedDuringPIDWait(t *testing.T) {
784 root := t.TempDir()
785 oldApp := filepath.Join(root, "Reasonix.app")
786 newApp := filepath.Join(root, "staging", "Reasonix.app")
787 backupApp := oldApp + ".reasonix-update-backup"
788 pending := filepath.Join(root, "pending.json")
789 for _, dir := range []string{oldApp, newApp} {
790 if err := os.MkdirAll(dir, 0o700); err != nil {
791 t.Fatal(err)
792 }
793 }
794 if err := os.WriteFile(filepath.Join(oldApp, "marker"), []byte("old"), 0o600); err != nil {
795 t.Fatal(err)
796 }
797 if err := os.WriteFile(filepath.Join(newApp, "marker"), []byte("new"), 0o600); err != nil {
798 t.Fatal(err)
799 }
800 if err := os.WriteFile(pending, []byte("pending"), 0o600); err != nil {
801 t.Fatal(err)
802 }
803 tx := &repair.UpdateTransaction{
804 ToVersion: "v2",
805 CreatedAt: "2026-07-28T00:00:00Z",
806 TargetKind: "app-bundle",
807 TargetPath: oldApp,
808 BackupPath: backupApp,
809 HandoffAppPath: newApp,
810 HandoffStagingPath: filepath.Dir(newApp),
811 HandoffOwnerPID: 99999999,
812 }
813 released := false
814 installMacHandoffTestDeps(
815 t,
816 tx,
817 pending,
818 filepath.Join(root, "update.log"),
819 func(string, string, string, time.Duration) (*repair.UpdateTransaction, func(), error) {
820 changed := *tx
821 changed.HandoffOwnerPID++
822 return &changed, func() { released = true }, nil
823 },
824 )
825
826 if code := runMacUpdateHandoff(macHandoffConfigFor(tx)); code == 0 {
827 t.Fatal("handoff accepted a transaction changed during PID wait")
828 }
829 if !released {
830 t.Fatal("rejected changed transaction did not release its claim")
831 }
832 if got, err := os.ReadFile(filepath.Join(oldApp, "marker")); err != nil || string(got) != "old" {
833 t.Fatalf("installed bundle changed after transaction drift: %q, %v", got, err)
834 }
835 if _, err := os.Stat(backupApp); !os.IsNotExist(err) {
836 t.Fatalf("backup was created after transaction drift: %v", err)
837 }
838 if _, err := os.Stat(pending); err != nil {
839 t.Fatalf("changed transaction was unsafely cancelled: %v", err)
840 }
841 }
842
843 func TestMacUpdateHandoffRejectsTransactionRewrittenBeforeFirstRead(t *testing.T) {
844 root := t.TempDir()
845 oldApp := filepath.Join(root, "Reasonix.app")
846 newApp := filepath.Join(root, "staging", "Reasonix.app")
847 backupApp := oldApp + ".reasonix-update-backup"
848 pending := filepath.Join(root, "pending.json")
849 for _, dir := range []string{oldApp, newApp} {
850 if err := os.MkdirAll(dir, 0o700); err != nil {
851 t.Fatal(err)
852 }
853 }
854 for path, content := range map[string]string{
855 filepath.Join(oldApp, "marker"): "old",
856 filepath.Join(newApp, "marker"): "new",
857 pending: "pending",
858 } {
859 if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
860 t.Fatal(err)
861 }
862 }
863 tx := &repair.UpdateTransaction{
864 ToVersion: "v2",
865 CreatedAt: "2026-07-28T00:00:00Z",
866 TargetKind: "app-bundle",
867 TargetPath: oldApp,
868 BackupPath: backupApp,
869 HandoffAppPath: newApp,
870 HandoffStagingPath: filepath.Dir(newApp),
871 HandoffOwnerPID: 99999999,
872 }
873 claimed := false
874 installMacHandoffTestDeps(
875 t,
876 tx,
877 pending,
878 filepath.Join(root, "update.log"),
879 func(string, string, string, time.Duration) (*repair.UpdateTransaction, func(), error) {
880 claimed = true
881 copy := *tx
882 return &copy, func() {}, nil
883 },
884 )
885 cfg := macHandoffConfigFor(tx)
886 tx.FromVersion = "rewritten"
887
888 if code := runMacUpdateHandoff(cfg); code == 0 {
889 t.Fatal("handoff accepted a transaction rewritten before its first read")
890 }
891 if claimed {
892 t.Fatal("rewritten transaction reached the claim path")
893 }
894 if got, err := os.ReadFile(filepath.Join(oldApp, "marker")); err != nil || string(got) != "old" {
895 t.Fatalf("installed bundle changed after rewritten transaction: %q, %v", got, err)
896 }
897 if _, err := os.Stat(pending); err != nil {
898 t.Fatalf("rewritten pending transaction was removed: %v", err)
899 }
900 }
901
902 func TestMacUpdateHandoffPreservesConcurrentCreateBeforePublish(t *testing.T) {
903 root := t.TempDir()
904 oldApp := filepath.Join(root, "Reasonix.app")
905 newApp := filepath.Join(root, "staging", "Reasonix.app")
906 backupApp := oldApp + ".reasonix-update-backup"
907 pending := filepath.Join(root, "pending.json")
908 for _, dir := range []string{oldApp, newApp} {
909 if err := os.MkdirAll(dir, 0o700); err != nil {
910 t.Fatal(err)
911 }
912 }
913 if err := os.WriteFile(filepath.Join(oldApp, "marker"), []byte("old"), 0o600); err != nil {
914 t.Fatal(err)
915 }
916 if err := os.WriteFile(filepath.Join(newApp, "marker"), []byte("new"), 0o600); err != nil {
917 t.Fatal(err)
918 }
919 if err := os.WriteFile(pending, []byte("pending"), 0o600); err != nil {
920 t.Fatal(err)
921 }
922 tx := &repair.UpdateTransaction{
923 ToVersion: "v2",
924 CreatedAt: "2026-07-28T00:00:00Z",
925 TargetKind: "app-bundle",
926 TargetPath: oldApp,
927 BackupPath: backupApp,
928 HandoffAppPath: newApp,
929 HandoffStagingPath: filepath.Dir(newApp),
930 HandoffOwnerPID: 99999999,
931 }
932 installMacHandoffTestDeps(t, tx, pending, filepath.Join(root, "update.log"), nil)
933
934 originalRename := macHandoffRename
935 recreated := false
936 macHandoffRename = func(from, to string) error {
937 if !recreated && to == oldApp && from != backupApp {
938 recreated = true
939 if err := os.MkdirAll(oldApp, 0o700); err != nil {
940 return err
941 }
942 if err := os.WriteFile(filepath.Join(oldApp, "marker"), []byte("concurrent"), 0o600); err != nil {
943 return err
944 }
945 }
946 return originalRename(from, to)
947 }
948 t.Cleanup(func() { macHandoffRename = originalRename })
949 opened := false
950 originalOpen := openCommand
951 openCommand = func(args ...string) *exec.Cmd {
952 opened = true
953 return exec.Command("/bin/sh", "-c", "exit 0")
954 }
955 t.Cleanup(func() { openCommand = originalOpen })
956
957 if code := runMacUpdateHandoff(macHandoffConfigFor(tx)); code == 0 {
958 t.Fatal("handoff replaced a bundle recreated before atomic publish")
959 }
960 if !recreated {
961 t.Fatal("test did not inject a concurrent destination")
962 }
963 if opened {
964 t.Fatal("handoff launched an app after publish conflict")
965 }
966 if got, err := os.ReadFile(filepath.Join(oldApp, "marker")); err != nil || string(got) != "concurrent" {
967 t.Fatalf("concurrent bundle was not preserved: %q, %v", got, err)
968 }
969 if got, err := os.ReadFile(filepath.Join(backupApp, "marker")); err != nil || string(got) != "old" {
970 t.Fatalf("rollback backup was not preserved: %q, %v", got, err)
971 }
972 if _, err := os.Stat(pending); err != nil {
973 t.Fatalf("pending recovery state was removed after publish conflict: %v", err)
974 }
975 if _, err := os.Stat(filepath.Dir(newApp)); err != nil {
976 t.Fatalf("source staging was removed after publish conflict: %v", err)
977 }
978 }
979
980 func TestMacUpdateHandoffParserRejectsFilesystemPaths(t *testing.T) {
981 _, err := parseMacUpdateHandoffArgs([]string{
982 "-to-version", "v2",
983 "-created-at", "2026-07-28T00:00:00Z",
984 "-transaction-id", strings.Repeat("a", 64),
985 "-old-app", "/tmp/Unrelated.app",
986 })
987 if err == nil {
988 t.Fatal("legacy filesystem path argument was accepted")
989 }
990 }
991
992 func TestMacUpdateHandoffParserRequiresCompletePipePair(t *testing.T) {
993 _, err := parseMacUpdateHandoffArgs([]string{
994 "-to-version", "v2",
995 "-created-at", "2026-07-28T00:00:00Z",
996 "-transaction-id", strings.Repeat("a", 64),
997 "-ready-fd", "3",
998 })
999 if err == nil || !strings.Contains(err.Error(), "pipe arguments") {
1000 t.Fatalf("partial pipe pair error = %v", err)
1001 }
1002 }
1003
1004 func TestMacUpdateHandoffReadinessSurfacesChildStartupFailure(t *testing.T) {
1005 readyReader, readyWriter, err := os.Pipe()
1006 if err != nil {
1007 t.Fatal(err)
1008 }
1009 defer readyReader.Close()
1010 originalRead := readMacUpdateHandoff
1011 originalLogPath := macHandoffLogPath
1012 readMacUpdateHandoff = func() (*repair.UpdateTransaction, error) {
1013 return nil, fmt.Errorf("pending transaction is unreadable")
1014 }
1015 macHandoffLogPath = func() string { return filepath.Join(t.TempDir(), "update-helper.log") }
1016 t.Cleanup(func() {
1017 readMacUpdateHandoff = originalRead
1018 macHandoffLogPath = originalLogPath
1019 })
1020 done := make(chan int, 1)
1021 go func() {
1022 done <- runMacUpdateHandoff(macUpdateHandoffConfig{
1023 ToVersion: "v2",
1024 CreatedAt: "2026-07-28T00:00:00Z",
1025 TransactionID: strings.Repeat("a", 64),
1026 ReadyFD: int(readyWriter.Fd()),
1027 ProceedFD: int(readyWriter.Fd()) + 1,
1028 })
1029 }()
1030 err = waitForMacHandoffReady(readyReader, time.Second)
1031 if err == nil || !strings.Contains(err.Error(), "read-pending-transaction") ||
1032 !strings.Contains(err.Error(), "pending transaction is unreadable") {
1033 t.Fatalf("readiness error = %v", err)
1034 }
1035 if code := <-done; code == 0 {
1036 t.Fatal("helper startup failure returned success")
1037 }
1038 }
1039
1040 func TestMacUpdateHandoffHandshakeWaitsForParentRelease(t *testing.T) {
1041 readyReader, readyWriter, err := os.Pipe()
1042 if err != nil {
1043 t.Fatal(err)
1044 }
1045 proceedReader, proceedWriter, err := os.Pipe()
1046 if err != nil {
1047 t.Fatal(err)
1048 }
1049 defer readyReader.Close()
1050 defer proceedWriter.Close()
1051
1052 done := make(chan error, 1)
1053 go func() {
1054 done <- completeMacHandoffHandshake(macUpdateHandoffConfig{
1055 ReadyFD: int(readyWriter.Fd()),
1056 ProceedFD: int(proceedReader.Fd()),
1057 })
1058 }()
1059 if err := waitForMacHandoffReady(readyReader, time.Second); err != nil {
1060 t.Fatal(err)
1061 }
1062 select {
1063 case err := <-done:
1064 t.Fatalf("handshake completed before parent release: %v", err)
1065 default:
1066 }
1067 if _, err := proceedWriter.Write([]byte("go")); err != nil {
1068 t.Fatal(err)
1069 }
1070 if err := proceedWriter.Close(); err != nil {
1071 t.Fatal(err)
1072 }
1073 select {
1074 case err := <-done:
1075 if err != nil {
1076 t.Fatal(err)
1077 }
1078 case <-time.After(time.Second):
1079 t.Fatal("handshake did not finish after parent release")
1080 }
1081 }
1082
1083 func TestMacUpdateHandoffHandshakeRejectsParentExit(t *testing.T) {
1084 readyReader, readyWriter, err := os.Pipe()
1085 if err != nil {
1086 t.Fatal(err)
1087 }
1088 proceedReader, proceedWriter, err := os.Pipe()
1089 if err != nil {
1090 t.Fatal(err)
1091 }
1092 defer readyReader.Close()
1093 if err := proceedWriter.Close(); err != nil {
1094 t.Fatal(err)
1095 }
1096
1097 done := make(chan error, 1)
1098 go func() {
1099 done <- completeMacHandoffHandshake(macUpdateHandoffConfig{
1100 ReadyFD: int(readyWriter.Fd()),
1101 ProceedFD: int(proceedReader.Fd()),
1102 })
1103 }()
1104 if err := waitForMacHandoffReady(readyReader, time.Second); err != nil {
1105 t.Fatal(err)
1106 }
1107 select {
1108 case err := <-done:
1109 if err == nil || !strings.Contains(err.Error(), "parent release") {
1110 t.Fatalf("parent exit error = %v", err)
1111 }
1112 case <-time.After(time.Second):
1113 t.Fatal("handshake did not observe parent exit")
1114 }
1115 }
1116
1117 func TestMacUpdateHandoffParentExitCancelsPreparedTransaction(t *testing.T) {
1118 root := t.TempDir()
1119 oldApp := filepath.Join(root, "Reasonix.app")
1120 newApp := filepath.Join(root, "staging", "Reasonix.app")
1121 pending := filepath.Join(root, "pending.json")
1122 for _, dir := range []string{oldApp, newApp} {
1123 if err := os.MkdirAll(dir, 0o700); err != nil {
1124 t.Fatal(err)
1125 }
1126 }
1127 if err := os.WriteFile(filepath.Join(oldApp, "marker"), []byte("old"), 0o600); err != nil {
1128 t.Fatal(err)
1129 }
1130 if err := os.WriteFile(filepath.Join(newApp, "marker"), []byte("new"), 0o600); err != nil {
1131 t.Fatal(err)
1132 }
1133 if err := os.WriteFile(pending, []byte("pending"), 0o600); err != nil {
1134 t.Fatal(err)
1135 }
1136 tx := &repair.UpdateTransaction{
1137 ToVersion: "v2",
1138 CreatedAt: "2026-07-28T00:00:00Z",
1139 TargetKind: "app-bundle",
1140 TargetPath: oldApp,
1141 BackupPath: oldApp + ".reasonix-update-backup",
1142 HandoffAppPath: newApp,
1143 HandoffStagingPath: filepath.Dir(newApp),
1144 HandoffOwnerPID: os.Getpid(),
1145 }
1146 installMacHandoffTestDeps(t, tx, pending, filepath.Join(root, "update.log"), nil)
1147 readyReader, readyWriter, err := os.Pipe()
1148 if err != nil {
1149 t.Fatal(err)
1150 }
1151 proceedReader, proceedWriter, err := os.Pipe()
1152 if err != nil {
1153 t.Fatal(err)
1154 }
1155 defer readyReader.Close()
1156 if err := proceedWriter.Close(); err != nil {
1157 t.Fatal(err)
1158 }
1159 cfg := macHandoffConfigFor(tx)
1160 cfg.ReadyFD = int(readyWriter.Fd())
1161 cfg.ProceedFD = int(proceedReader.Fd())
1162
1163 if code := runMacUpdateHandoff(cfg); code == 0 {
1164 t.Fatal("handoff accepted a parent that exited before release")
1165 }
1166 if _, err := os.Stat(pending); !os.IsNotExist(err) {
1167 t.Fatalf("abandoned pending transaction survived: %v", err)
1168 }
1169 if got, err := os.ReadFile(filepath.Join(oldApp, "marker")); err != nil || string(got) != "old" {
1170 t.Fatalf("original app changed = %q, %v", got, err)
1171 }
1172 }
1173
1174 func TestMacHandoffRenameDoesNotReplaceExistingDestination(t *testing.T) {
1175 dir := t.TempDir()
1176 source := filepath.Join(dir, "source")
1177 destination := filepath.Join(dir, "destination")
1178 if err := os.WriteFile(source, []byte("source"), 0o600); err != nil {
1179 t.Fatal(err)
1180 }
1181 if err := os.WriteFile(destination, []byte("destination"), 0o600); err != nil {
1182 t.Fatal(err)
1183 }
1184 if err := macHandoffRename(source, destination); err == nil {
1185 t.Fatal("macOS handoff rename overwrote an existing destination")
1186 }
1187 for path, want := range map[string]string{source: "source", destination: "destination"} {
1188 got, err := os.ReadFile(path)
1189 if err != nil || string(got) != want {
1190 t.Fatalf("%s = %q, %v; want %q", filepath.Base(path), got, err, want)
1191 }
1192 }
1193 }
1194
1195 func TestRetainMacHandoffNodeRetriesNameCollision(t *testing.T) {
1196 path := filepath.Join(t.TempDir(), "Reasonix.app")
1197 if err := os.Mkdir(path, 0o700); err != nil {
1198 t.Fatal(err)
1199 }
1200 originalRename := macHandoffRename
1201 calls := 0
1202 macHandoffRename = func(oldPath, newPath string) error {
1203 calls++
1204 if calls == 1 {
1205 return os.ErrExist
1206 }
1207 return originalRename(oldPath, newPath)
1208 }
1209 t.Cleanup(func() { macHandoffRename = originalRename })
1210
1211 retained, err := retainMacHandoffNode(path, "reasonix-update-failed")
1212 if err != nil {
1213 t.Fatal(err)
1214 }
1215 if calls != 2 || !strings.Contains(retained, ".reasonix-update-failed-") {
1216 t.Fatalf("retain calls=%d path=%q", calls, retained)
1217 }
1218 if _, err := os.Stat(retained); err != nil {
1219 t.Fatalf("retained bundle: %v", err)
1220 }
1221 }
1222
1223 func TestCleanupOwnedMacUpdateDirectoryPreservesConcurrentRecreate(t *testing.T) {
1224 parent := t.TempDir()
1225 path, err := os.MkdirTemp(parent, ".reasonix-update-install-*")
1226 if err != nil {
1227 t.Fatal(err)
1228 }
1229 owner, err := os.Lstat(path)
1230 if err != nil {
1231 t.Fatal(err)
1232 }
1233 originalHook := macUpdateCleanupAfterRename
1234 macUpdateCleanupAfterRename = func(original, _ string) {
1235 if original != path {
1236 return
1237 }
1238 if err := os.Mkdir(original, 0o700); err != nil {
1239 t.Fatal(err)
1240 }
1241 if err := os.WriteFile(filepath.Join(original, "concurrent"), []byte("keep"), 0o600); err != nil {
1242 t.Fatal(err)
1243 }
1244 }
1245 t.Cleanup(func() { macUpdateCleanupAfterRename = originalHook })
1246
1247 if err := cleanupOwnedMacUpdateDirectory(path, owner); err != nil {
1248 t.Fatal(err)
1249 }
1250 if got, err := os.ReadFile(filepath.Join(path, "concurrent")); err != nil || string(got) != "keep" {
1251 t.Fatalf("concurrent directory = %q, %v; want preserved", got, err)
1252 }
1253 }
1254
1255 func TestCleanupOwnedMacUpdateDirectoryRejectsReplacedRoot(t *testing.T) {
1256 parent := t.TempDir()
1257 path, err := os.MkdirTemp(parent, ".reasonix-update-install-*")
1258 if err != nil {
1259 t.Fatal(err)
1260 }
1261 owner, err := os.Lstat(path)
1262 if err != nil {
1263 t.Fatal(err)
1264 }
1265 if err := os.Remove(path); err != nil {
1266 t.Fatal(err)
1267 }
1268 if err := os.Mkdir(path, 0o700); err != nil {
1269 t.Fatal(err)
1270 }
1271 if err := os.WriteFile(filepath.Join(path, "replacement"), []byte("keep"), 0o600); err != nil {
1272 t.Fatal(err)
1273 }
1274
1275 if err := cleanupOwnedMacUpdateDirectory(path, owner); err == nil ||
1276 !strings.Contains(err.Error(), "changed before cleanup") {
1277 t.Fatalf("cleanup error = %v, want ownership rejection", err)
1278 }
1279 if got, err := os.ReadFile(filepath.Join(path, "replacement")); err != nil || string(got) != "keep" {
1280 t.Fatalf("replacement directory = %q, %v; want preserved", got, err)
1281 }
1282 }
1283
1284 func TestMacUpdateHandoffSerializesWithConcurrentRollback(t *testing.T) {
1285 // Ensure LockRepairMutations used by handoff and repair share identity.
1286 root, err := filepath.EvalSymlinks(t.TempDir())
1287 if err != nil {
1288 t.Fatal(err)
1289 }
1290 oldApp := filepath.Join(root, "Reasonix.app")
1291 if err := os.MkdirAll(oldApp, 0o700); err != nil {
1292 t.Fatal(err)
1293 }
1294 var wg sync.WaitGroup
1295 order := make(chan string, 2)
1296 wg.Add(2)
1297 go func() {
1298 defer wg.Done()
1299 unlock, err := repair.LockRepairMutationsTimeout(2*time.Second, oldApp)
1300 if err != nil {
1301 t.Errorf("first lock: %v", err)
1302 return
1303 }
1304 order <- "a"
1305 time.Sleep(200 * time.Millisecond)
1306 unlock()
1307 }()
1308 go func() {
1309 defer wg.Done()
1310 time.Sleep(20 * time.Millisecond)
1311 unlock, err := repair.LockRepairMutationsTimeout(2*time.Second, oldApp)
1312 if err != nil {
1313 t.Errorf("second lock: %v", err)
1314 return
1315 }
1316 order <- "b"
1317 unlock()
1318 }()
1319 wg.Wait()
1320 first := <-order
1321 second := <-order
1322 if first != "a" || second != "b" {
1323 t.Fatalf("lock order = %s then %s, want a then b", first, second)
1324 }
1325 }
1326
1326 lines GO