返回 DeepSeek-Reasonix
update_handoff_test.go
根目录 / internal / repair / update_handoff_test.go
1 package repair
2
3 import (
4 "errors"
5 "os"
6 "path/filepath"
7 "runtime"
8 "strings"
9 "sync"
10 "testing"
11 "time"
12 )
13
14 func prepareTestAppBundleHandoff(t *testing.T) (*UpdateTransaction, string) {
15 t.Helper()
16 t.Setenv("REASONIX_HOME", t.TempDir())
17
18 installRoot, err := filepath.EvalSymlinks(t.TempDir())
19 if err != nil {
20 t.Fatal(err)
21 }
22 app := filepath.Join(installRoot, "Reasonix.app")
23 exe := filepath.Join(app, "Contents", "MacOS", "Reasonix")
24 if err := os.MkdirAll(filepath.Dir(exe), 0o700); err != nil {
25 t.Fatal(err)
26 }
27 if err := os.WriteFile(exe, []byte("current"), 0o700); err != nil {
28 t.Fatal(err)
29 }
30 originalExecutable := repairExecutable
31 repairExecutable = func() (string, error) { return exe, nil }
32 t.Cleanup(func() { repairExecutable = originalExecutable })
33
34 staging, err := os.MkdirTemp("", "reasonix-mac-update-*")
35 if err != nil {
36 t.Fatal(err)
37 }
38 t.Cleanup(func() { _ = os.RemoveAll(staging) })
39 stagedApp := filepath.Join(staging, "Reasonix.app")
40 if err := os.MkdirAll(stagedApp, 0o700); err != nil {
41 t.Fatal(err)
42 }
43 tx, err := PrepareAppBundleUpdateHandoff(
44 "v1",
45 "v2",
46 app,
47 app+".reasonix-update-backup",
48 stagedApp,
49 staging,
50 os.Getpid(),
51 )
52 if err != nil {
53 t.Fatal(err)
54 }
55 return tx, staging
56 }
57
58 func TestReconcilePendingUpdateCancelsAbandonedSameVersionAppHandoff(t *testing.T) {
59 tx, staging := prepareTestAppBundleHandoff(t)
60
61 result, err := ReconcilePendingUpdate(tx.ToVersion)
62 if err != nil {
63 t.Fatal(err)
64 }
65 if !result.Pending || !result.Cleared || result.RolledBack || result.AwaitingHealth {
66 t.Fatalf("reconcile result = %+v", result)
67 }
68 if _, err := os.Stat(PendingUpdatePath()); !os.IsNotExist(err) {
69 t.Fatalf("pending transaction survived reconcile: %v", err)
70 }
71 if _, err := os.Stat(staging); !os.IsNotExist(err) {
72 t.Fatalf("verified handoff staging survived reconcile: %v", err)
73 }
74 if err := VerifyAppBundleUpdateHandoffOriginal(tx); err != nil {
75 t.Fatalf("original bundle changed during reconcile: %v", err)
76 }
77 }
78
79 func TestReconcilePendingUpdateLeavesProbationaryTarget(t *testing.T) {
80 tx, staging := prepareTestAppBundleHandoff(t)
81 if err := os.Rename(tx.TargetPath, tx.BackupPath); err != nil {
82 t.Fatal(err)
83 }
84 if err := os.Rename(tx.HandoffAppPath, tx.TargetPath); err != nil {
85 t.Fatal(err)
86 }
87
88 result, err := ReconcilePendingUpdate(tx.ToVersion)
89 if !errors.Is(err, ErrPendingUpdateAwaitingHealth) {
90 t.Fatalf("reconcile error = %v", err)
91 }
92 if !result.Pending || !result.AwaitingHealth || result.Cleared || result.RolledBack {
93 t.Fatalf("reconcile result = %+v", result)
94 }
95 if _, err := ReadPendingUpdate(); err != nil {
96 t.Fatalf("probationary transaction was removed: %v", err)
97 }
98 if _, err := os.Stat(staging); err != nil {
99 t.Fatalf("probationary staging was removed: %v", err)
100 }
101 }
102
103 func TestReconcilePendingUpdateRollsBackPublishedAppHandoff(t *testing.T) {
104 tx, _ := prepareTestAppBundleHandoff(t)
105 if err := os.Rename(tx.TargetPath, tx.BackupPath); err != nil {
106 t.Fatal(err)
107 }
108 if err := os.Rename(tx.HandoffAppPath, tx.TargetPath); err != nil {
109 t.Fatal(err)
110 }
111
112 result, err := ReconcilePendingUpdate(tx.FromVersion)
113 if err != nil {
114 t.Fatal(err)
115 }
116 if !result.Pending || !result.RolledBack || result.Cleared || result.AwaitingHealth {
117 t.Fatalf("reconcile result = %+v", result)
118 }
119 if err := VerifyAppBundleUpdateHandoffOriginal(tx); err != nil {
120 t.Fatalf("previous app bundle was not restored: %v", err)
121 }
122 if _, err := os.Stat(PendingUpdatePath()); !os.IsNotExist(err) {
123 t.Fatalf("pending transaction survived rollback: %v", err)
124 }
125 }
126
127 func TestReconcilePendingUpdateRejectsTransactionRewrittenBeforeCancelLock(t *testing.T) {
128 tx, _ := prepareTestAppBundleHandoff(t)
129 attempted := make(chan struct{})
130 allow := make(chan struct{})
131 originalAcquire := acquirePendingUpdateLock
132 var once sync.Once
133 acquirePendingUpdateLock = func() (func(), error) {
134 blocked := false
135 once.Do(func() {
136 blocked = true
137 close(attempted)
138 })
139 if blocked {
140 <-allow
141 }
142 return originalAcquire()
143 }
144 t.Cleanup(func() { acquirePendingUpdateLock = originalAcquire })
145
146 type outcome struct {
147 result PendingUpdateReconcileResult
148 err error
149 }
150 done := make(chan outcome, 1)
151 go func() {
152 result, err := ReconcilePendingUpdate(tx.FromVersion)
153 done <- outcome{result: result, err: err}
154 }()
155 <-attempted
156 changed := *tx
157 changed.ToVersion = "v3"
158 if err := overwritePendingUpdateForTest(&changed); err != nil {
159 t.Fatal(err)
160 }
161 close(allow)
162
163 got := <-done
164 if got.err == nil || !strings.Contains(got.err.Error(), "changed") {
165 t.Fatalf("reconcile outcome = %+v, %v", got.result, got.err)
166 }
167 current, err := ReadPendingUpdate()
168 if err != nil || current.FromVersion != changed.FromVersion {
169 t.Fatalf("rewritten pending update = %+v, %v", current, err)
170 }
171 }
172
173 func TestClaimPendingAppBundleUpdateHandoffReturnsRecordedPaths(t *testing.T) {
174 tx, _ := prepareTestAppBundleHandoff(t)
175 var lockedPaths []string
176 originalBeforeLock := repairMutationBeforeLock
177 repairMutationBeforeLock = func(paths []string) {
178 lockedPaths = append([]string(nil), paths...)
179 }
180 t.Cleanup(func() { repairMutationBeforeLock = originalBeforeLock })
181
182 claimed, release, err := ClaimPendingAppBundleUpdateHandoff(tx.ToVersion, tx.CreatedAt, time.Second)
183 if err != nil {
184 t.Fatal(err)
185 }
186 if claimed.TargetPath != tx.TargetPath ||
187 claimed.BackupPath != tx.BackupPath ||
188 claimed.HandoffAppPath != tx.HandoffAppPath ||
189 claimed.HandoffStagingPath != tx.HandoffStagingPath ||
190 claimed.HandoffOwnerPID != tx.HandoffOwnerPID {
191 release()
192 t.Fatalf("claim returned different paths: %#v", claimed)
193 }
194 if len(lockedPaths) != 2 {
195 release()
196 t.Fatalf("claim locked %d paths, want target and backup: %v", len(lockedPaths), lockedPaths)
197 }
198 if err := ClearClaimedAppBundleUpdateHandoff(claimed); err != nil {
199 release()
200 t.Fatal(err)
201 }
202 release()
203 if _, err := os.Stat(PendingUpdatePath()); !os.IsNotExist(err) {
204 t.Fatalf("pending transaction still exists: %v", err)
205 }
206 }
207
208 func TestClaimPendingAppBundleUpdateHandoffExactRejectsRewrittenTransaction(t *testing.T) {
209 tx, _ := prepareTestAppBundleHandoff(t)
210 changed := *tx
211 changed.FromVersion = "rewritten"
212 if err := overwritePendingUpdateForTest(&changed); err != nil {
213 t.Fatal(err)
214 }
215
216 _, release, err := ClaimPendingAppBundleUpdateHandoffExact(
217 tx.ToVersion,
218 tx.CreatedAt,
219 UpdateTransactionID(tx),
220 time.Second,
221 )
222 if release != nil {
223 release()
224 }
225 if err == nil || !strings.Contains(err.Error(), "pending transaction changed") {
226 t.Fatalf("claim error = %v, want full transaction rejection", err)
227 }
228 current, readErr := ReadPendingUpdate()
229 if readErr != nil || current.FromVersion != changed.FromVersion {
230 t.Fatalf("rewritten transaction = %+v, %v", current, readErr)
231 }
232 }
233
234 type existingAppBundleBackupFixture struct {
235 app string
236 backup string
237 stagedApp string
238 staging string
239 }
240
241 func newExistingAppBundleBackupFixture(t *testing.T) existingAppBundleBackupFixture {
242 t.Helper()
243 t.Setenv("REASONIX_HOME", t.TempDir())
244 installRoot, err := filepath.EvalSymlinks(t.TempDir())
245 if err != nil {
246 t.Fatal(err)
247 }
248 app := filepath.Join(installRoot, "Reasonix.app")
249 exe := filepath.Join(app, "Contents", "MacOS", "Reasonix")
250 backup := app + ".reasonix-update-backup"
251 staging, err := os.MkdirTemp("", "reasonix-mac-update-*")
252 if err != nil {
253 t.Fatal(err)
254 }
255 t.Cleanup(func() { _ = os.RemoveAll(staging) })
256 stagedApp := filepath.Join(staging, "Reasonix.app")
257 for _, dir := range []string{filepath.Dir(exe), backup, stagedApp} {
258 if err := os.MkdirAll(dir, 0o700); err != nil {
259 t.Fatal(err)
260 }
261 }
262 if err := os.WriteFile(exe, []byte("current"), 0o700); err != nil {
263 t.Fatal(err)
264 }
265 if err := os.WriteFile(filepath.Join(backup, "marker"), []byte("preserve"), 0o600); err != nil {
266 t.Fatal(err)
267 }
268 originalExecutable := repairExecutable
269 repairExecutable = func() (string, error) { return exe, nil }
270 t.Cleanup(func() { repairExecutable = originalExecutable })
271 return existingAppBundleBackupFixture{
272 app: app,
273 backup: backup,
274 stagedApp: stagedApp,
275 staging: staging,
276 }
277 }
278
279 func TestPrepareAppBundleUpdateHandoffQuarantinesExistingBackup(t *testing.T) {
280 fixture := newExistingAppBundleBackupFixture(t)
281
282 tx, err := PrepareAppBundleUpdateHandoff(
283 "v1", "v2", fixture.app, fixture.backup, fixture.stagedApp, fixture.staging, os.Getpid(),
284 )
285 if err != nil {
286 t.Fatal(err)
287 }
288 if _, err := os.Lstat(fixture.backup); !os.IsNotExist(err) {
289 t.Fatalf("existing backup still blocks prepare: %v", err)
290 }
291 quarantines, err := filepath.Glob(fixture.backup + ".reasonix-orphaned-*")
292 if err != nil || len(quarantines) != 1 {
293 t.Fatalf("quarantined backups = %v, %v", quarantines, err)
294 }
295 if got, err := os.ReadFile(filepath.Join(quarantines[0], "marker")); err != nil || string(got) != "preserve" {
296 t.Fatalf("quarantined backup marker = %q, %v", got, err)
297 }
298 if tx.OrphanedBackupPath != quarantines[0] || strings.TrimSpace(tx.OrphanedBackupTreeID) == "" {
299 t.Fatalf("quarantine ownership = %q %q, want recorded path and digest", tx.OrphanedBackupPath, tx.OrphanedBackupTreeID)
300 }
301 current, err := ReadPendingUpdate()
302 if err != nil || UpdateTransactionID(current) != UpdateTransactionID(tx) {
303 t.Fatalf("pending transaction = %+v, %v", current, err)
304 }
305 }
306
307 func TestCancelAppBundleUpdateHandoffCleansOwnedQuarantine(t *testing.T) {
308 fixture := newExistingAppBundleBackupFixture(t)
309 tx, err := PrepareAppBundleUpdateHandoff(
310 "v1", "v2", fixture.app, fixture.backup, fixture.stagedApp, fixture.staging, os.Getpid(),
311 )
312 if err != nil {
313 t.Fatal(err)
314 }
315 if _, err := CancelPendingAppBundleUpdateHandoffExact(tx, time.Second); err != nil {
316 t.Fatal(err)
317 }
318 if _, err := os.Lstat(tx.OrphanedBackupPath); !os.IsNotExist(err) {
319 t.Fatalf("terminal transaction retained owned quarantine: %v", err)
320 }
321 if matches, _ := filepath.Glob(tx.OrphanedBackupPath + ".reasonix-cleanup-*"); len(matches) != 0 {
322 t.Fatalf("terminal cleanup retained temporary paths: %v", matches)
323 }
324 if _, err := os.Lstat(PendingUpdatePath()); !os.IsNotExist(err) {
325 t.Fatalf("cancelled transaction remains pending: %v", err)
326 }
327 }
328
329 func TestCancelAppBundleUpdateHandoffPreservesChangedQuarantine(t *testing.T) {
330 fixture := newExistingAppBundleBackupFixture(t)
331 tx, err := PrepareAppBundleUpdateHandoff(
332 "v1", "v2", fixture.app, fixture.backup, fixture.stagedApp, fixture.staging, os.Getpid(),
333 )
334 if err != nil {
335 t.Fatal(err)
336 }
337 changed := filepath.Join(tx.OrphanedBackupPath, "changed-after-prepare")
338 if err := os.WriteFile(changed, []byte("keep"), 0o600); err != nil {
339 t.Fatal(err)
340 }
341 if _, err := CancelPendingAppBundleUpdateHandoffExact(tx, time.Second); err != nil {
342 t.Fatal(err)
343 }
344 if got, err := os.ReadFile(changed); err != nil || string(got) != "keep" {
345 t.Fatalf("changed quarantine = %q, %v; want preserved", got, err)
346 }
347 if _, err := os.Lstat(PendingUpdatePath()); !os.IsNotExist(err) {
348 t.Fatalf("cancelled transaction remains pending: %v", err)
349 }
350 }
351
352 func TestCancelAppBundleUpdateHandoffPreservesConcurrentQuarantineRecreate(t *testing.T) {
353 fixture := newExistingAppBundleBackupFixture(t)
354 tx, err := PrepareAppBundleUpdateHandoff(
355 "v1", "v2", fixture.app, fixture.backup, fixture.stagedApp, fixture.staging, os.Getpid(),
356 )
357 if err != nil {
358 t.Fatal(err)
359 }
360 originalHook := updateCleanupAfterRename
361 updateCleanupAfterRename = func(original, _ string) {
362 if original != tx.OrphanedBackupPath {
363 return
364 }
365 if err := os.Mkdir(original, 0o700); err != nil {
366 t.Fatal(err)
367 }
368 if err := os.WriteFile(filepath.Join(original, "concurrent"), []byte("keep"), 0o600); err != nil {
369 t.Fatal(err)
370 }
371 }
372 t.Cleanup(func() { updateCleanupAfterRename = originalHook })
373
374 if _, err := CancelPendingAppBundleUpdateHandoffExact(tx, time.Second); err != nil {
375 t.Fatal(err)
376 }
377 if got, err := os.ReadFile(filepath.Join(tx.OrphanedBackupPath, "concurrent")); err != nil || string(got) != "keep" {
378 t.Fatalf("concurrently recreated quarantine = %q, %v; want preserved", got, err)
379 }
380 if _, err := os.Lstat(PendingUpdatePath()); !os.IsNotExist(err) {
381 t.Fatalf("cancelled transaction remains pending: %v", err)
382 }
383 }
384
385 func TestAppBundleUpdateRejectsForgedOrphanedBackupMetadata(t *testing.T) {
386 tx, _ := prepareTestAppBundleHandoff(t)
387 tx.OrphanedBackupPath = filepath.Join(filepath.Dir(tx.TargetPath), "unrelated.reasonix-orphaned-1-0")
388 tx.OrphanedBackupTreeID = strings.Repeat("0", 64)
389 if err := validateUpdateTransaction(tx); err == nil || !strings.Contains(err.Error(), "unexpected name") {
390 t.Fatalf("validation error = %v, want forged quarantine rejection", err)
391 }
392 }
393
394 func TestAppBundleUpdateWithoutOrphanKeepsLegacyTransactionShape(t *testing.T) {
395 prepareTestAppBundleHandoff(t)
396 body, err := os.ReadFile(PendingUpdatePath())
397 if err != nil {
398 t.Fatal(err)
399 }
400 if strings.Contains(string(body), "orphanedBackup") {
401 t.Fatalf("ordinary transaction unexpectedly gained orphan metadata: %s", body)
402 }
403 if _, err := ReadPendingUpdate(); err != nil {
404 t.Fatalf("ordinary transaction is no longer readable: %v", err)
405 }
406 }
407
408 func TestPrepareAppBundleUpdateHandoffPreservesConcurrentBackupRecreate(t *testing.T) {
409 fixture := newExistingAppBundleBackupFixture(t)
410 originalHook := updateBackupAfterQuarantine
411 updateBackupAfterQuarantine = func(original, _ string) {
412 if original != fixture.backup {
413 return
414 }
415 if err := os.Mkdir(original, 0o700); err != nil {
416 t.Fatal(err)
417 }
418 if err := os.WriteFile(filepath.Join(original, "concurrent"), []byte("keep"), 0o600); err != nil {
419 t.Fatal(err)
420 }
421 }
422 t.Cleanup(func() { updateBackupAfterQuarantine = originalHook })
423
424 _, err := PrepareAppBundleUpdateHandoff(
425 "v1", "v2", fixture.app, fixture.backup, fixture.stagedApp, fixture.staging, os.Getpid(),
426 )
427 if err == nil || !strings.Contains(err.Error(), "recreated") {
428 t.Fatalf("prepare error = %v, want recreated-backup rejection", err)
429 }
430 if got, err := os.ReadFile(filepath.Join(fixture.backup, "concurrent")); err != nil || string(got) != "keep" {
431 t.Fatalf("concurrent backup = %q, %v", got, err)
432 }
433 quarantines, globErr := filepath.Glob(fixture.backup + ".reasonix-orphaned-*")
434 if globErr != nil || len(quarantines) != 1 {
435 t.Fatalf("preserved quarantines = %v, %v", quarantines, globErr)
436 }
437 if _, err := os.Stat(PendingUpdatePath()); !os.IsNotExist(err) {
438 t.Fatalf("failed prepare wrote pending transaction: %v", err)
439 }
440 }
441
442 func TestPrepareAppBundleUpdateHandoffRestoresBackupChangedDuringQuarantine(t *testing.T) {
443 fixture := newExistingAppBundleBackupFixture(t)
444 originalHook := updateBackupAfterQuarantine
445 updateBackupAfterQuarantine = func(original, quarantine string) {
446 if original != fixture.backup {
447 return
448 }
449 if err := os.WriteFile(filepath.Join(quarantine, "changed"), []byte("keep"), 0o600); err != nil {
450 t.Fatal(err)
451 }
452 }
453 t.Cleanup(func() { updateBackupAfterQuarantine = originalHook })
454
455 _, err := PrepareAppBundleUpdateHandoff(
456 "v1", "v2", fixture.app, fixture.backup, fixture.stagedApp, fixture.staging, os.Getpid(),
457 )
458 if err == nil || !strings.Contains(err.Error(), "changed during quarantine") {
459 t.Fatalf("prepare error = %v, want changed-backup rejection", err)
460 }
461 if got, err := os.ReadFile(filepath.Join(fixture.backup, "changed")); err != nil || string(got) != "keep" {
462 t.Fatalf("changed backup was not restored = %q, %v", got, err)
463 }
464 if matches, _ := filepath.Glob(fixture.backup + ".reasonix-orphaned-*"); len(matches) != 0 {
465 t.Fatalf("restored backup left a quarantine: %v", matches)
466 }
467 if _, err := os.Stat(PendingUpdatePath()); !os.IsNotExist(err) {
468 t.Fatalf("failed prepare wrote pending transaction: %v", err)
469 }
470 }
471
472 func TestPrepareAppBundleUpdateHandoffRejectsUnboundExistingBackup(t *testing.T) {
473 fixture := newExistingAppBundleBackupFixture(t)
474 outside := filepath.Join(t.TempDir(), "Reasonix")
475 if err := os.WriteFile(outside, []byte("outside"), 0o700); err != nil {
476 t.Fatal(err)
477 }
478 repairExecutable = func() (string, error) { return outside, nil }
479
480 _, err := PrepareAppBundleUpdateHandoff(
481 "v1", "v2", fixture.app, fixture.backup, fixture.stagedApp, fixture.staging, os.Getpid(),
482 )
483 if err == nil || !strings.Contains(err.Error(), "outside the current Reasonix installation") {
484 t.Fatalf("prepare error = %v, want current-installation rejection", err)
485 }
486 if got, err := os.ReadFile(filepath.Join(fixture.backup, "marker")); err != nil || string(got) != "preserve" {
487 t.Fatalf("unbound backup changed = %q, %v", got, err)
488 }
489 if matches, _ := filepath.Glob(fixture.backup + ".reasonix-orphaned-*"); len(matches) != 0 {
490 t.Fatalf("unbound backup was quarantined: %v", matches)
491 }
492 }
493
494 func TestClaimPendingAppBundleUpdateHandoffRejectsBackupAppearingAfterPrepare(t *testing.T) {
495 tx, _ := prepareTestAppBundleHandoff(t)
496 if err := os.MkdirAll(tx.BackupPath, 0o700); err != nil {
497 t.Fatal(err)
498 }
499 _, release, err := ClaimPendingAppBundleUpdateHandoff(tx.ToVersion, tx.CreatedAt, time.Second)
500 if release != nil {
501 release()
502 }
503 if err == nil || !strings.Contains(err.Error(), "backup path already exists") {
504 t.Fatalf("claim error = %v, want appearing-backup rejection", err)
505 }
506 if _, err := ReadPendingUpdate(); err != nil {
507 t.Fatalf("rejected claim removed pending transaction: %v", err)
508 }
509 if _, err := CancelPendingAppBundleUpdateHandoff(tx.ToVersion, tx.CreatedAt, time.Second); err == nil ||
510 !strings.Contains(err.Error(), "backup path already exists") {
511 t.Fatalf("cancel error = %v, want appearing-backup rejection", err)
512 }
513 if _, err := os.Stat(tx.BackupPath); err != nil {
514 t.Fatalf("rejected cancel removed appearing backup: %v", err)
515 }
516 if _, err := ReadPendingUpdate(); err != nil {
517 t.Fatalf("rejected cancel removed pending transaction: %v", err)
518 }
519 }
520
521 func TestClearClaimedAppBundleUpdateHandoffRejectsOriginalDrift(t *testing.T) {
522 tx, _ := prepareTestAppBundleHandoff(t)
523 claimed, release, err := ClaimPendingAppBundleUpdateHandoff(tx.ToVersion, tx.CreatedAt, time.Second)
524 if err != nil {
525 t.Fatal(err)
526 }
527 defer release()
528 if err := os.WriteFile(filepath.Join(tx.TargetPath, "changed-after-claim"), []byte("tampered"), 0o600); err != nil {
529 t.Fatal(err)
530 }
531 if err := ClearClaimedAppBundleUpdateHandoff(claimed); err == nil ||
532 !strings.Contains(err.Error(), "installed bundle changed after prepare") {
533 t.Fatalf("clear error = %v, want original drift rejection", err)
534 }
535 if _, err := ReadPendingUpdate(); err != nil {
536 t.Fatalf("unsafe clear removed pending handoff: %v", err)
537 }
538 }
539
540 func TestClaimPendingAppBundleUpdateHandoffRejectsUnboundTarget(t *testing.T) {
541 tx, staging := prepareTestAppBundleHandoff(t)
542 arbitrary := filepath.Join(t.TempDir(), "Unrelated.app")
543 if err := os.MkdirAll(arbitrary, 0o700); err != nil {
544 t.Fatal(err)
545 }
546 marker := filepath.Join(arbitrary, "marker")
547 if err := os.WriteFile(marker, []byte("keep"), 0o600); err != nil {
548 t.Fatal(err)
549 }
550 tx.TargetPath = arbitrary
551 tx.BackupPath = arbitrary + ".reasonix-update-backup"
552 tx.HandoffAppPath = filepath.Join(staging, "Other.app")
553 if err := overwritePendingUpdateForTest(tx); err != nil {
554 t.Fatal(err)
555 }
556
557 if _, release, err := ClaimPendingAppBundleUpdateHandoff(tx.ToVersion, tx.CreatedAt, time.Second); err == nil {
558 release()
559 t.Fatal("unbound app target was claimed")
560 }
561 if got, err := os.ReadFile(marker); err != nil || string(got) != "keep" {
562 t.Fatalf("unbound target changed: %q, %v", got, err)
563 }
564 }
565
566 func TestClaimPendingAppBundleUpdateHandoffRejectsLegacyTransaction(t *testing.T) {
567 tx, _ := prepareTestAppBundleHandoff(t)
568 tx.HandoffAppPath = ""
569 tx.HandoffStagingPath = ""
570 tx.HandoffAppTreeID = ""
571 tx.HandoffStagingTreeID = ""
572 tx.HandoffOwnerPID = 0
573 if err := overwritePendingUpdateForTest(tx); err != nil {
574 t.Fatal(err)
575 }
576
577 _, release, err := ClaimPendingAppBundleUpdateHandoff(tx.ToVersion, tx.CreatedAt, time.Second)
578 if release != nil {
579 release()
580 }
581 if err == nil || !strings.Contains(err.Error(), "handoff metadata is missing") {
582 t.Fatalf("claim error = %v, want legacy transaction rejection", err)
583 }
584 if _, err := ReadPendingUpdate(); err != nil {
585 t.Fatalf("legacy transaction should remain readable: %v", err)
586 }
587 }
588
589 func TestClaimPendingAppBundleUpdateHandoffRejectsMissingStagingDigest(t *testing.T) {
590 tx, _ := prepareTestAppBundleHandoff(t)
591 tx.HandoffStagingTreeID = ""
592 if err := overwritePendingUpdateForTest(tx); err != nil {
593 t.Fatal(err)
594 }
595
596 _, release, err := ClaimPendingAppBundleUpdateHandoff(
597 tx.ToVersion,
598 tx.CreatedAt,
599 time.Second,
600 )
601 if release != nil {
602 release()
603 }
604 if err == nil || !strings.Contains(err.Error(), "handoff staging digest is missing") {
605 t.Fatalf("claim error = %v, want missing staging digest rejection", err)
606 }
607 if _, err := ReadPendingUpdate(); err != nil {
608 t.Fatalf("rejected transaction should remain readable: %v", err)
609 }
610 }
611
612 func TestClaimPendingAppBundleUpdateHandoffRejectsReplacementWhileLocking(t *testing.T) {
613 tx, staging := prepareTestAppBundleHandoff(t)
614 replacement := *tx
615 replacement.HandoffAppPath = filepath.Join(staging, "Replacement.app")
616
617 originalBeforeLock := repairMutationBeforeLock
618 changed := false
619 repairMutationBeforeLock = func([]string) {
620 if changed {
621 return
622 }
623 changed = true
624 if err := overwritePendingUpdateForTest(&replacement); err != nil {
625 t.Errorf("replace pending transaction: %v", err)
626 }
627 }
628 t.Cleanup(func() { repairMutationBeforeLock = originalBeforeLock })
629
630 _, release, err := ClaimPendingAppBundleUpdateHandoff(tx.ToVersion, tx.CreatedAt, time.Second)
631 if release != nil {
632 release()
633 }
634 if err == nil || !strings.Contains(err.Error(), "changed while waiting") {
635 t.Fatalf("claim error = %v, want replacement rejection", err)
636 }
637 }
638
639 func TestClaimPendingAppBundleUpdateHandoffRejectsStagedTreeDrift(t *testing.T) {
640 tx, staging := prepareTestAppBundleHandoff(t)
641 if err := os.WriteFile(filepath.Join(tx.HandoffAppPath, "changed-after-prepare"), []byte("tampered"), 0o600); err != nil {
642 t.Fatal(err)
643 }
644 _, release, err := ClaimPendingAppBundleUpdateHandoff(tx.ToVersion, tx.CreatedAt, time.Second)
645 if release != nil {
646 release()
647 }
648 if err == nil || !strings.Contains(err.Error(), "staged bundle changed") {
649 t.Fatalf("claim error = %v, want staged tree drift rejection", err)
650 }
651 if _, err := os.Stat(staging); err != nil {
652 t.Fatalf("staging was removed after rejected claim: %v", err)
653 }
654 }
655
656 func TestClaimPendingAppBundleUpdateHandoffRejectsStagingRootDrift(t *testing.T) {
657 tx, staging := prepareTestAppBundleHandoff(t)
658 if err := os.WriteFile(filepath.Join(staging, "unexpected"), []byte("tampered"), 0o600); err != nil {
659 t.Fatal(err)
660 }
661 _, release, err := ClaimPendingAppBundleUpdateHandoffExact(
662 tx.ToVersion,
663 tx.CreatedAt,
664 UpdateTransactionID(tx),
665 time.Second,
666 )
667 if release != nil {
668 release()
669 }
670 if err == nil || !strings.Contains(err.Error(), "staging directory changed") {
671 t.Fatalf("claim error = %v, want staging-root drift rejection", err)
672 }
673 if _, err := os.Stat(staging); err != nil {
674 t.Fatalf("staging was removed after rejected claim: %v", err)
675 }
676 }
677
678 func TestCleanupAppBundleUpdateHandoffStagingPreservesConcurrentRecreate(t *testing.T) {
679 tx, staging := prepareTestAppBundleHandoff(t)
680 originalHook := updateCleanupAfterRename
681 updateCleanupAfterRename = func(oldpath, _ string) {
682 if oldpath == staging {
683 if err := os.MkdirAll(staging, 0o700); err != nil {
684 t.Errorf("recreate staging: %v", err)
685 return
686 }
687 if err := os.WriteFile(filepath.Join(staging, "concurrent"), []byte("keep"), 0o600); err != nil {
688 t.Errorf("write concurrent staging: %v", err)
689 }
690 }
691 }
692 t.Cleanup(func() { updateCleanupAfterRename = originalHook })
693
694 if err := CleanupAppBundleUpdateHandoffStaging(tx); err != nil {
695 t.Fatal(err)
696 }
697 if got, err := os.ReadFile(filepath.Join(staging, "concurrent")); err != nil || string(got) != "keep" {
698 t.Fatalf("concurrent staging = %q, %v", got, err)
699 }
700 }
701
702 func TestCleanupAppBundleUpdateHandoffStagingRejectsDrift(t *testing.T) {
703 tx, staging := prepareTestAppBundleHandoff(t)
704 if err := os.WriteFile(filepath.Join(staging, "unexpected"), []byte("keep"), 0o600); err != nil {
705 t.Fatal(err)
706 }
707
708 if err := CleanupAppBundleUpdateHandoffStaging(tx); err == nil ||
709 !strings.Contains(err.Error(), "staging directory changed") {
710 t.Fatalf("cleanup error = %v, want staging drift rejection", err)
711 }
712 if got, err := os.ReadFile(filepath.Join(staging, "unexpected")); err != nil || string(got) != "keep" {
713 t.Fatalf("drifted staging = %q, %v", got, err)
714 }
715 }
716
717 func TestClaimPendingAppBundleUpdateHandoffRejectsOriginalTreeDrift(t *testing.T) {
718 tx, _ := prepareTestAppBundleHandoff(t)
719 if err := os.WriteFile(filepath.Join(tx.TargetPath, "changed-after-prepare"), []byte("tampered"), 0o600); err != nil {
720 t.Fatal(err)
721 }
722 _, release, err := ClaimPendingAppBundleUpdateHandoff(tx.ToVersion, tx.CreatedAt, time.Second)
723 if release != nil {
724 release()
725 }
726 if err == nil || !strings.Contains(err.Error(), "installed bundle changed after prepare") {
727 t.Fatalf("claim error = %v, want original tree drift rejection", err)
728 }
729 }
730
731 func TestCancelPendingAppBundleUpdateHandoffAfterSourceDrift(t *testing.T) {
732 tx, staging := prepareTestAppBundleHandoff(t)
733 if err := os.WriteFile(filepath.Join(tx.HandoffAppPath, "changed-after-prepare"), []byte("tampered"), 0o600); err != nil {
734 t.Fatal(err)
735 }
736 if _, release, err := ClaimPendingAppBundleUpdateHandoff(tx.ToVersion, tx.CreatedAt, time.Second); err == nil {
737 if release != nil {
738 release()
739 }
740 t.Fatal("claim accepted staged source drift")
741 }
742 cancelled, err := CancelPendingAppBundleUpdateHandoff(tx.ToVersion, tx.CreatedAt, time.Second)
743 if err != nil {
744 t.Fatal(err)
745 }
746 if cancelled.CreatedAt != tx.CreatedAt {
747 t.Fatalf("cancelled transaction = %+v, want createdAt %q", cancelled, tx.CreatedAt)
748 }
749 if _, err := ReadPendingUpdate(); !os.IsNotExist(err) {
750 t.Fatalf("pending handoff survived safe cancellation: %v", err)
751 }
752 if _, err := os.Stat(staging); err != nil {
753 t.Fatalf("repair cancellation removed caller-owned staging: %v", err)
754 }
755 }
756
757 func TestCancelPendingAppBundleUpdateHandoffPreservesDriftedOriginal(t *testing.T) {
758 tx, _ := prepareTestAppBundleHandoff(t)
759 if err := os.WriteFile(filepath.Join(tx.TargetPath, "changed-after-prepare"), []byte("tampered"), 0o600); err != nil {
760 t.Fatal(err)
761 }
762 if _, err := CancelPendingAppBundleUpdateHandoff(tx.ToVersion, tx.CreatedAt, time.Second); err == nil ||
763 !strings.Contains(err.Error(), "installed bundle changed after prepare") {
764 t.Fatalf("cancel error = %v, want original drift rejection", err)
765 }
766 if _, err := ReadPendingUpdate(); err != nil {
767 t.Fatalf("pending handoff was lost after unsafe cancellation: %v", err)
768 }
769 }
770
771 func TestCancelPendingAppBundleUpdateHandoffExactRejectsRewrittenTransaction(t *testing.T) {
772 tx, _ := prepareTestAppBundleHandoff(t)
773 changed := *tx
774 changed.HandoffOwnerPID++
775 if err := overwritePendingUpdateForTest(&changed); err != nil {
776 t.Fatal(err)
777 }
778
779 if _, err := CancelPendingAppBundleUpdateHandoffExact(tx, time.Second); err == nil ||
780 !strings.Contains(err.Error(), "transaction changed") {
781 t.Fatalf("exact cancel error = %v, want full transaction rejection", err)
782 }
783 current, err := ReadPendingUpdate()
784 if err != nil || current.HandoffOwnerPID != changed.HandoffOwnerPID {
785 t.Fatalf("rewritten handoff transaction = %+v, %v", current, err)
786 }
787 }
788
789 func TestClaimPendingAppBundleUpdateHandoffRejectsStagingSymlinkEscape(t *testing.T) {
790 if runtime.GOOS == "windows" {
791 t.Skip("creating symlinks requires elevated privileges on Windows CI")
792 }
793 tx, _ := prepareTestAppBundleHandoff(t)
794 outside := filepath.Join(t.TempDir(), "Outside.app")
795 if err := os.MkdirAll(outside, 0o700); err != nil {
796 t.Fatal(err)
797 }
798 if err := os.RemoveAll(tx.HandoffAppPath); err != nil {
799 t.Fatal(err)
800 }
801 if err := os.Symlink(outside, tx.HandoffAppPath); err != nil {
802 t.Fatal(err)
803 }
804 _, release, err := ClaimPendingAppBundleUpdateHandoff(tx.ToVersion, tx.CreatedAt, time.Second)
805 if release != nil {
806 release()
807 }
808 if err == nil || !strings.Contains(err.Error(), "resolves outside its staging directory") {
809 t.Fatalf("claim error = %v, want staging containment rejection", err)
810 }
811 }
812
812 lines GO