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