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