返回 DeepSeek-Reasonix
plan_test.go
根目录 / internal / repair / plan_test.go
1 package repair
2
3 import (
4 "encoding/json"
5 "os"
6 "path/filepath"
7 "strings"
8 "sync"
9 "sync/atomic"
10 "testing"
11 "time"
12
13 "reasonix/internal/fileutil"
14 )
15
16 func repairMutationTestKey(path string) string {
17 return canonicalRepairPath(path)
18 }
19
20 func TestCanonicalRepairPathUsesFilesystemCaseSemantics(t *testing.T) {
21 root := t.TempDir()
22 upper := filepath.Join(root, "Project")
23 lower := filepath.Join(root, "project")
24
25 original := repairPathCaseInsensitive
26 t.Cleanup(func() { repairPathCaseInsensitive = original })
27
28 repairPathCaseInsensitive = func(string) bool { return false }
29 if canonicalRepairPath(upper) == canonicalRepairPath(lower) {
30 t.Fatal("case-sensitive filesystem identities were conflated")
31 }
32
33 repairPathCaseInsensitive = func(string) bool { return true }
34 if canonicalRepairPath(upper) != canonicalRepairPath(lower) {
35 t.Fatal("case-insensitive filesystem aliases did not converge")
36 }
37 }
38
39 func TestDecodeRepairPlanRejectsUnknownFieldsAndActions(t *testing.T) {
40 tests := []string{
41 `{"schemaVersion":1,"summary":"x","actions":[{"type":"run_shell","reason":"x"}]}`,
42 `{"schemaVersion":1,"summary":"x","actions":[{"type":"rollback_update","reason":"x","command":"rm"}]}`,
43 `{"schemaVersion":1,"summary":"x","actions":[{"type":"rebuild_derived_state","target":"sessions","reason":"x"}]}`,
44 `{"schemaVersion":1,"summary":"\u001b[2J","actions":[]}`,
45 }
46 for _, raw := range tests {
47 if _, err := DecodeRepairPlan([]byte(raw)); err == nil {
48 t.Fatalf("unsafe plan accepted: %s", raw)
49 }
50 }
51 }
52
53 func TestDecodeRepairPlanAcceptsFencedWhitelistPlan(t *testing.T) {
54 raw := "```json\n" + `{"schemaVersion":1,"summary":"repair tabs","actions":[{"type":"rebuild_derived_state","target":"tabs","reason":"malformed"}]}` + "\n```"
55 plan, err := DecodeRepairPlan([]byte(raw))
56 if err != nil {
57 t.Fatal(err)
58 }
59 if len(plan.Actions) != 1 || plan.Actions[0].Target != "tabs" {
60 t.Fatalf("plan = %+v", plan)
61 }
62 }
63
64 func TestDecodeRepairPlanAllowsNoOpPlan(t *testing.T) {
65 plan, err := DecodeRepairPlan([]byte(`{"schemaVersion":1,"summary":"no safe repair","actions":[]}`))
66 if err != nil {
67 t.Fatal(err)
68 }
69 if len(plan.Actions) != 0 {
70 t.Fatalf("actions = %+v", plan.Actions)
71 }
72 }
73
74 func TestRepairPlanIDsBindPlanAndPreviewContent(t *testing.T) {
75 plan := RepairPlan{SchemaVersion: 1, Summary: "repair tabs", Actions: []RepairPlanAction{{Type: "rebuild_derived_state", Target: "tabs", Reason: "malformed"}}}
76 preview := []RepairPlanPreview{{Index: 1, Type: "rebuild_derived_state", Description: "Quarantine and rebuild derived desktop state: tabs"}}
77 if got := RepairPlanID(plan); got != RepairPlanID(plan) || got == "" {
78 t.Fatalf("plan ID is not stable: %q", got)
79 }
80 previewID := RepairPlanPreviewID(plan, preview)
81 changedPlan := plan
82 changedPlan.Actions = []RepairPlanAction{{Type: "rebuild_derived_state", Target: "window", Reason: "malformed"}}
83 if previewID == RepairPlanPreviewID(changedPlan, preview) {
84 t.Fatal("changing the action did not change the preview ID")
85 }
86 changedPreview := append([]RepairPlanPreview(nil), preview...)
87 changedPreview[0].Description = "changed preview"
88 if previewID == RepairPlanPreviewID(plan, changedPreview) {
89 t.Fatal("changing the preview did not change the preview ID")
90 }
91 }
92
93 func TestApplyRepairPlanRejectsUnboundPreview(t *testing.T) {
94 home := t.TempDir()
95 t.Setenv("REASONIX_HOME", home)
96 tabs := filepath.Join(home, "desktop-tabs.json")
97 if err := os.WriteFile(tabs, []byte("first-state"), 0o600); err != nil {
98 t.Fatal(err)
99 }
100 plan := RepairPlan{SchemaVersion: 1, Summary: "tabs", Actions: []RepairPlanAction{{Type: "rebuild_derived_state", Target: "tabs", Reason: "malformed"}}}
101 preview, err := PreviewRepairPlan(plan, ApplyPlanOptions{})
102 if err != nil {
103 t.Fatal(err)
104 }
105 expected := RepairPlanPreviewID(plan, preview)
106 if err := os.WriteFile(tabs, []byte("changed-after-preview"), 0o600); err != nil {
107 t.Fatal(err)
108 }
109 _, err = ApplyRepairPlan(plan, ApplyPlanOptions{ExpectedPreviewID: expected})
110 if err == nil || !strings.Contains(err.Error(), "preview changed since confirmation") {
111 t.Fatalf("error = %v, want stale preview refusal", err)
112 }
113 if got, readErr := os.ReadFile(tabs); readErr != nil || string(got) != "changed-after-preview" {
114 t.Fatalf("stale preview touched derived state: %q, %v", got, readErr)
115 }
116 }
117
118 func TestApplyRepairPlanRechecksPreviewBeforeEachAction(t *testing.T) {
119 home := t.TempDir()
120 t.Setenv("REASONIX_HOME", home)
121 tabs := filepath.Join(home, "desktop-tabs.json")
122 if err := os.WriteFile(tabs, []byte("bad-tabs"), 0o600); err != nil {
123 t.Fatal(err)
124 }
125 plan := RepairPlan{SchemaVersion: 1, Summary: "rebuild tabs twice", Actions: []RepairPlanAction{
126 {Type: "rebuild_derived_state", Target: "tabs", Reason: "malformed"},
127 {Type: "rebuild_derived_state", Target: "tabs", Reason: "malformed"},
128 }}
129 preview, err := PreviewRepairPlan(plan, ApplyPlanOptions{})
130 if err != nil {
131 t.Fatal(err)
132 }
133 result, err := ApplyRepairPlan(plan, ApplyPlanOptions{ExpectedPreviewID: RepairPlanPreviewID(plan, preview)})
134 if err == nil || !strings.Contains(err.Error(), "action 2: repair plan preview changed") {
135 t.Fatalf("error = %v, want second-action stale preview refusal", err)
136 }
137 if len(result.Applied) != 1 {
138 t.Fatalf("applied = %v, want only the confirmed first action", result.Applied)
139 }
140 if _, statErr := os.Stat(tabs); !os.IsNotExist(statErr) {
141 t.Fatalf("second action unexpectedly restored or rewrote tabs: %v", statErr)
142 }
143 }
144
145 func TestApplyRepairPlanBindsPendingUpdateTransactionIdentity(t *testing.T) {
146 home := t.TempDir()
147 t.Setenv("REASONIX_HOME", home)
148 dir, err := filepath.EvalSymlinks(t.TempDir())
149 if err != nil {
150 t.Fatal(err)
151 }
152 target := filepath.Join(dir, "reasonix-desktop")
153 originalExecutable := repairExecutable
154 repairExecutable = func() (string, error) { return filepath.Join(dir, "reasonix-guard"), nil }
155 t.Cleanup(func() { repairExecutable = originalExecutable })
156 if err := os.WriteFile(target, []byte("old"), 0o700); err != nil {
157 t.Fatal(err)
158 }
159 tx, err := PrepareFileUpdate("v1", "v2", target)
160 if err != nil {
161 t.Fatal(err)
162 }
163 if err := os.WriteFile(target, []byte("new"), 0o700); err != nil {
164 t.Fatal(err)
165 }
166 plan := RepairPlan{SchemaVersion: 1, Summary: "rollback", Actions: []RepairPlanAction{{Type: "rollback_update", Reason: "failed update"}}}
167 preview, err := PreviewRepairPlan(plan, ApplyPlanOptions{})
168 if err != nil {
169 t.Fatal(err)
170 }
171 expected := RepairPlanPreviewID(plan, preview)
172 tx.CreatedAt = time.Now().Add(time.Second).UTC().Format(time.RFC3339Nano)
173 if err := overwritePendingUpdateForTest(tx); err != nil {
174 t.Fatal(err)
175 }
176 if _, err := ApplyRepairPlan(plan, ApplyPlanOptions{ExpectedPreviewID: expected}); err == nil || !strings.Contains(err.Error(), "preview changed since confirmation") {
177 t.Fatalf("error = %v, want changed transaction refusal", err)
178 }
179 if got, err := os.ReadFile(target); err != nil || string(got) != "new" {
180 t.Fatalf("stale rollback touched target: %q, %v", got, err)
181 }
182 }
183
184 func TestApplyRepairPlanRollsBackCurrentConfirmedUpdateOnce(t *testing.T) {
185 home := t.TempDir()
186 t.Setenv("REASONIX_HOME", home)
187 dir, err := filepath.EvalSymlinks(t.TempDir())
188 if err != nil {
189 t.Fatal(err)
190 }
191 target := filepath.Join(dir, "reasonix-desktop")
192 originalExecutable := repairExecutable
193 repairExecutable = func() (string, error) { return filepath.Join(dir, "reasonix-guard"), nil }
194 t.Cleanup(func() { repairExecutable = originalExecutable })
195 if err := os.WriteFile(target, []byte("old"), 0o700); err != nil {
196 t.Fatal(err)
197 }
198 if _, err := PrepareFileUpdate("v1", "v2", target); err != nil {
199 t.Fatal(err)
200 }
201 if err := os.WriteFile(target, []byte("new"), 0o700); err != nil {
202 t.Fatal(err)
203 }
204 plan := RepairPlan{SchemaVersion: 1, Summary: "rollback", Actions: []RepairPlanAction{{Type: "rollback_update", Reason: "failed update"}}}
205 preview, err := PreviewRepairPlan(plan, ApplyPlanOptions{})
206 if err != nil {
207 t.Fatal(err)
208 }
209 result, err := ApplyRepairPlan(plan, ApplyPlanOptions{ExpectedPreviewID: RepairPlanPreviewID(plan, preview)})
210 if err != nil {
211 t.Fatal(err)
212 }
213 if len(result.Applied) != 1 || result.Applied[0] != "rolled back update to v1" {
214 t.Fatalf("applied = %v, want one successful rollback", result.Applied)
215 }
216 if got, err := os.ReadFile(target); err != nil || string(got) != "old" {
217 t.Fatalf("target after rollback = %q, %v", got, err)
218 }
219 if _, err := os.Stat(PendingUpdatePath()); !os.IsNotExist(err) {
220 t.Fatalf("pending update survived successful rollback: %v", err)
221 }
222 }
223
224 func TestApplyRepairPlanRejectsBackupChangedDuringRollbackStaging(t *testing.T) {
225 home := t.TempDir()
226 t.Setenv("REASONIX_HOME", home)
227 dir, err := filepath.EvalSymlinks(t.TempDir())
228 if err != nil {
229 t.Fatal(err)
230 }
231 target := filepath.Join(dir, "reasonix-desktop")
232 originalExecutable := repairExecutable
233 repairExecutable = func() (string, error) { return filepath.Join(dir, "reasonix-guard"), nil }
234 t.Cleanup(func() { repairExecutable = originalExecutable })
235 if err := os.WriteFile(target, []byte("old"), 0o700); err != nil {
236 t.Fatal(err)
237 }
238 tx, err := PrepareFileUpdate("v1", "v2", target)
239 if err != nil {
240 t.Fatal(err)
241 }
242 if err := os.WriteFile(target, []byte("new"), 0o700); err != nil {
243 t.Fatal(err)
244 }
245 plan := RepairPlan{SchemaVersion: 1, Summary: "rollback", Actions: []RepairPlanAction{{Type: "rollback_update", Reason: "failed update"}}}
246 preview, err := PreviewRepairPlan(plan, ApplyPlanOptions{})
247 if err != nil {
248 t.Fatal(err)
249 }
250 expected := RepairPlanPreviewID(plan, preview)
251
252 originalCopy := rollbackStageCopy
253 rollbackStageCopy = func(src, dst string, mode os.FileMode) (string, error) {
254 if src == tx.BackupPath {
255 if err := os.WriteFile(src, []byte("tampered"), 0o700); err != nil {
256 return "", err
257 }
258 }
259 return originalCopy(src, dst, mode)
260 }
261 t.Cleanup(func() { rollbackStageCopy = originalCopy })
262
263 if _, err := ApplyRepairPlan(plan, ApplyPlanOptions{ExpectedPreviewID: expected}); err == nil || !strings.Contains(err.Error(), "backup hash mismatch") {
264 t.Fatalf("error = %v, want staged backup hash refusal", err)
265 }
266 if got, err := os.ReadFile(target); err != nil || string(got) != "new" {
267 t.Fatalf("rollback installed unconfirmed bytes: %q, %v", got, err)
268 }
269 if _, err := ReadPendingUpdate(); err != nil {
270 t.Fatalf("failed rollback consumed pending transaction: %v", err)
271 }
272 }
273
274 func TestRollbackPendingUpdateStateBindsCompleteTransaction(t *testing.T) {
275 home := t.TempDir()
276 t.Setenv("REASONIX_HOME", home)
277 dir, err := filepath.EvalSymlinks(t.TempDir())
278 if err != nil {
279 t.Fatal(err)
280 }
281 target := filepath.Join(dir, "reasonix-desktop")
282 originalExecutable := repairExecutable
283 repairExecutable = func() (string, error) { return filepath.Join(dir, "reasonix-guard"), nil }
284 t.Cleanup(func() { repairExecutable = originalExecutable })
285 if err := os.WriteFile(target, []byte("old"), 0o700); err != nil {
286 t.Fatal(err)
287 }
288 tx, err := PrepareFileUpdate("v1", "v2", target)
289 if err != nil {
290 t.Fatal(err)
291 }
292 if err := os.WriteFile(target, []byte("new"), 0o700); err != nil {
293 t.Fatal(err)
294 }
295 expectedState, expectedFiles := pendingUpdateBoundPreview(tx)
296 tx.FromVersion = "different-old-version"
297 if err := overwritePendingUpdateForTest(tx); err != nil {
298 t.Fatal(err)
299 }
300 result, err := rollbackPendingUpdateState(expectedState, expectedFiles)
301 if err != nil {
302 t.Fatal(err)
303 }
304 if result.RolledBack {
305 t.Fatalf("changed transaction was rolled back: %+v", result)
306 }
307 if got, err := os.ReadFile(target); err != nil || string(got) != "new" {
308 t.Fatalf("changed transaction touched target: %q, %v", got, err)
309 }
310 if _, err := ReadPendingUpdate(); err != nil {
311 t.Fatalf("changed transaction was consumed: %v", err)
312 }
313 }
314
315 func TestApplyRepairPlanRejectsUncooperativeWriteBeforeRename(t *testing.T) {
316 home := t.TempDir()
317 t.Setenv("REASONIX_HOME", home)
318 tabs := filepath.Join(home, "desktop-tabs.json")
319 if err := os.WriteFile(tabs, []byte("confirmed"), 0o600); err != nil {
320 t.Fatal(err)
321 }
322 plan := RepairPlan{SchemaVersion: 1, Summary: "tabs", Actions: []RepairPlanAction{{Type: "rebuild_derived_state", Target: "tabs", Reason: "malformed"}}}
323 preview, err := PreviewRepairPlan(plan, ApplyPlanOptions{})
324 if err != nil {
325 t.Fatal(err)
326 }
327 originalHook := repairMutationBeforeRename
328 repairMutationBeforeRename = func(path string) {
329 if path == tabs {
330 if err := os.WriteFile(path, []byte("changed-in-window"), 0o600); err != nil {
331 t.Fatal(err)
332 }
333 }
334 }
335 t.Cleanup(func() { repairMutationBeforeRename = originalHook })
336 result, err := ApplyRepairPlan(plan, ApplyPlanOptions{ExpectedPreviewID: RepairPlanPreviewID(plan, preview)})
337 if err == nil || !strings.Contains(err.Error(), "preview changed since confirmation") {
338 t.Fatalf("error = %v, want final state refusal", err)
339 }
340 if len(result.Applied) != 0 {
341 t.Fatalf("applied = %v, want no writes", result.Applied)
342 }
343 if got, err := os.ReadFile(tabs); err != nil || string(got) != "changed-in-window" {
344 t.Fatalf("unconfirmed write was quarantined: %q, %v", got, err)
345 }
346 }
347
348 func TestApplyRepairPlanPreservesUncooperativeWriteAfterRename(t *testing.T) {
349 home := t.TempDir()
350 t.Setenv("REASONIX_HOME", home)
351 tabs := filepath.Join(home, "desktop-tabs.json")
352 if err := os.WriteFile(tabs, []byte("confirmed"), 0o600); err != nil {
353 t.Fatal(err)
354 }
355 plan := RepairPlan{SchemaVersion: 1, Summary: "tabs", Actions: []RepairPlanAction{{Type: "rebuild_derived_state", Target: "tabs", Reason: "malformed"}}}
356 preview, err := PreviewRepairPlan(plan, ApplyPlanOptions{})
357 if err != nil {
358 t.Fatal(err)
359 }
360 originalHook := repairMutationAfterRename
361 repairMutationAfterRename = func(path string) {
362 if path == tabs {
363 if err := os.WriteFile(path, []byte("new-after-rename"), 0o600); err != nil {
364 t.Fatal(err)
365 }
366 }
367 }
368 t.Cleanup(func() { repairMutationAfterRename = originalHook })
369 result, err := ApplyRepairPlan(plan, ApplyPlanOptions{ExpectedPreviewID: RepairPlanPreviewID(plan, preview)})
370 if err == nil {
371 t.Fatal("uncooperative post-rename write was accepted")
372 }
373 if len(result.Applied) != 0 {
374 t.Fatalf("applied = %v, want no successful action", result.Applied)
375 }
376 if got, err := os.ReadFile(tabs); err != nil || string(got) != "new-after-rename" {
377 t.Fatalf("post-rename writer was overwritten: %q, %v", got, err)
378 }
379 quarantines, err := filepath.Glob(tabs + ".reasonix-rebuild-*")
380 if err != nil || len(quarantines) != 1 {
381 t.Fatalf("confirmed state backup = %v, %v", quarantines, err)
382 }
383 if got, err := os.ReadFile(quarantines[0]); err != nil || string(got) != "confirmed" {
384 t.Fatalf("confirmed state backup = %q, %v", got, err)
385 }
386 // The moved node must still be undoable: concurrent rewrite is retained as redo.
387 repairMutationAfterRename = originalHook
388 if _, err := UndoLastRepair(); err != nil {
389 t.Fatalf("undo after concurrent recreate: %v", err)
390 }
391 if got, err := os.ReadFile(tabs); err != nil || string(got) != "confirmed" {
392 t.Fatalf("undo restored = %q, %v, want confirmed quarantine", got, err)
393 }
394 redos, err := filepath.Glob(tabs + ".reasonix-redo-*")
395 if err != nil || len(redos) != 1 {
396 t.Fatalf("redo copies = %v, %v", redos, err)
397 }
398 if got, err := os.ReadFile(redos[0]); err != nil || string(got) != "new-after-rename" {
399 t.Fatalf("redo retained concurrent write = %q, %v", got, err)
400 }
401 }
402
403 func TestRepairMutationLockRechecksAfterWaiting(t *testing.T) {
404 home := t.TempDir()
405 t.Setenv("REASONIX_HOME", home)
406 tabs := filepath.Join(home, "desktop-tabs.json")
407 if err := os.WriteFile(tabs, []byte("confirmed"), 0o600); err != nil {
408 t.Fatal(err)
409 }
410 plan := RepairPlan{SchemaVersion: 1, Summary: "tabs", Actions: []RepairPlanAction{{Type: "rebuild_derived_state", Target: "tabs", Reason: "malformed"}}}
411 preview, err := PreviewRepairPlan(plan, ApplyPlanOptions{})
412 if err != nil {
413 t.Fatal(err)
414 }
415
416 holder, err := lockRepairMutations(tabs)
417 if err != nil {
418 t.Fatal(err)
419 }
420 reachedLock := make(chan struct{})
421 originalHook := repairMutationBeforeLock
422 repairMutationBeforeLock = func(paths []string) {
423 if len(paths) == 1 && paths[0] == repairMutationTestKey(tabs) {
424 select {
425 case <-reachedLock:
426 default:
427 close(reachedLock)
428 }
429 }
430 }
431 t.Cleanup(func() { repairMutationBeforeLock = originalHook })
432
433 resultCh := make(chan struct {
434 result ApplyPlanResult
435 err error
436 }, 1)
437 applyPreviewID := RepairPlanPreviewID(plan, preview)
438 go func() {
439 result, err := ApplyRepairPlan(plan, ApplyPlanOptions{ExpectedPreviewID: applyPreviewID})
440 resultCh <- struct {
441 result ApplyPlanResult
442 err error
443 }{result, err}
444 }()
445 <-reachedLock
446 if err := os.WriteFile(tabs, []byte("changed-while-waiting"), 0o600); err != nil {
447 t.Fatal(err)
448 }
449 holder()
450 got := <-resultCh
451 if got.err == nil || !strings.Contains(got.err.Error(), "preview changed since confirmation") {
452 t.Fatalf("error = %v, want post-lock state refusal", got.err)
453 }
454 if len(got.result.Applied) != 0 {
455 t.Fatalf("applied = %v, want no writes", got.result.Applied)
456 }
457 if data, err := os.ReadFile(tabs); err != nil || string(data) != "changed-while-waiting" {
458 t.Fatalf("post-preview state was touched: %q, %v", data, err)
459 }
460 }
461
462 func TestApplyRepairPlanDirectCallerRejectsDriftAfterInvocationPreview(t *testing.T) {
463 home := t.TempDir()
464 t.Setenv("REASONIX_HOME", home)
465 tabs := filepath.Join(home, "desktop-tabs.json")
466 if err := os.WriteFile(tabs, []byte("initial"), 0o600); err != nil {
467 t.Fatal(err)
468 }
469 plan := RepairPlan{
470 SchemaVersion: RepairPlanSchemaVersion,
471 Summary: "rebuild tabs",
472 Actions: []RepairPlanAction{{
473 Type: "rebuild_derived_state",
474 Target: "tabs",
475 Reason: "malformed state",
476 }},
477 }
478 targetKey := repairMutationTestKey(tabs)
479 originalHook := repairMutationBeforeLock
480 var writeErr error
481 changed := false
482 repairMutationBeforeLock = func(paths []string) {
483 if changed || len(paths) != 1 || paths[0] != targetKey {
484 return
485 }
486 changed = true
487 writeErr = os.WriteFile(tabs, []byte("changed-after-preview"), 0o600)
488 }
489 t.Cleanup(func() { repairMutationBeforeLock = originalHook })
490
491 result, err := ApplyRepairPlan(plan, ApplyPlanOptions{})
492 if err == nil || !strings.Contains(err.Error(), "preview changed since confirmation") {
493 t.Fatalf("direct apply after invocation drift = %+v, %v", result, err)
494 }
495 if writeErr != nil {
496 t.Fatalf("inject drift: %v", writeErr)
497 }
498 if len(result.Applied) != 0 {
499 t.Fatalf("direct apply wrote actions after drift: %v", result.Applied)
500 }
501 if got, err := os.ReadFile(tabs); err != nil || string(got) != "changed-after-preview" {
502 t.Fatalf("drifted state = %q, %v", got, err)
503 }
504 }
505
506 func TestRepairTransactionLockSerializesDisjointTargets(t *testing.T) {
507 home := t.TempDir()
508 t.Setenv("REASONIX_HOME", home)
509 tabs := filepath.Join(home, "desktop-tabs.json")
510 projects := filepath.Join(home, "desktop-projects.json")
511 if err := os.WriteFile(tabs, []byte("tabs"), 0o600); err != nil {
512 t.Fatal(err)
513 }
514 if err := os.WriteFile(projects, []byte("projects"), 0o600); err != nil {
515 t.Fatal(err)
516 }
517 plan := RepairPlan{SchemaVersion: 1, Summary: "tabs", Actions: []RepairPlanAction{{Type: "rebuild_derived_state", Target: "tabs", Reason: "malformed"}}}
518 preview, err := PreviewRepairPlan(plan, ApplyPlanOptions{})
519 if err != nil {
520 t.Fatal(err)
521 }
522
523 tabsPath := repairMutationTestKey(tabs)
524 transactionPath := repairMutationTestKey(repairTransactionPath())
525 firstHolding := atomic.Bool{}
526 tabsReached := make(chan struct{})
527 releaseTabs := make(chan struct{})
528 secondAttempted := make(chan struct{})
529 t.Cleanup(func() {
530 select {
531 case <-releaseTabs:
532 default:
533 close(releaseTabs)
534 }
535 })
536 originalHook := repairMutationBeforeLock
537 repairMutationBeforeLock = func(paths []string) {
538 if len(paths) != 1 {
539 return
540 }
541 switch paths[0] {
542 case tabsPath:
543 firstHolding.Store(true)
544 close(tabsReached)
545 <-releaseTabs
546 case transactionPath:
547 if firstHolding.Load() {
548 select {
549 case <-secondAttempted:
550 default:
551 close(secondAttempted)
552 }
553 }
554 }
555 }
556 t.Cleanup(func() { repairMutationBeforeLock = originalHook })
557
558 firstResult := make(chan error, 1)
559 go func() {
560 _, err := ApplyRepairPlan(plan, ApplyPlanOptions{ExpectedPreviewID: RepairPlanPreviewID(plan, preview)})
561 firstResult <- err
562 }()
563 <-tabsReached
564 secondResult := make(chan error, 1)
565 go func() {
566 _, err := RebuildDerivedState("projects")
567 secondResult <- err
568 }()
569 <-secondAttempted
570 select {
571 case err := <-secondResult:
572 t.Fatalf("disjoint repair bypassed transaction lock: %v", err)
573 default:
574 }
575 if got, err := os.ReadFile(projects); err != nil || string(got) != "projects" {
576 t.Fatalf("waiting repair changed project state: %q, %v", got, err)
577 }
578
579 close(releaseTabs)
580 if err := <-firstResult; err != nil {
581 t.Fatal(err)
582 }
583 if err := <-secondResult; err != nil {
584 t.Fatal(err)
585 }
586 if _, err := os.Stat(projects); !os.IsNotExist(err) {
587 t.Fatalf("serialized repair did not run: %v", err)
588 }
589 }
590
591 func TestRepairPlanPreviewDiffMatchesBoundState(t *testing.T) {
592 home := t.TempDir()
593 t.Setenv("REASONIX_HOME", home)
594 global := filepath.Join(home, "config.toml")
595 if err := os.WriteFile(global, []byte("[broken\n"), 0o600); err != nil {
596 t.Fatal(err)
597 }
598 plan := RepairPlan{SchemaVersion: 1, Summary: "config", Actions: []RepairPlanAction{{Type: "repair_config", Scope: "global", Reason: "invalid"}}}
599 preview, err := PreviewRepairPlan(plan, ApplyPlanOptions{})
600 if err != nil {
601 t.Fatal(err)
602 }
603 encoded, err := json.Marshal(preview[0])
604 if err != nil {
605 t.Fatal(err)
606 }
607 if !strings.Contains(preview[0].Diff, "[broken") || !strings.Contains(string(encoded), preview[0].StateID) {
608 t.Fatalf("preview diff/state are not derived from one snapshot: %+v", preview[0])
609 }
610 if got := repairPlanFileSnapshotAt(global); got.StateID != preview[0].fileStates[global] || string(got.Content) != "[broken\n" {
611 t.Fatalf("bound state = %+v, current = %+v", preview[0].fileStates, got)
612 }
613 }
614
615 func TestApplyRepairPlanRestoresCurrentConfirmedSnapshot(t *testing.T) {
616 home := t.TempDir()
617 t.Setenv("REASONIX_HOME", home)
618 global := filepath.Join(home, "config.toml")
619 if err := os.WriteFile(global, []byte("default_model = \"known-good\"\n"), 0o600); err != nil {
620 t.Fatal(err)
621 }
622 if err := RecordHealthyConfig("v1"); err != nil {
623 t.Fatal(err)
624 }
625 snapshots, err := ListConfigSnapshots()
626 if err != nil || len(snapshots) != 1 {
627 t.Fatalf("snapshots = %+v, err = %v", snapshots, err)
628 }
629 current := []byte("default_model = \"current\"\n")
630 if err := os.WriteFile(global, current, 0o600); err != nil {
631 t.Fatal(err)
632 }
633 plan := RepairPlan{SchemaVersion: 1, Summary: "snapshot", Actions: []RepairPlanAction{{Type: "restore_snapshot", SnapshotID: snapshots[0].ID, Reason: "known good"}}}
634 preview, err := PreviewRepairPlan(plan, ApplyPlanOptions{})
635 if err != nil {
636 t.Fatal(err)
637 }
638 result, err := ApplyRepairPlan(plan, ApplyPlanOptions{ExpectedPreviewID: RepairPlanPreviewID(plan, preview)})
639 if err != nil {
640 t.Fatal(err)
641 }
642 if len(result.Applied) != 1 || !strings.Contains(result.Applied[0], "restored config snapshot") {
643 t.Fatalf("applied = %v", result.Applied)
644 }
645 if got, err := os.ReadFile(global); err != nil || string(got) != "default_model = \"known-good\"\n" {
646 t.Fatalf("restored config = %q, %v", got, err)
647 }
648 if _, err := UndoLastRepair(); err != nil {
649 t.Fatal(err)
650 }
651 if got, err := os.ReadFile(global); err != nil || string(got) != string(current) {
652 t.Fatalf("undo restored = %q, %v", got, err)
653 }
654 }
655
656 func TestApplyRepairPlanRejectsSnapshotMetadataDrift(t *testing.T) {
657 home := t.TempDir()
658 t.Setenv("REASONIX_HOME", home)
659 global := filepath.Join(home, "config.toml")
660 if err := os.WriteFile(global, []byte("default_model = \"known-good\"\n"), 0o600); err != nil {
661 t.Fatal(err)
662 }
663 if err := RecordHealthyConfig("v1"); err != nil {
664 t.Fatal(err)
665 }
666 snapshots, err := ListConfigSnapshots()
667 if err != nil || len(snapshots) != 1 {
668 t.Fatalf("snapshots = %+v, err = %v", snapshots, err)
669 }
670 current := []byte("default_model = \"current\"\n")
671 if err := os.WriteFile(global, current, 0o600); err != nil {
672 t.Fatal(err)
673 }
674 plan := RepairPlan{SchemaVersion: 1, Summary: "snapshot", Actions: []RepairPlanAction{{Type: "restore_snapshot", SnapshotID: snapshots[0].ID, Reason: "known good"}}}
675 preview, err := PreviewRepairPlan(plan, ApplyPlanOptions{})
676 if err != nil {
677 t.Fatal(err)
678 }
679 metadataPath := snapshots[0].Path + ".json"
680 metadata := snapshots[0]
681 metadata.Version = "drifted"
682 encoded, err := json.MarshalIndent(metadata, "", " ")
683 if err != nil {
684 t.Fatal(err)
685 }
686 if err := os.WriteFile(metadataPath, append(encoded, '\n'), 0o600); err != nil {
687 t.Fatal(err)
688 }
689
690 if _, err := ApplyRepairPlan(plan, ApplyPlanOptions{ExpectedPreviewID: RepairPlanPreviewID(plan, preview)}); err == nil {
691 t.Fatal("apply accepted snapshot metadata drift after confirmation")
692 }
693 if got, err := os.ReadFile(global); err != nil || string(got) != string(current) {
694 t.Fatalf("config changed after rejected metadata drift: %q, %v", got, err)
695 }
696 }
697
698 func TestApplyRepairPlanReportsConfirmedLastKnownGoodRestoreFailure(t *testing.T) {
699 home := t.TempDir()
700 t.Setenv("REASONIX_HOME", home)
701 global := filepath.Join(home, "config.toml")
702 if err := os.WriteFile(global, []byte("[broken\n"), 0o600); err != nil {
703 t.Fatal(err)
704 }
705 lastKnownGood := lastKnownGoodConfigPath()
706 if err := os.MkdirAll(filepath.Dir(lastKnownGood), 0o700); err != nil {
707 t.Fatal(err)
708 }
709 if err := os.WriteFile(lastKnownGood, []byte("[also-broken\n"), 0o600); err != nil {
710 t.Fatal(err)
711 }
712
713 plan := RepairPlan{SchemaVersion: 1, Summary: "config", Actions: []RepairPlanAction{{Type: "repair_config", Scope: "global", Reason: "invalid"}}}
714 preview, err := PreviewRepairPlan(plan, ApplyPlanOptions{})
715 if err != nil {
716 t.Fatal(err)
717 }
718 result, err := ApplyRepairPlan(plan, ApplyPlanOptions{ExpectedPreviewID: RepairPlanPreviewID(plan, preview)})
719 if err == nil || !strings.Contains(err.Error(), "restore confirmed last-known-good config") {
720 t.Fatalf("error = %v, want confirmed restore failure", err)
721 }
722 if len(result.Applied) != 0 {
723 t.Fatalf("applied = %v, want failed action omitted", result.Applied)
724 }
725 if _, statErr := os.Stat(global); !os.IsNotExist(statErr) {
726 t.Fatalf("global config unexpectedly restored: %v", statErr)
727 }
728 if _, err := UndoLastRepair(); err != nil {
729 t.Fatal(err)
730 }
731 if got, err := os.ReadFile(global); err != nil || string(got) != "[broken\n" {
732 t.Fatalf("undo restored = %q, %v", got, err)
733 }
734 }
735
736 func TestApplyRepairPlanDoesNotRestoreUnreadableLastKnownGoodNode(t *testing.T) {
737 home := t.TempDir()
738 t.Setenv("REASONIX_HOME", home)
739 global := filepath.Join(home, "config.toml")
740 if err := os.WriteFile(global, []byte("[broken\n"), 0o600); err != nil {
741 t.Fatal(err)
742 }
743 lastKnownGood := lastKnownGoodConfigPath()
744 if err := os.MkdirAll(lastKnownGood, 0o700); err != nil {
745 t.Fatal(err)
746 }
747
748 plan := RepairPlan{SchemaVersion: 1, Summary: "config", Actions: []RepairPlanAction{{Type: "repair_config", Scope: "global", Reason: "invalid"}}}
749 preview, err := PreviewRepairPlan(plan, ApplyPlanOptions{})
750 if err != nil {
751 t.Fatal(err)
752 }
753 result, err := ApplyRepairPlan(plan, ApplyPlanOptions{ExpectedPreviewID: RepairPlanPreviewID(plan, preview)})
754 if err != nil {
755 t.Fatal(err)
756 }
757 if len(result.Applied) != 1 || !strings.Contains(result.Applied[0], "quarantined global config") {
758 t.Fatalf("applied = %v, want quarantine without restore", result.Applied)
759 }
760 if _, err := os.Stat(global); !os.IsNotExist(err) {
761 t.Fatalf("unreadable source created a global config: %v", err)
762 }
763 }
764
765 func TestProjectRepairPlanRequiresExplicitPermission(t *testing.T) {
766 plan := RepairPlan{SchemaVersion: 1, Summary: "project", Actions: []RepairPlanAction{{Type: "repair_config", Scope: "project", Reason: "bad toml"}}}
767 if _, err := PreviewRepairPlan(plan, ApplyPlanOptions{Root: t.TempDir()}); err == nil || !strings.Contains(err.Error(), "--allow-project") {
768 t.Fatalf("preview error = %v", err)
769 }
770 }
771
772 func TestApplyRepairPlanMultiActionUndoRevertsWholePlan(t *testing.T) {
773 home := t.TempDir()
774 t.Setenv("REASONIX_HOME", home)
775 global := filepath.Join(home, "config.toml")
776 tabs := filepath.Join(home, "desktop-tabs.json")
777 if err := os.WriteFile(global, []byte("[broken\n"), 0o600); err != nil {
778 t.Fatal(err)
779 }
780 if err := os.WriteFile(tabs, []byte("bad-tabs"), 0o600); err != nil {
781 t.Fatal(err)
782 }
783 plan := RepairPlan{SchemaVersion: 1, Summary: "config + tabs", Actions: []RepairPlanAction{
784 {Type: "repair_config", Scope: "global", Reason: "bad toml"},
785 {Type: "rebuild_derived_state", Target: "tabs", Reason: "bad tabs"},
786 }}
787 if _, err := ApplyRepairPlan(plan, ApplyPlanOptions{Root: t.TempDir()}); err != nil {
788 t.Fatal(err)
789 }
790 if _, err := os.Stat(tabs); !os.IsNotExist(err) {
791 t.Fatalf("tabs not quarantined: %v", err)
792 }
793 if _, err := UndoLastRepair(); err != nil {
794 t.Fatal(err)
795 }
796 got, err := os.ReadFile(global)
797 if err != nil || string(got) != "[broken\n" {
798 t.Fatalf("global config not restored by plan-level undo: %q, %v", got, err)
799 }
800 got, err = os.ReadFile(tabs)
801 if err != nil || string(got) != "bad-tabs" {
802 t.Fatalf("derived state not restored by plan-level undo: %q, %v", got, err)
803 }
804 }
805
806 func TestApplyRepairPlanPersistsWholePrefixBeforeReturningFromAction(t *testing.T) {
807 home := t.TempDir()
808 t.Setenv("REASONIX_HOME", home)
809 global := filepath.Join(home, "config.toml")
810 tabs := filepath.Join(home, "desktop-tabs.json")
811 if err := os.WriteFile(global, []byte("[broken\n"), 0o600); err != nil {
812 t.Fatal(err)
813 }
814 if err := os.WriteFile(tabs, []byte("bad-tabs"), 0o600); err != nil {
815 t.Fatal(err)
816 }
817 plan := RepairPlan{SchemaVersion: 1, Summary: "crash-safe plan", Actions: []RepairPlanAction{
818 {Type: "repair_config", Scope: "global", Reason: "bad toml"},
819 {Type: "rebuild_derived_state", Target: "tabs", Reason: "bad tabs"},
820 }}
821 preview, err := PreviewRepairPlan(plan, ApplyPlanOptions{})
822 if err != nil {
823 t.Fatal(err)
824 }
825
826 originalHook := repairTransactionAfterPersist
827 repairTransactionAfterPersist = func(tx *RepairTransaction) {
828 if len(tx.Changes) > 0 && tx.Changes[len(tx.Changes)-1].Scope == "derived:tabs" {
829 panic("simulated crash after action transaction persist")
830 }
831 }
832 panicked := false
833 func() {
834 defer func() {
835 panicked = recover() != nil
836 }()
837 _, _ = ApplyRepairPlan(plan, ApplyPlanOptions{
838 ExpectedPreviewID: RepairPlanPreviewID(plan, preview),
839 })
840 }()
841 repairTransactionAfterPersist = originalHook
842 if !panicked {
843 t.Fatal("simulated post-persist crash did not run")
844 }
845
846 tx, err := ReadLastRepair()
847 if err != nil {
848 t.Fatal(err)
849 }
850 if len(tx.Changes) != 2 ||
851 tx.Changes[0].Scope != "global" ||
852 tx.Changes[1].Scope != "derived:tabs" {
853 t.Fatalf("durable repair prefix = %+v", tx.Changes)
854 }
855 if _, err := UndoLastRepair(); err != nil {
856 t.Fatal(err)
857 }
858 if got, err := os.ReadFile(global); err != nil || string(got) != "[broken\n" {
859 t.Fatalf("global config not restored after crash: %q, %v", got, err)
860 }
861 if got, err := os.ReadFile(tabs); err != nil || string(got) != "bad-tabs" {
862 t.Fatalf("derived state not restored after crash: %q, %v", got, err)
863 }
864 }
865
866 func TestApplyRepairPlanPersistsMissingSnapshotTargetBeforeCreate(t *testing.T) {
867 home := t.TempDir()
868 t.Setenv("REASONIX_HOME", home)
869 global := filepath.Join(home, "config.toml")
870 tabs := filepath.Join(home, "desktop-tabs.json")
871 if err := os.WriteFile(global, []byte("default_model = \"known-good\"\n"), 0o600); err != nil {
872 t.Fatal(err)
873 }
874 if err := RecordHealthyConfig("v1"); err != nil {
875 t.Fatal(err)
876 }
877 snapshots, err := ListConfigSnapshots()
878 if err != nil || len(snapshots) != 1 {
879 t.Fatalf("snapshots = %+v, err = %v", snapshots, err)
880 }
881 if err := os.Remove(global); err != nil {
882 t.Fatal(err)
883 }
884 if err := os.WriteFile(tabs, []byte("bad-tabs"), 0o600); err != nil {
885 t.Fatal(err)
886 }
887 plan := RepairPlan{SchemaVersion: 1, Summary: "crash-safe create", Actions: []RepairPlanAction{
888 {Type: "rebuild_derived_state", Target: "tabs", Reason: "bad tabs"},
889 {Type: "restore_snapshot", SnapshotID: snapshots[0].ID, Reason: "known good"},
890 }}
891 preview, err := PreviewRepairPlan(plan, ApplyPlanOptions{})
892 if err != nil {
893 t.Fatal(err)
894 }
895
896 originalHook := repairSnapshotAfterCreate
897 t.Cleanup(func() { repairSnapshotAfterCreate = originalHook })
898 repairSnapshotAfterCreate = func(path string) {
899 if path == global {
900 panic("simulated crash after snapshot create")
901 }
902 }
903 panicked := false
904 func() {
905 defer func() {
906 panicked = recover() != nil
907 }()
908 _, _ = ApplyRepairPlan(plan, ApplyPlanOptions{
909 ExpectedPreviewID: RepairPlanPreviewID(plan, preview),
910 })
911 }()
912 repairSnapshotAfterCreate = originalHook
913 if !panicked {
914 t.Fatal("simulated post-create crash did not run")
915 }
916
917 tx, err := ReadLastRepair()
918 if err != nil {
919 t.Fatal(err)
920 }
921 if len(tx.Changes) != 1 || tx.Changes[0].Scope != "derived:tabs" {
922 t.Fatalf("committed repair prefix = %+v", tx.Changes)
923 }
924 pendingBytes, err := os.ReadFile(pendingRepairTransactionPath())
925 if err != nil {
926 t.Fatal(err)
927 }
928 var pending RepairTransaction
929 if err := json.Unmarshal(pendingBytes, &pending); err != nil {
930 t.Fatal(err)
931 }
932 if len(pending.Changes) != 2 ||
933 pending.Changes[0].Scope != "derived:tabs" ||
934 !pending.Changes[1].RemoveOnUndo ||
935 !pending.Changes[1].Prepared ||
936 pending.Changes[1].TargetPath != global {
937 t.Fatalf("prepared repair prefix = %+v", pending.Changes)
938 }
939 if _, err := UndoLastRepair(); err != nil {
940 t.Fatal(err)
941 }
942 tx, err = ReadLastRepair()
943 if err != nil || len(tx.Changes) != 2 || !tx.Undone {
944 t.Fatalf("reconciled repair prefix = %+v, %v", tx, err)
945 }
946 if _, err := os.Lstat(global); !os.IsNotExist(err) {
947 t.Fatalf("created config remained after crash recovery: %v", err)
948 }
949 if got, err := os.ReadFile(tabs); err != nil || string(got) != "bad-tabs" {
950 t.Fatalf("derived state not restored after crash: %q, %v", got, err)
951 }
952 }
953
954 func TestPreparedCreateStateIDMatchesAtomicCreate(t *testing.T) {
955 dir := t.TempDir()
956 path := filepath.Join(dir, "config.toml")
957 content := []byte("default_model = \"known-good\"\n")
958 predicted := repairPlanPreparedCreateStateID(path, content, 0o600)
959 if err := fileutil.AtomicCreateFile(path, content, 0o600); err != nil {
960 t.Fatal(err)
961 }
962 if err := verifyRepairPlanReleaseNodeStateFor(path, path, predicted); err != nil {
963 t.Fatalf("prepared create ownership drifted from published node: %v", err)
964 }
965 }
966
967 func TestApplyRepairPlanPreparedIntentSurvivesCrashBeforeRename(t *testing.T) {
968 home := t.TempDir()
969 t.Setenv("REASONIX_HOME", home)
970 tabs := filepath.Join(home, "desktop-tabs.json")
971 projects := filepath.Join(home, "desktop-projects.json")
972 if err := os.WriteFile(projects, []byte("previous-repair"), 0o600); err != nil {
973 t.Fatal(err)
974 }
975 if _, err := RebuildDerivedState("projects"); err != nil {
976 t.Fatal(err)
977 }
978 if err := os.WriteFile(tabs, []byte("confirmed-tabs"), 0o600); err != nil {
979 t.Fatal(err)
980 }
981 plan := RepairPlan{SchemaVersion: 1, Summary: "prepared rename", Actions: []RepairPlanAction{{
982 Type: "rebuild_derived_state", Target: "tabs", Reason: "bad tabs",
983 }}}
984 preview, err := PreviewRepairPlan(plan, ApplyPlanOptions{})
985 if err != nil {
986 t.Fatal(err)
987 }
988 originalHook := repairMutationAfterPrepare
989 t.Cleanup(func() { repairMutationAfterPrepare = originalHook })
990 repairMutationAfterPrepare = func(path string) {
991 if path == tabs {
992 panic("simulated crash before rename")
993 }
994 }
995 panicked := false
996 func() {
997 defer func() { panicked = recover() != nil }()
998 _, _ = ApplyRepairPlan(plan, ApplyPlanOptions{
999 ExpectedPreviewID: RepairPlanPreviewID(plan, preview),
1000 })
1001 }()
1002 repairMutationAfterPrepare = originalHook
1003 if !panicked {
1004 t.Fatal("simulated pre-rename crash did not run")
1005 }
1006 tx, err := ReadLastRepair()
1007 if err != nil || len(tx.Changes) != 1 ||
1008 tx.Changes[0].Scope != "derived:projects" ||
1009 tx.Changes[0].Prepared {
1010 t.Fatalf("previous last repair was overwritten = %+v, %v", tx, err)
1011 }
1012 if _, err := os.Stat(pendingRepairTransactionPath()); err != nil {
1013 t.Fatalf("prepared journal is missing: %v", err)
1014 }
1015 if got, err := os.ReadFile(tabs); err != nil || string(got) != "confirmed-tabs" {
1016 t.Fatalf("pre-rename target changed: %q, %v", got, err)
1017 }
1018 if _, err := UndoLastRepair(); err != nil {
1019 t.Fatal(err)
1020 }
1021 if got, err := os.ReadFile(tabs); err != nil || string(got) != "confirmed-tabs" {
1022 t.Fatalf("prepared no-op undo changed target: %q, %v", got, err)
1023 }
1024 if got, err := os.ReadFile(projects); err != nil || string(got) != "previous-repair" {
1025 t.Fatalf("previous last repair was not undone: %q, %v", got, err)
1026 }
1027 if _, err := os.Stat(pendingRepairTransactionPath()); !os.IsNotExist(err) {
1028 t.Fatalf("no-op prepared journal survived reconciliation: %v", err)
1029 }
1030 }
1031
1032 func TestApplyRepairPlanPreparedIntentSurvivesCrashAfterRename(t *testing.T) {
1033 home := t.TempDir()
1034 t.Setenv("REASONIX_HOME", home)
1035 global := filepath.Join(home, "config.toml")
1036 tabs := filepath.Join(home, "desktop-tabs.json")
1037 if err := os.WriteFile(global, []byte("[broken\n"), 0o600); err != nil {
1038 t.Fatal(err)
1039 }
1040 if err := os.WriteFile(tabs, []byte("confirmed-tabs"), 0o600); err != nil {
1041 t.Fatal(err)
1042 }
1043 plan := RepairPlan{SchemaVersion: 1, Summary: "prepared prefix", Actions: []RepairPlanAction{
1044 {Type: "repair_config", Scope: "global", Reason: "bad config"},
1045 {Type: "rebuild_derived_state", Target: "tabs", Reason: "bad tabs"},
1046 }}
1047 preview, err := PreviewRepairPlan(plan, ApplyPlanOptions{})
1048 if err != nil {
1049 t.Fatal(err)
1050 }
1051 originalHook := repairMutationAfterRename
1052 t.Cleanup(func() { repairMutationAfterRename = originalHook })
1053 repairMutationAfterRename = func(path string) {
1054 if path == tabs {
1055 panic("simulated crash after rename")
1056 }
1057 }
1058 panicked := false
1059 func() {
1060 defer func() { panicked = recover() != nil }()
1061 _, _ = ApplyRepairPlan(plan, ApplyPlanOptions{
1062 ExpectedPreviewID: RepairPlanPreviewID(plan, preview),
1063 })
1064 }()
1065 repairMutationAfterRename = originalHook
1066 if !panicked {
1067 t.Fatal("simulated post-rename crash did not run")
1068 }
1069 tx, err := ReadLastRepair()
1070 if err != nil || len(tx.Changes) != 1 || tx.Changes[0].Scope != "global" {
1071 t.Fatalf("committed repair prefix = %+v, %v", tx, err)
1072 }
1073 if _, err := os.Stat(pendingRepairTransactionPath()); err != nil {
1074 t.Fatalf("post-rename prepared journal is missing: %v", err)
1075 }
1076 if _, err := UndoLastRepair(); err != nil {
1077 t.Fatal(err)
1078 }
1079 tx, err = ReadLastRepair()
1080 if err != nil || len(tx.Changes) != 2 || tx.Changes[1].Prepared || !tx.Undone {
1081 t.Fatalf("reconciled repair prefix = %+v, %v", tx, err)
1082 }
1083 if got, err := os.ReadFile(global); err != nil || string(got) != "[broken\n" {
1084 t.Fatalf("global prefix not restored: %q, %v", got, err)
1085 }
1086 if got, err := os.ReadFile(tabs); err != nil || string(got) != "confirmed-tabs" {
1087 t.Fatalf("prepared renamed target not restored: %q, %v", got, err)
1088 }
1089 }
1090
1091 func TestApplyRepairPlanRetainsDurableRenameWhenPendingCleanupFails(t *testing.T) {
1092 home := t.TempDir()
1093 t.Setenv("REASONIX_HOME", home)
1094 tabs := filepath.Join(home, "desktop-tabs.json")
1095 if err := os.WriteFile(tabs, []byte("confirmed-tabs"), 0o600); err != nil {
1096 t.Fatal(err)
1097 }
1098 plan := RepairPlan{SchemaVersion: 1, Summary: "durable cleanup boundary", Actions: []RepairPlanAction{{
1099 Type: "rebuild_derived_state", Target: "tabs", Reason: "bad tabs",
1100 }}}
1101 preview, err := PreviewRepairPlan(plan, ApplyPlanOptions{})
1102 if err != nil {
1103 t.Fatal(err)
1104 }
1105
1106 originalHook := repairPendingAfterMove
1107 t.Cleanup(func() { repairPendingAfterMove = originalHook })
1108 injected := false
1109 var hookErr error
1110 repairPendingAfterMove = func(path, cleanup string) {
1111 if injected {
1112 return
1113 }
1114 injected = true
1115 var b []byte
1116 b, hookErr = os.ReadFile(cleanup)
1117 if hookErr == nil {
1118 hookErr = os.WriteFile(path, b, 0o600)
1119 }
1120 }
1121 _, err = ApplyRepairPlan(plan, ApplyPlanOptions{
1122 ExpectedPreviewID: RepairPlanPreviewID(plan, preview),
1123 })
1124 repairPendingAfterMove = originalHook
1125 if hookErr != nil {
1126 t.Fatalf("inject duplicate pending journal: %v", hookErr)
1127 }
1128 if err == nil || !strings.Contains(err.Error(), "cleanup pending journal") {
1129 t.Fatalf("apply error = %v, want durable cleanup failure", err)
1130 }
1131 if _, err := os.Lstat(tabs); !os.IsNotExist(err) {
1132 t.Fatalf("durable rename was compensated: %v", err)
1133 }
1134 tx, err := ReadLastRepair()
1135 if err != nil || len(tx.Changes) != 1 || tx.Changes[0].Prepared {
1136 t.Fatalf("durable undo state = %+v, %v", tx, err)
1137 }
1138 if _, err := os.Stat(pendingRepairTransactionPath()); err != nil {
1139 t.Fatalf("pending journal needed for cleanup retry is missing: %v", err)
1140 }
1141 if _, err := UndoLastRepair(); err != nil {
1142 t.Fatal(err)
1143 }
1144 if got, err := os.ReadFile(tabs); err != nil || string(got) != "confirmed-tabs" {
1145 t.Fatalf("undo after cleanup retry = %q, %v", got, err)
1146 }
1147 }
1148
1149 func TestPendingRepairCleanupNeverDeletesConcurrentReplacement(t *testing.T) {
1150 home := t.TempDir()
1151 t.Setenv("REASONIX_HOME", home)
1152 tabs := filepath.Join(home, "desktop-tabs.json")
1153 if err := os.WriteFile(tabs, []byte("confirmed-tabs"), 0o600); err != nil {
1154 t.Fatal(err)
1155 }
1156 plan := RepairPlan{SchemaVersion: 1, Summary: "pending ownership", Actions: []RepairPlanAction{{
1157 Type: "rebuild_derived_state", Target: "tabs", Reason: "bad tabs",
1158 }}}
1159 preview, err := PreviewRepairPlan(plan, ApplyPlanOptions{})
1160 if err != nil {
1161 t.Fatal(err)
1162 }
1163
1164 replacement := []byte("{\"foreign\":true}\n")
1165 originalHook := repairPendingAfterMove
1166 t.Cleanup(func() { repairPendingAfterMove = originalHook })
1167 injected := false
1168 repairPendingAfterMove = func(_, cleanup string) {
1169 if !injected {
1170 injected = true
1171 if err := os.WriteFile(cleanup, replacement, 0o600); err != nil {
1172 panic(err)
1173 }
1174 }
1175 }
1176 _, err = ApplyRepairPlan(plan, ApplyPlanOptions{
1177 ExpectedPreviewID: RepairPlanPreviewID(plan, preview),
1178 })
1179 repairPendingAfterMove = originalHook
1180 if err == nil || !strings.Contains(err.Error(), "changed before cleanup") {
1181 t.Fatalf("apply error = %v, want pending ownership failure", err)
1182 }
1183 got, readErr := os.ReadFile(pendingRepairTransactionPath())
1184 if readErr != nil || string(got) != string(replacement) {
1185 t.Fatalf("concurrent pending replacement = %q, %v", got, readErr)
1186 }
1187 if _, err := UndoLastRepair(); err == nil ||
1188 !strings.Contains(err.Error(), "reconcile pending mutation") {
1189 t.Fatalf("undo with foreign pending journal = %v", err)
1190 }
1191 if got, err := os.ReadFile(pendingRepairTransactionPath()); err != nil ||
1192 string(got) != string(replacement) {
1193 t.Fatalf("foreign pending journal was consumed: %q, %v", got, err)
1194 }
1195 if _, err := os.Lstat(tabs); !os.IsNotExist(err) {
1196 t.Fatalf("failed-closed undo changed repaired target: %v", err)
1197 }
1198 }
1199
1200 func TestReconcilePreparedRepairRechecksSourceAfterTargetLock(t *testing.T) {
1201 home := t.TempDir()
1202 t.Setenv("REASONIX_HOME", home)
1203 tabs := filepath.Join(home, "desktop-tabs.json")
1204 if err := os.WriteFile(tabs, []byte("confirmed-tabs"), 0o600); err != nil {
1205 t.Fatal(err)
1206 }
1207 tx := newRepairTransaction(time.Now())
1208 tx.Changes = append(tx.Changes, preparedRepairChangeForPrevious(
1209 "derived:tabs",
1210 tabs,
1211 tabs+".reasonix-rebuild-20260729T000000Z",
1212 ))
1213 if err := persistPreparedRepairTransaction(tx); err != nil {
1214 t.Fatal(err)
1215 }
1216
1217 originalHook := repairMutationBeforeLock
1218 t.Cleanup(func() { repairMutationBeforeLock = originalHook })
1219 targetKey := repairMutationTestKey(tabs)
1220 injected := false
1221 repairMutationBeforeLock = func(paths []string) {
1222 if injected {
1223 return
1224 }
1225 for _, path := range paths {
1226 if path == targetKey {
1227 injected = true
1228 if err := os.WriteFile(tabs, []byte("drifted-tabs"), 0o600); err != nil {
1229 panic(err)
1230 }
1231 }
1232 }
1233 }
1234 if _, err := UndoLastRepair(); err == nil ||
1235 !strings.Contains(err.Error(), "prepared state is not present") {
1236 t.Fatalf("reconcile drift error = %v", err)
1237 }
1238 repairMutationBeforeLock = originalHook
1239 if got, err := os.ReadFile(tabs); err != nil || string(got) != "drifted-tabs" {
1240 t.Fatalf("reconcile changed drifted source: %q, %v", got, err)
1241 }
1242 if _, err := os.Stat(pendingRepairTransactionPath()); err != nil {
1243 t.Fatalf("ambiguous prepared journal was removed: %v", err)
1244 }
1245 }
1246
1247 func TestUndoPreparedSnapshotCreatePreservesConcurrentReplacement(t *testing.T) {
1248 home := t.TempDir()
1249 t.Setenv("REASONIX_HOME", home)
1250 global := filepath.Join(home, "config.toml")
1251 snapshot := []byte("default_model = \"known-good\"\n")
1252 if err := os.WriteFile(global, snapshot, 0o600); err != nil {
1253 t.Fatal(err)
1254 }
1255 if err := RecordHealthyConfig("v1"); err != nil {
1256 t.Fatal(err)
1257 }
1258 snapshots, err := ListConfigSnapshots()
1259 if err != nil || len(snapshots) != 1 {
1260 t.Fatalf("snapshots = %+v, err = %v", snapshots, err)
1261 }
1262 if err := os.Remove(global); err != nil {
1263 t.Fatal(err)
1264 }
1265 plan := RepairPlan{SchemaVersion: 1, Summary: "snapshot create", Actions: []RepairPlanAction{{
1266 Type: "restore_snapshot", SnapshotID: snapshots[0].ID, Reason: "known good",
1267 }}}
1268 preview, err := PreviewRepairPlan(plan, ApplyPlanOptions{})
1269 if err != nil {
1270 t.Fatal(err)
1271 }
1272 originalHook := repairSnapshotAfterCreate
1273 t.Cleanup(func() { repairSnapshotAfterCreate = originalHook })
1274 repairSnapshotAfterCreate = func(string) { panic("simulated crash after create") }
1275 func() {
1276 defer func() { _ = recover() }()
1277 _, _ = ApplyRepairPlan(plan, ApplyPlanOptions{
1278 ExpectedPreviewID: RepairPlanPreviewID(plan, preview),
1279 })
1280 }()
1281 repairSnapshotAfterCreate = originalHook
1282 concurrent := []byte("default_model = \"concurrent\"\n")
1283 if err := os.WriteFile(global, concurrent, 0o600); err != nil {
1284 t.Fatal(err)
1285 }
1286 if _, err := UndoLastRepair(); err != nil {
1287 t.Fatal(err)
1288 }
1289 if got, err := os.ReadFile(global); err != nil || string(got) != string(concurrent) {
1290 t.Fatalf("undo consumed concurrent replacement: %q, %v", got, err)
1291 }
1292 }
1293
1294 func TestSnapshotCreateDoesNotClaimConcurrentReplacementBeforeOwnershipCheck(t *testing.T) {
1295 home := t.TempDir()
1296 t.Setenv("REASONIX_HOME", home)
1297 global := filepath.Join(home, "config.toml")
1298 snapshot := []byte("default_model = \"known-good\"\n")
1299 if err := os.WriteFile(global, snapshot, 0o600); err != nil {
1300 t.Fatal(err)
1301 }
1302 if err := RecordHealthyConfig("v1"); err != nil {
1303 t.Fatal(err)
1304 }
1305 snapshots, err := ListConfigSnapshots()
1306 if err != nil || len(snapshots) != 1 {
1307 t.Fatalf("snapshots = %+v, err = %v", snapshots, err)
1308 }
1309 if err := os.Remove(global); err != nil {
1310 t.Fatal(err)
1311 }
1312 plan := RepairPlan{SchemaVersion: 1, Summary: "snapshot create", Actions: []RepairPlanAction{{
1313 Type: "restore_snapshot", SnapshotID: snapshots[0].ID, Reason: "known good",
1314 }}}
1315 preview, err := PreviewRepairPlan(plan, ApplyPlanOptions{})
1316 if err != nil {
1317 t.Fatal(err)
1318 }
1319 concurrent := []byte("default_model = \"concurrent\"\n")
1320 originalHook := repairSnapshotAfterCreate
1321 t.Cleanup(func() { repairSnapshotAfterCreate = originalHook })
1322 repairSnapshotAfterCreate = func(path string) {
1323 if err := fileutil.AtomicWriteFile(path, concurrent, 0o600); err != nil {
1324 t.Fatal(err)
1325 }
1326 }
1327 _, err = ApplyRepairPlan(plan, ApplyPlanOptions{
1328 ExpectedPreviewID: RepairPlanPreviewID(plan, preview),
1329 })
1330 repairSnapshotAfterCreate = originalHook
1331 if err == nil || !strings.Contains(err.Error(), "published create ownership changed") {
1332 t.Fatalf("concurrent replacement error = %v", err)
1333 }
1334 if _, err := UndoLastRepair(); err != nil {
1335 t.Fatal(err)
1336 }
1337 if got, err := os.ReadFile(global); err != nil || string(got) != string(concurrent) {
1338 t.Fatalf("undo consumed concurrent replacement: %q, %v", got, err)
1339 }
1340 }
1341
1342 func TestProjectRepairPlanDoesNotRepairGlobalConfig(t *testing.T) {
1343 home := t.TempDir()
1344 t.Setenv("REASONIX_HOME", home)
1345 root := t.TempDir()
1346 global := filepath.Join(home, "config.toml")
1347 project := filepath.Join(root, "reasonix.toml")
1348 for _, path := range []string{global, project} {
1349 if err := os.WriteFile(path, []byte("[broken\n"), 0o600); err != nil {
1350 t.Fatal(err)
1351 }
1352 }
1353 plan := RepairPlan{SchemaVersion: 1, Summary: "project only", Actions: []RepairPlanAction{{Type: "repair_config", Scope: "project", Reason: "bad project toml"}}}
1354 if _, err := ApplyRepairPlan(plan, ApplyPlanOptions{Root: root, AllowProject: true}); err != nil {
1355 t.Fatal(err)
1356 }
1357 if _, err := os.Stat(global); err != nil {
1358 t.Fatalf("global config was touched: %v", err)
1359 }
1360 if _, err := os.Stat(project); !os.IsNotExist(err) {
1361 t.Fatalf("project config was not quarantined: %v", err)
1362 }
1363 }
1364
1365 func TestRepairPlanPreviewIDRejectsSameContentDifferentTargets(t *testing.T) {
1366 home := t.TempDir()
1367 t.Setenv("REASONIX_HOME", home)
1368 rootA := t.TempDir()
1369 rootB := t.TempDir()
1370 content := []byte("[broken\n")
1371 for _, root := range []string{rootA, rootB} {
1372 if err := os.WriteFile(filepath.Join(root, "reasonix.toml"), content, 0o600); err != nil {
1373 t.Fatal(err)
1374 }
1375 }
1376 plan := RepairPlan{SchemaVersion: 1, Summary: "project", Actions: []RepairPlanAction{{Type: "repair_config", Scope: "project", Reason: "bad toml"}}}
1377 previewA, err := PreviewRepairPlan(plan, ApplyPlanOptions{Root: rootA, AllowProject: true})
1378 if err != nil {
1379 t.Fatal(err)
1380 }
1381 previewB, err := PreviewRepairPlan(plan, ApplyPlanOptions{Root: rootB, AllowProject: true})
1382 if err != nil {
1383 t.Fatal(err)
1384 }
1385 idA := RepairPlanPreviewID(plan, previewA)
1386 idB := RepairPlanPreviewID(plan, previewB)
1387 if idA == "" || idA == idB {
1388 t.Fatalf("same-content different roots must not share previewId: a=%s b=%s", idA, idB)
1389 }
1390 if _, err := ApplyRepairPlan(plan, ApplyPlanOptions{Root: rootB, AllowProject: true, ExpectedPreviewID: idA}); err == nil || !strings.Contains(err.Error(), "preview changed since confirmation") {
1391 t.Fatalf("error = %v, want cross-target preview refusal", err)
1392 }
1393 if got, err := os.ReadFile(filepath.Join(rootB, "reasonix.toml")); err != nil || string(got) != string(content) {
1394 t.Fatalf("project B was modified without confirmation: %q, %v", got, err)
1395 }
1396 if _, err := os.Stat(filepath.Join(rootA, "reasonix.toml")); err != nil {
1397 t.Fatalf("project A was touched: %v", err)
1398 }
1399 }
1400
1401 func TestRepairMutationLockConvergesSymlinkAliases(t *testing.T) {
1402 base := t.TempDir()
1403 realDir := filepath.Join(base, "real", "project")
1404 if err := os.MkdirAll(realDir, 0o700); err != nil {
1405 t.Fatal(err)
1406 }
1407 linkParent := filepath.Join(base, "link")
1408 if err := os.MkdirAll(linkParent, 0o700); err != nil {
1409 t.Fatal(err)
1410 }
1411 linkDir := filepath.Join(linkParent, "project")
1412 if err := os.Symlink(realDir, linkDir); err != nil {
1413 t.Fatal(err)
1414 }
1415 realFile := filepath.Join(realDir, "reasonix.toml")
1416 aliasFile := filepath.Join(linkDir, "reasonix.toml")
1417 if err := os.WriteFile(realFile, []byte("[broken\n"), 0o600); err != nil {
1418 t.Fatal(err)
1419 }
1420 if canonicalRepairPath(realFile) != canonicalRepairPath(aliasFile) {
1421 t.Fatalf("symlink aliases diverged: real=%q alias=%q", canonicalRepairPath(realFile), canonicalRepairPath(aliasFile))
1422 }
1423
1424 holder, err := lockRepairMutations(realFile)
1425 if err != nil {
1426 t.Fatal(err)
1427 }
1428 reached := make(chan struct{})
1429 originalHook := repairMutationBeforeLock
1430 repairMutationBeforeLock = func(paths []string) {
1431 if len(paths) == 1 && paths[0] == canonicalRepairPath(aliasFile) {
1432 select {
1433 case <-reached:
1434 default:
1435 close(reached)
1436 }
1437 }
1438 }
1439 t.Cleanup(func() { repairMutationBeforeLock = originalHook })
1440
1441 type outcome struct {
1442 unlock func()
1443 err error
1444 }
1445 resultCh := make(chan outcome, 1)
1446 go func() {
1447 unlock, err := lockRepairMutations(aliasFile)
1448 resultCh <- outcome{unlock, err}
1449 }()
1450 select {
1451 case <-reached:
1452 case <-time.After(2 * time.Second):
1453 holder()
1454 t.Fatal("alias lock did not wait on real-path holder")
1455 }
1456 select {
1457 case got := <-resultCh:
1458 if got.unlock != nil {
1459 got.unlock()
1460 }
1461 holder()
1462 t.Fatalf("alias lock acquired while real path held: err=%v", got.err)
1463 case <-time.After(150 * time.Millisecond):
1464 }
1465 holder()
1466 got := <-resultCh
1467 if got.err != nil {
1468 t.Fatalf("alias lock after release: %v", got.err)
1469 }
1470 got.unlock()
1471 }
1472
1473 func TestApplyRepairPlanRejectsReleaseUnitDriftWithStablePendingUpdate(t *testing.T) {
1474 home := t.TempDir()
1475 t.Setenv("REASONIX_HOME", home)
1476 dir, err := filepath.EvalSymlinks(t.TempDir())
1477 if err != nil {
1478 t.Fatal(err)
1479 }
1480 target := filepath.Join(dir, "reasonix-desktop")
1481 originalExecutable := repairExecutable
1482 repairExecutable = func() (string, error) { return filepath.Join(dir, "reasonix-guard"), nil }
1483 t.Cleanup(func() { repairExecutable = originalExecutable })
1484 if err := os.WriteFile(target, []byte("old"), 0o700); err != nil {
1485 t.Fatal(err)
1486 }
1487 if _, err := PrepareFileUpdate("v1", "v2", target); err != nil {
1488 t.Fatal(err)
1489 }
1490 if err := os.WriteFile(target, []byte("new"), 0o700); err != nil {
1491 t.Fatal(err)
1492 }
1493 plan := RepairPlan{SchemaVersion: 1, Summary: "rollback", Actions: []RepairPlanAction{{Type: "rollback_update", Reason: "failed update"}}}
1494 preview, err := PreviewRepairPlan(plan, ApplyPlanOptions{})
1495 if err != nil {
1496 t.Fatal(err)
1497 }
1498 expected := RepairPlanPreviewID(plan, preview)
1499 // Pending transaction stays identical, but the live binary changes again.
1500 if err := os.WriteFile(target, []byte("newer-unconfirmed"), 0o700); err != nil {
1501 t.Fatal(err)
1502 }
1503 if _, err := ApplyRepairPlan(plan, ApplyPlanOptions{ExpectedPreviewID: expected}); err == nil || !strings.Contains(err.Error(), "preview changed since confirmation") {
1504 t.Fatalf("error = %v, want release-unit drift refusal", err)
1505 }
1506 if got, err := os.ReadFile(target); err != nil || string(got) != "newer-unconfirmed" {
1507 t.Fatalf("stale rollback displaced current binary: %q, %v", got, err)
1508 }
1509 }
1510
1511 func TestRepairMutationLockSerializesUpdateRollbackAndPrepare(t *testing.T) {
1512 home := t.TempDir()
1513 t.Setenv("REASONIX_HOME", home)
1514 dir, err := filepath.EvalSymlinks(t.TempDir())
1515 if err != nil {
1516 t.Fatal(err)
1517 }
1518 target := filepath.Join(dir, "reasonix-desktop")
1519 originalExecutable := repairExecutable
1520 repairExecutable = func() (string, error) { return filepath.Join(dir, "reasonix-guard"), nil }
1521 t.Cleanup(func() { repairExecutable = originalExecutable })
1522 if err := os.WriteFile(target, []byte("old"), 0o700); err != nil {
1523 t.Fatal(err)
1524 }
1525 if _, err := PrepareFileUpdate("v1", "v2", target); err != nil {
1526 t.Fatal(err)
1527 }
1528 if err := os.WriteFile(target, []byte("new"), 0o700); err != nil {
1529 t.Fatal(err)
1530 }
1531
1532 holder, err := lockRepairMutations(target)
1533 if err != nil {
1534 t.Fatal(err)
1535 }
1536 reached := make(chan struct{})
1537 originalHook := repairMutationBeforeLock
1538 repairMutationBeforeLock = func(paths []string) {
1539 if len(paths) == 1 && paths[0] == canonicalRepairPath(target) {
1540 select {
1541 case <-reached:
1542 default:
1543 close(reached)
1544 }
1545 }
1546 }
1547 t.Cleanup(func() { repairMutationBeforeLock = originalHook })
1548
1549 var once sync.Once
1550 resultCh := make(chan error, 1)
1551 go func() {
1552 _, err := RollbackPendingUpdate()
1553 resultCh <- err
1554 }()
1555 select {
1556 case <-reached:
1557 case <-time.After(2 * time.Second):
1558 holder()
1559 t.Fatal("rollback did not wait for shared target lock")
1560 }
1561 select {
1562 case err := <-resultCh:
1563 holder()
1564 t.Fatalf("rollback completed while target locked: %v", err)
1565 case <-time.After(150 * time.Millisecond):
1566 }
1567 once.Do(holder)
1568 if err := <-resultCh; err != nil {
1569 t.Fatalf("rollback after target unlock: %v", err)
1570 }
1571 if got, err := os.ReadFile(target); err != nil || string(got) != "old" {
1572 t.Fatalf("target after serialized rollback = %q, %v", got, err)
1573 }
1574 }
1575
1576 func TestRepairPlanPreviewIDDistinguishesLeafSymlinkTargets(t *testing.T) {
1577 home := t.TempDir()
1578 t.Setenv("REASONIX_HOME", home)
1579 base := t.TempDir()
1580 shared := filepath.Join(base, "shared.toml")
1581 if err := os.WriteFile(shared, []byte("[broken\n"), 0o600); err != nil {
1582 t.Fatal(err)
1583 }
1584 rootA := filepath.Join(base, "a")
1585 rootB := filepath.Join(base, "b")
1586 for _, root := range []string{rootA, rootB} {
1587 if err := os.MkdirAll(root, 0o700); err != nil {
1588 t.Fatal(err)
1589 }
1590 if err := os.Symlink(shared, filepath.Join(root, "reasonix.toml")); err != nil {
1591 t.Fatal(err)
1592 }
1593 }
1594 plan := RepairPlan{SchemaVersion: 1, Summary: "project", Actions: []RepairPlanAction{{Type: "repair_config", Scope: "project", Reason: "bad toml"}}}
1595 previewA, err := PreviewRepairPlan(plan, ApplyPlanOptions{Root: rootA, AllowProject: true})
1596 if err != nil {
1597 t.Fatal(err)
1598 }
1599 previewB, err := PreviewRepairPlan(plan, ApplyPlanOptions{Root: rootB, AllowProject: true})
1600 if err != nil {
1601 t.Fatal(err)
1602 }
1603 idA := RepairPlanPreviewID(plan, previewA)
1604 idB := RepairPlanPreviewID(plan, previewB)
1605 if idA == "" || idA == idB {
1606 t.Fatalf("leaf symlink targets must not share previewId: a=%s b=%s", idA, idB)
1607 }
1608 if _, err := ApplyRepairPlan(plan, ApplyPlanOptions{Root: rootA, AllowProject: true, ExpectedPreviewID: idA}); err != nil {
1609 t.Fatalf("confirmed leaf symlink repair failed: %v", err)
1610 }
1611 if _, err := os.Lstat(filepath.Join(rootA, "reasonix.toml")); !os.IsNotExist(err) {
1612 t.Fatalf("project A symlink was not quarantined: %v", err)
1613 }
1614 if _, err := os.Lstat(filepath.Join(rootB, "reasonix.toml")); err != nil {
1615 t.Fatalf("project B leaf was touched: %v", err)
1616 }
1617 if _, err := os.Stat(shared); err != nil {
1618 t.Fatalf("shared referent was removed: %v", err)
1619 }
1620 }
1621
1622 func TestApplyRepairPlanRejectsAppBundleInteriorDrift(t *testing.T) {
1623 home := t.TempDir()
1624 t.Setenv("REASONIX_HOME", home)
1625 base, err := filepath.EvalSymlinks(t.TempDir())
1626 if err != nil {
1627 t.Fatal(err)
1628 }
1629 app := filepath.Join(base, "Reasonix.app")
1630 exe := filepath.Join(app, "Contents", "MacOS", "Reasonix")
1631 if err := os.MkdirAll(filepath.Dir(exe), 0o700); err != nil {
1632 t.Fatal(err)
1633 }
1634 if err := os.WriteFile(exe, []byte("old"), 0o700); err != nil {
1635 t.Fatal(err)
1636 }
1637 originalExecutable := repairExecutable
1638 repairExecutable = func() (string, error) { return exe, nil }
1639 t.Cleanup(func() { repairExecutable = originalExecutable })
1640
1641 backup := app + ".reasonix-update-backup"
1642 if err := os.MkdirAll(filepath.Join(backup, "Contents", "MacOS"), 0o700); err != nil {
1643 t.Fatal(err)
1644 }
1645 if err := os.WriteFile(filepath.Join(backup, "Contents", "MacOS", "Reasonix"), []byte("backup-old"), 0o700); err != nil {
1646 t.Fatal(err)
1647 }
1648 if _, err := PrepareAppBundleUpdate("v1", "v2", app, backup); err != nil {
1649 t.Fatal(err)
1650 }
1651 if err := os.WriteFile(exe, []byte("new"), 0o700); err != nil {
1652 t.Fatal(err)
1653 }
1654 plan := RepairPlan{SchemaVersion: 1, Summary: "rollback", Actions: []RepairPlanAction{{Type: "rollback_update", Reason: "failed update"}}}
1655 preview, err := PreviewRepairPlan(plan, ApplyPlanOptions{})
1656 if err != nil {
1657 t.Fatal(err)
1658 }
1659 expected := RepairPlanPreviewID(plan, preview)
1660 if err := os.WriteFile(exe, []byte("newer-unconfirmed"), 0o700); err != nil {
1661 t.Fatal(err)
1662 }
1663 if _, err := ApplyRepairPlan(plan, ApplyPlanOptions{ExpectedPreviewID: expected}); err == nil || !strings.Contains(err.Error(), "preview changed since confirmation") {
1664 t.Fatalf("error = %v, want app-bundle interior drift refusal", err)
1665 }
1666 if got, err := os.ReadFile(exe); err != nil || string(got) != "newer-unconfirmed" {
1667 t.Fatalf("unconfirmed bundle interior was rolled back: %q, %v", got, err)
1668 }
1669 }
1670
1670 lines GO