返回 DeepSeek-Reasonix
config_test.go
根目录 / internal / repair / config_test.go
1 package repair
2
3 import (
4 "crypto/sha256"
5 "encoding/hex"
6 "errors"
7 "os"
8 "path/filepath"
9 "runtime"
10 "strings"
11 "testing"
12 "time"
13
14 "reasonix/internal/config"
15 )
16
17 func TestInspectInvalidProjectConfigIsReadOnlyByDefault(t *testing.T) {
18 root := t.TempDir()
19 path := filepath.Join(root, "reasonix.toml")
20 if err := os.WriteFile(path, []byte("[broken\n"), 0o600); err != nil {
21 t.Fatal(err)
22 }
23 report, err := InspectAndRepairConfig(ConfigOptions{Root: root, Apply: true})
24 if err != nil {
25 t.Fatal(err)
26 }
27 if len(report.Checks) != 2 || report.Checks[1].Valid {
28 t.Fatalf("checks = %+v", report.Checks)
29 }
30 if _, err := os.Stat(path); err != nil {
31 t.Fatalf("project config was modified without IncludeProject: %v", err)
32 }
33 }
34
35 func TestInspectCanQuarantineInvalidProjectConfig(t *testing.T) {
36 root := t.TempDir()
37 path := filepath.Join(root, "reasonix.toml")
38 if err := os.WriteFile(path, []byte("[broken\n"), 0o600); err != nil {
39 t.Fatal(err)
40 }
41 report, err := InspectAndRepairConfig(ConfigOptions{Root: root, Apply: true, IncludeProject: true})
42 if err != nil {
43 t.Fatal(err)
44 }
45 if report.Checks[1].Exists || !report.Checks[1].Valid {
46 t.Fatalf("project check after repair = %+v", report.Checks[1])
47 }
48 if matches, _ := filepath.Glob(path + ".reasonix-quarantine-*"); len(matches) != 1 {
49 t.Fatalf("quarantine matches = %v", matches)
50 }
51 }
52
53 func TestInspectCanQuarantineDanglingProjectConfigSymlink(t *testing.T) {
54 if runtime.GOOS == "windows" {
55 t.Skip("creating symlinks requires elevated privileges on Windows CI")
56 }
57 t.Setenv("REASONIX_HOME", t.TempDir())
58 root := t.TempDir()
59 path := filepath.Join(root, "reasonix.toml")
60 if err := os.Symlink(filepath.Join(root, "missing.toml"), path); err != nil {
61 t.Fatal(err)
62 }
63 report, err := InspectAndRepairConfig(ConfigOptions{
64 Root: root,
65 Apply: true,
66 IncludeProject: true,
67 OnlyScope: "project",
68 })
69 if err != nil {
70 t.Fatal(err)
71 }
72 if report.Checks[1].Exists || !report.Checks[1].Valid {
73 t.Fatalf("project check after dangling-link repair = %+v", report.Checks[1])
74 }
75 matches, err := filepath.Glob(path + ".reasonix-quarantine-*")
76 if err != nil || len(matches) != 1 {
77 t.Fatalf("dangling symlink quarantine = %v, %v", matches, err)
78 }
79 info, err := os.Lstat(matches[0])
80 if err != nil || info.Mode()&os.ModeSymlink == 0 {
81 t.Fatalf("quarantine did not preserve dangling symlink node: %v, %v", info, err)
82 }
83 }
84
85 func TestRebuildDerivedStateQuarantinesDanglingSymlink(t *testing.T) {
86 if runtime.GOOS == "windows" {
87 t.Skip("creating symlinks requires elevated privileges on Windows CI")
88 }
89 home := t.TempDir()
90 t.Setenv("REASONIX_HOME", home)
91 path := filepath.Join(home, "desktop-tabs.json")
92 if err := os.Symlink(filepath.Join(home, "missing-tabs.json"), path); err != nil {
93 t.Fatal(err)
94 }
95 applied, err := RebuildDerivedState("tabs")
96 if err != nil {
97 t.Fatal(err)
98 }
99 if len(applied) != 1 {
100 t.Fatalf("applied = %v, want dangling state quarantine", applied)
101 }
102 if _, err := os.Lstat(path); !os.IsNotExist(err) {
103 t.Fatalf("dangling derived-state link survived rebuild: %v", err)
104 }
105 info, err := os.Lstat(applied[0])
106 if err != nil || info.Mode()&os.ModeSymlink == 0 {
107 t.Fatalf("derived-state quarantine did not preserve symlink: %v, %v", info, err)
108 }
109 }
110
111 func TestRebuildDerivedStateDirectRejectsWriteBeforeRename(t *testing.T) {
112 home := t.TempDir()
113 t.Setenv("REASONIX_HOME", home)
114 tabs := filepath.Join(home, "desktop-tabs.json")
115 if err := os.WriteFile(tabs, []byte("initial"), 0o600); err != nil {
116 t.Fatal(err)
117 }
118 originalHook := repairMutationBeforeRename
119 var writeErr error
120 repairMutationBeforeRename = func(path string) {
121 if path == tabs {
122 writeErr = os.WriteFile(path, []byte("concurrent"), 0o600)
123 }
124 }
125 t.Cleanup(func() { repairMutationBeforeRename = originalHook })
126
127 if _, err := RebuildDerivedState("tabs"); err == nil ||
128 !strings.Contains(err.Error(), "preview changed since confirmation") {
129 t.Fatalf("direct rebuild after drift = %v", err)
130 }
131 if writeErr != nil {
132 t.Fatalf("inject direct rebuild drift: %v", writeErr)
133 }
134 if got, err := os.ReadFile(tabs); err != nil || string(got) != "concurrent" {
135 t.Fatalf("concurrent derived state = %q, %v", got, err)
136 }
137 }
138
139 func TestInspectAndRepairConfigDirectRejectsWriteBeforeRename(t *testing.T) {
140 t.Setenv("REASONIX_HOME", t.TempDir())
141 root := t.TempDir()
142 project := filepath.Join(root, "reasonix.toml")
143 if err := os.WriteFile(project, []byte("invalid = [\n"), 0o600); err != nil {
144 t.Fatal(err)
145 }
146 originalHook := repairMutationBeforeRename
147 var writeErr error
148 repairMutationBeforeRename = func(path string) {
149 if path == project {
150 writeErr = os.WriteFile(path, []byte("different = [\n"), 0o600)
151 }
152 }
153 t.Cleanup(func() { repairMutationBeforeRename = originalHook })
154
155 if _, err := InspectAndRepairConfig(ConfigOptions{
156 Root: root,
157 Apply: true,
158 IncludeProject: true,
159 OnlyScope: "project",
160 }); err == nil || !strings.Contains(err.Error(), "preview changed since confirmation") {
161 t.Fatalf("direct config repair after drift = %v", err)
162 }
163 if writeErr != nil {
164 t.Fatalf("inject direct config drift: %v", writeErr)
165 }
166 if got, err := os.ReadFile(project); err != nil || string(got) != "different = [\n" {
167 t.Fatalf("concurrent project config = %q, %v", got, err)
168 }
169 }
170
171 func TestInspectAndRepairConfigDirectRejectsDriftWhileWaitingForTransactionLock(t *testing.T) {
172 t.Setenv("REASONIX_HOME", t.TempDir())
173 root := t.TempDir()
174 project := filepath.Join(root, "reasonix.toml")
175 if err := os.WriteFile(project, []byte("invalid = [\n"), 0o600); err != nil {
176 t.Fatal(err)
177 }
178 transactionKey := repairMutationTestKey(repairTransactionPath())
179 originalHook := repairMutationBeforeLock
180 var writeErr error
181 changed := false
182 repairMutationBeforeLock = func(paths []string) {
183 if changed || len(paths) != 1 || paths[0] != transactionKey {
184 return
185 }
186 changed = true
187 writeErr = os.WriteFile(project, []byte("different = [\n"), 0o600)
188 }
189 t.Cleanup(func() { repairMutationBeforeLock = originalHook })
190
191 if _, err := InspectAndRepairConfig(ConfigOptions{
192 Root: root,
193 Apply: true,
194 IncludeProject: true,
195 OnlyScope: "project",
196 }); err == nil || !strings.Contains(err.Error(), "preview changed since confirmation") {
197 t.Fatalf("direct config repair after lock-wait drift = %v", err)
198 }
199 if writeErr != nil {
200 t.Fatalf("inject direct config drift: %v", writeErr)
201 }
202 if got, err := os.ReadFile(project); err != nil || string(got) != "different = [\n" {
203 t.Fatalf("drifted project config = %q, %v", got, err)
204 }
205 }
206
207 func TestRebuildDerivedStateDirectRejectsDriftWhileWaitingForTransactionLock(t *testing.T) {
208 home := t.TempDir()
209 t.Setenv("REASONIX_HOME", home)
210 tabs := filepath.Join(home, "desktop-tabs.json")
211 if err := os.WriteFile(tabs, []byte("initial"), 0o600); err != nil {
212 t.Fatal(err)
213 }
214 transactionKey := repairMutationTestKey(repairTransactionPath())
215 originalHook := repairMutationBeforeLock
216 var writeErr error
217 changed := false
218 repairMutationBeforeLock = func(paths []string) {
219 if changed || len(paths) != 1 || paths[0] != transactionKey {
220 return
221 }
222 changed = true
223 writeErr = os.WriteFile(tabs, []byte("changed-while-waiting"), 0o600)
224 }
225 t.Cleanup(func() { repairMutationBeforeLock = originalHook })
226
227 if _, err := RebuildDerivedState("tabs"); err == nil ||
228 !strings.Contains(err.Error(), "preview changed since confirmation") {
229 t.Fatalf("direct derived-state rebuild after lock-wait drift = %v", err)
230 }
231 if writeErr != nil {
232 t.Fatalf("inject direct derived-state drift: %v", writeErr)
233 }
234 if got, err := os.ReadFile(tabs); err != nil || string(got) != "changed-while-waiting" {
235 t.Fatalf("drifted derived state = %q, %v", got, err)
236 }
237 }
238
239 func TestRepairRestoresLastKnownGoodGlobalConfig(t *testing.T) {
240 home := t.TempDir()
241 t.Setenv("REASONIX_HOME", home)
242 path := filepath.Join(home, "config.toml")
243 original := []byte("default_model = \"deepseek-flash\"\n")
244 if err := os.WriteFile(path, original, 0o600); err != nil {
245 t.Fatal(err)
246 }
247 if err := RecordHealthyConfig("v1"); err != nil {
248 t.Fatal(err)
249 }
250 if err := os.WriteFile(path, []byte("[broken\n"), 0o600); err != nil {
251 t.Fatal(err)
252 }
253 report, err := InspectAndRepairConfig(ConfigOptions{Root: t.TempDir(), Apply: true})
254 if err != nil {
255 t.Fatal(err)
256 }
257 got, err := os.ReadFile(path)
258 if err != nil {
259 t.Fatal(err)
260 }
261 if string(got) != string(original) {
262 t.Fatalf("restored config = %q, want %q", got, original)
263 }
264 if len(report.Applied) != 2 {
265 t.Fatalf("applied = %v", report.Applied)
266 }
267 }
268
269 func TestConfigSnapshotsRotateAndVerifyHash(t *testing.T) {
270 home := t.TempDir()
271 t.Setenv("REASONIX_HOME", home)
272 path := filepath.Join(home, "config.toml")
273 for i := 0; i < configSnapshotRetention+2; i++ {
274 content := []byte("default_model = \"model-" + string(rune('a'+i)) + "\"\n")
275 if err := os.WriteFile(path, content, 0o600); err != nil {
276 t.Fatal(err)
277 }
278 if err := RecordHealthyConfig("v1"); err != nil {
279 t.Fatal(err)
280 }
281 }
282 snapshots, err := ListConfigSnapshots()
283 if err != nil {
284 t.Fatal(err)
285 }
286 if len(snapshots) != configSnapshotRetention {
287 t.Fatalf("snapshots = %d, want %d", len(snapshots), configSnapshotRetention)
288 }
289 if err := os.WriteFile(snapshots[0].Path, []byte("tampered"), 0o600); err != nil {
290 t.Fatal(err)
291 }
292 if _, err := RestoreConfigSnapshot(snapshots[0].ID); err == nil {
293 t.Fatal("tampered snapshot was restored")
294 }
295 }
296
297 func TestRecordConfigSnapshotDoesNotReplaceExistingID(t *testing.T) {
298 t.Setenv("REASONIX_HOME", t.TempDir())
299 content := []byte("default_model = \"confirmed\"\n")
300 now := time.Date(2026, time.July, 29, 1, 2, 3, 4, time.UTC)
301 sum := sha256.Sum256(content)
302 id := now.Format("20060102T150405.000000000Z") + "-" + hex.EncodeToString(sum[:])[:12]
303 path := filepath.Join(snapshotDir(), id+".toml")
304 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
305 t.Fatal(err)
306 }
307 intruder := []byte("default_model = \"existing\"\n")
308 if err := os.WriteFile(path, intruder, 0o600); err != nil {
309 t.Fatal(err)
310 }
311
312 if err := recordConfigSnapshot(config.UserConfigPath(), content, "v1", now); err == nil {
313 t.Fatal("snapshot publication replaced or accepted a conflicting existing ID")
314 }
315 if got, err := os.ReadFile(path); err != nil || string(got) != string(intruder) {
316 t.Fatalf("existing snapshot = %q, %v; want preserved %q", got, err, intruder)
317 }
318 }
319
320 func TestRecordHealthyConfigRejectsTamperedDuplicateSnapshot(t *testing.T) {
321 t.Setenv("REASONIX_HOME", t.TempDir())
322 path := config.UserConfigPath()
323 healthy := []byte("default_model = \"healthy\"\n")
324 if err := os.WriteFile(path, healthy, 0o600); err != nil {
325 t.Fatal(err)
326 }
327 if err := RecordHealthyConfig("v1"); err != nil {
328 t.Fatal(err)
329 }
330 snapshots, err := ListConfigSnapshots()
331 if err != nil || len(snapshots) != 1 {
332 t.Fatalf("snapshots = %+v, %v", snapshots, err)
333 }
334 tampered := []byte("default_model = \"tampered\"\n")
335 if err := os.WriteFile(snapshots[0].Path, tampered, 0o600); err != nil {
336 t.Fatal(err)
337 }
338
339 if err := RecordHealthyConfig("v1"); err == nil {
340 t.Fatal("duplicate snapshot deduplication hid tampered content")
341 }
342 if got, err := os.ReadFile(snapshots[0].Path); err != nil || string(got) != string(tampered) {
343 t.Fatalf("tampered snapshot = %q, %v; want preserved", got, err)
344 }
345 }
346
347 func TestRecordHealthyConfigReadsSourceAfterMutationLock(t *testing.T) {
348 t.Setenv("REASONIX_HOME", t.TempDir())
349 path := config.UserConfigPath()
350 before := []byte("default_model = \"before-lock\"\n")
351 after := []byte("default_model = \"after-lock\"\n")
352 if err := os.WriteFile(path, before, 0o600); err != nil {
353 t.Fatal(err)
354 }
355 unlock, err := lockRepairMutations(path)
356 if err != nil {
357 t.Fatal(err)
358 }
359
360 attempted := make(chan struct{}, 1)
361 originalHook := repairMutationBeforeLock
362 repairMutationBeforeLock = func(keys []string) {
363 for _, key := range keys {
364 if key == canonicalRepairPath(path) {
365 select {
366 case attempted <- struct{}{}:
367 default:
368 }
369 }
370 }
371 }
372 t.Cleanup(func() { repairMutationBeforeLock = originalHook })
373 done := make(chan error, 1)
374 go func() { done <- RecordHealthyConfig("v1") }()
375 <-attempted
376 select {
377 case err := <-done:
378 unlock()
379 t.Fatalf("RecordHealthyConfig bypassed the held source lock: %v", err)
380 default:
381 }
382 if err := os.WriteFile(path, after, 0o600); err != nil {
383 unlock()
384 t.Fatal(err)
385 }
386 unlock()
387 if err := <-done; err != nil {
388 t.Fatal(err)
389 }
390 if got, err := os.ReadFile(lastKnownGoodConfigPath()); err != nil || string(got) != string(after) {
391 t.Fatalf("last-known-good = %q, %v; want post-lock bytes %q", got, err, after)
392 }
393 }
394
395 func TestPruneConfigSnapshotPreservesContentChangedAfterMetadataMove(t *testing.T) {
396 t.Setenv("REASONIX_HOME", t.TempDir())
397 snapshots := recordConfigSnapshotSeries(t, configSnapshotRetention)
398 oldest := snapshots[len(snapshots)-1]
399 tampered := []byte("default_model = \"concurrent\"\n")
400 originalHook := configSnapshotPruneAfterMove
401 configSnapshotPruneAfterMove = func(kind, original, _ string) {
402 if kind == "metadata" && original == oldest.Path+".json" {
403 if err := os.WriteFile(oldest.Path, tampered, 0o600); err != nil {
404 t.Fatal(err)
405 }
406 }
407 }
408 t.Cleanup(func() { configSnapshotPruneAfterMove = originalHook })
409
410 next := []byte("default_model = \"next\"\n")
411 err := recordConfigSnapshot(
412 config.UserConfigPath(),
413 next,
414 "v1",
415 time.Date(2026, time.July, 29, 0, 0, configSnapshotRetention, 0, time.UTC),
416 )
417 if err == nil || !strings.Contains(err.Error(), "content changed during prune") {
418 t.Fatalf("prune error = %v, want content-drift rejection", err)
419 }
420 if got, readErr := os.ReadFile(oldest.Path); readErr != nil || string(got) != string(tampered) {
421 t.Fatalf("concurrent snapshot content = %q, %v; want preserved", got, readErr)
422 }
423 if _, statErr := os.Stat(oldest.Path + ".json"); statErr != nil {
424 t.Fatalf("snapshot metadata was not restored: %v", statErr)
425 }
426 }
427
428 func TestPruneConfigSnapshotPreservesRecreatedMetadata(t *testing.T) {
429 t.Setenv("REASONIX_HOME", t.TempDir())
430 snapshots := recordConfigSnapshotSeries(t, configSnapshotRetention)
431 oldest := snapshots[len(snapshots)-1]
432 metaPath := oldest.Path + ".json"
433 metadata, err := os.ReadFile(metaPath)
434 if err != nil {
435 t.Fatal(err)
436 }
437 originalHook := configSnapshotPruneAfterMove
438 configSnapshotPruneAfterMove = func(kind, original, _ string) {
439 if kind == "metadata" && original == metaPath {
440 if err := os.WriteFile(metaPath, metadata, 0o600); err != nil {
441 t.Fatal(err)
442 }
443 }
444 }
445 t.Cleanup(func() { configSnapshotPruneAfterMove = originalHook })
446
447 next := []byte("default_model = \"next\"\n")
448 err = recordConfigSnapshot(
449 config.UserConfigPath(),
450 next,
451 "v1",
452 time.Date(2026, time.July, 29, 0, 0, configSnapshotRetention, 0, time.UTC),
453 )
454 if err == nil || !strings.Contains(err.Error(), "metadata was recreated during prune") {
455 t.Fatalf("prune error = %v, want metadata-recreation rejection", err)
456 }
457 if got, readErr := os.ReadFile(metaPath); readErr != nil || string(got) != string(metadata) {
458 t.Fatalf("recreated metadata = %q, %v; want preserved", got, readErr)
459 }
460 if _, statErr := os.Stat(oldest.Path); statErr != nil {
461 t.Fatalf("snapshot content was removed after metadata recreation: %v", statErr)
462 }
463 cleanups, globErr := filepath.Glob(metaPath + ".reasonix-cleanup-*")
464 if globErr != nil || len(cleanups) != 1 {
465 t.Fatalf("displaced metadata cleanup = %v, %v", cleanups, globErr)
466 }
467 }
468
469 func recordConfigSnapshotSeries(t *testing.T, count int) []ConfigSnapshot {
470 t.Helper()
471 for i := 0; i < count; i++ {
472 content := []byte("default_model = \"model-" + string(rune('a'+i)) + "\"\n")
473 now := time.Date(2026, time.July, 29, 0, 0, i, 0, time.UTC)
474 if err := recordConfigSnapshot(config.UserConfigPath(), content, "v1", now); err != nil {
475 t.Fatal(err)
476 }
477 }
478 snapshots, err := ListConfigSnapshots()
479 if err != nil {
480 t.Fatal(err)
481 }
482 if len(snapshots) != count {
483 t.Fatalf("snapshots = %d, want %d", len(snapshots), count)
484 }
485 return snapshots
486 }
487
488 func TestRestoreConfigSnapshotUsesTheBytesItVerified(t *testing.T) {
489 home := t.TempDir()
490 t.Setenv("REASONIX_HOME", home)
491 dest := config.UserConfigPath()
492 confirmed := []byte("default_model = \"confirmed\"\n")
493 if err := os.WriteFile(dest, confirmed, 0o600); err != nil {
494 t.Fatal(err)
495 }
496 if err := RecordHealthyConfig("v1"); err != nil {
497 t.Fatal(err)
498 }
499 snapshots, err := ListConfigSnapshots()
500 if err != nil || len(snapshots) != 1 {
501 t.Fatalf("snapshots = %+v, %v", snapshots, err)
502 }
503 if err := os.WriteFile(dest, []byte("default_model = \"current\"\n"), 0o600); err != nil {
504 t.Fatal(err)
505 }
506
507 originalRename := snapshotRename
508 mutated := false
509 snapshotRename = func(oldpath, newpath string) error {
510 if oldpath == dest && !mutated {
511 mutated = true
512 if err := os.WriteFile(snapshots[0].Path, []byte("default_model = \"changed-after-verify\"\n"), 0o600); err != nil {
513 return err
514 }
515 }
516 return os.Rename(oldpath, newpath)
517 }
518 t.Cleanup(func() { snapshotRename = originalRename })
519
520 if _, err := RestoreConfigSnapshot(snapshots[0].ID); err != nil {
521 t.Fatal(err)
522 }
523 got, err := os.ReadFile(dest)
524 if err != nil || string(got) != string(confirmed) {
525 t.Fatalf("restored config = %q (%v), want verified bytes %q", got, err, confirmed)
526 }
527 }
528
529 func TestRestoreConfigSnapshotDirectRejectsTargetDriftBeforeRename(t *testing.T) {
530 t.Setenv("REASONIX_HOME", t.TempDir())
531 dest := config.UserConfigPath()
532 if err := os.WriteFile(dest, []byte("default_model = \"snapshot\"\n"), 0o600); err != nil {
533 t.Fatal(err)
534 }
535 if err := RecordHealthyConfig("v1"); err != nil {
536 t.Fatal(err)
537 }
538 snapshots, err := ListConfigSnapshots()
539 if err != nil || len(snapshots) != 1 {
540 t.Fatalf("snapshots = %+v, %v", snapshots, err)
541 }
542 if err := os.WriteFile(dest, []byte("default_model = \"current\"\n"), 0o600); err != nil {
543 t.Fatal(err)
544 }
545 originalHook := repairMutationBeforeRename
546 var writeErr error
547 repairMutationBeforeRename = func(path string) {
548 if path == dest {
549 writeErr = os.WriteFile(path, []byte("default_model = \"concurrent\"\n"), 0o600)
550 }
551 }
552 t.Cleanup(func() { repairMutationBeforeRename = originalHook })
553
554 if _, err := RestoreConfigSnapshot(snapshots[0].ID); err == nil ||
555 !strings.Contains(err.Error(), "preview changed since confirmation") {
556 t.Fatalf("direct snapshot restore after drift = %v", err)
557 }
558 if writeErr != nil {
559 t.Fatalf("inject direct snapshot drift: %v", writeErr)
560 }
561 if got, err := os.ReadFile(dest); err != nil ||
562 string(got) != "default_model = \"concurrent\"\n" {
563 t.Fatalf("concurrent config = %q, %v", got, err)
564 }
565 }
566
567 func TestRestoreConfigSnapshotDirectRejectsDriftWhileWaitingForTransactionLock(t *testing.T) {
568 t.Setenv("REASONIX_HOME", t.TempDir())
569 dest := config.UserConfigPath()
570 if err := os.WriteFile(dest, []byte("default_model = \"snapshot\"\n"), 0o600); err != nil {
571 t.Fatal(err)
572 }
573 if err := RecordHealthyConfig("v1"); err != nil {
574 t.Fatal(err)
575 }
576 snapshots, err := ListConfigSnapshots()
577 if err != nil || len(snapshots) != 1 {
578 t.Fatalf("snapshots = %+v, %v", snapshots, err)
579 }
580 if err := os.WriteFile(dest, []byte("default_model = \"current\"\n"), 0o600); err != nil {
581 t.Fatal(err)
582 }
583 transactionKey := repairMutationTestKey(repairTransactionPath())
584 originalHook := repairMutationBeforeLock
585 var writeErr error
586 changed := false
587 repairMutationBeforeLock = func(paths []string) {
588 if changed || len(paths) != 1 || paths[0] != transactionKey {
589 return
590 }
591 changed = true
592 writeErr = os.WriteFile(dest, []byte("default_model = \"changed-while-waiting\"\n"), 0o600)
593 }
594 t.Cleanup(func() { repairMutationBeforeLock = originalHook })
595
596 if _, err := RestoreConfigSnapshot(snapshots[0].ID); err == nil ||
597 !strings.Contains(err.Error(), "preview changed since confirmation") {
598 t.Fatalf("direct snapshot restore after lock-wait drift = %v", err)
599 }
600 if writeErr != nil {
601 t.Fatalf("inject direct snapshot drift: %v", writeErr)
602 }
603 if got, err := os.ReadFile(dest); err != nil ||
604 string(got) != "default_model = \"changed-while-waiting\"\n" {
605 t.Fatalf("drifted config = %q, %v", got, err)
606 }
607 }
608
609 func TestRestoreConfigSnapshotPreservesConcurrentRecreate(t *testing.T) {
610 home := t.TempDir()
611 t.Setenv("REASONIX_HOME", home)
612 dest := config.UserConfigPath()
613 original := []byte("default_model = \"snapshot\"\n")
614 if err := os.WriteFile(dest, original, 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, %v", snapshots, err)
623 }
624 current := []byte("default_model = \"current\"\n")
625 if err := os.WriteFile(dest, current, 0o600); err != nil {
626 t.Fatal(err)
627 }
628
629 concurrent := []byte("default_model = \"concurrent\"\n")
630 originalHook := repairMutationAfterRename
631 repairMutationAfterRename = func(path string) {
632 if path == dest {
633 if err := os.WriteFile(dest, concurrent, 0o600); err != nil {
634 t.Fatal(err)
635 }
636 }
637 }
638 t.Cleanup(func() { repairMutationAfterRename = originalHook })
639
640 if _, err := RestoreConfigSnapshot(snapshots[0].ID); err == nil {
641 t.Fatal("snapshot restore accepted a target recreated after displacement")
642 }
643 if got, err := os.ReadFile(dest); err != nil || string(got) != string(concurrent) {
644 t.Fatalf("concurrent config was overwritten: %q, %v", got, err)
645 }
646 last, err := ReadLastRepair()
647 if err != nil || len(last.Changes) != 1 {
648 t.Fatalf("displaced original was not recorded for undo: %+v, %v", last, err)
649 }
650 if got, err := os.ReadFile(last.Changes[0].PreviousPath); err != nil || string(got) != string(current) {
651 t.Fatalf("displaced config = %q, %v; want %q", got, err, current)
652 }
653 }
654
655 func TestRestoreConfigSnapshotCrossDeviceFallbackPreservesConcurrentRecreate(t *testing.T) {
656 t.Setenv("REASONIX_HOME", t.TempDir())
657 dest := config.UserConfigPath()
658 if err := os.WriteFile(dest, []byte("default_model = \"snapshot\"\n"), 0o600); err != nil {
659 t.Fatal(err)
660 }
661 if err := RecordHealthyConfig("v1"); err != nil {
662 t.Fatal(err)
663 }
664 snapshots, err := ListConfigSnapshots()
665 if err != nil || len(snapshots) != 1 {
666 t.Fatalf("snapshots = %+v, %v", snapshots, err)
667 }
668 current := []byte("default_model = \"current\"\n")
669 if err := os.WriteFile(dest, current, 0o600); err != nil {
670 t.Fatal(err)
671 }
672
673 originalRename := snapshotRename
674 snapshotRename = func(oldpath, newpath string) error {
675 if filepath.Dir(oldpath) != filepath.Dir(newpath) {
676 return errors.New("injected cross-device rename")
677 }
678 return renameRepairNodeNoReplace(oldpath, newpath)
679 }
680 t.Cleanup(func() { snapshotRename = originalRename })
681 concurrent := []byte("default_model = \"concurrent\"\n")
682 originalHook := repairMutationAfterRename
683 repairMutationAfterRename = func(path string) {
684 if path == dest {
685 if err := os.WriteFile(dest, concurrent, 0o600); err != nil {
686 t.Fatal(err)
687 }
688 }
689 }
690 t.Cleanup(func() { repairMutationAfterRename = originalHook })
691
692 if _, err := RestoreConfigSnapshot(snapshots[0].ID); err == nil {
693 t.Fatal("cross-device fallback accepted a target recreated after displacement")
694 }
695 if got, err := os.ReadFile(dest); err != nil || string(got) != string(concurrent) {
696 t.Fatalf("concurrent config was overwritten: %q, %v", got, err)
697 }
698 last, err := ReadLastRepair()
699 if err != nil || len(last.Changes) != 1 {
700 t.Fatalf("last repair = %+v, %v", last, err)
701 }
702 if filepath.Dir(last.Changes[0].PreviousPath) != filepath.Dir(dest) {
703 t.Fatalf("fallback backup = %q, want sibling of %q", last.Changes[0].PreviousPath, dest)
704 }
705 if got, err := os.ReadFile(last.Changes[0].PreviousPath); err != nil || string(got) != string(current) {
706 t.Fatalf("fallback backup = %q, %v", got, err)
707 }
708 }
709
710 func TestRestoreConfigSnapshotDoesNotPublishWhenUndoPersistenceFails(t *testing.T) {
711 t.Setenv("REASONIX_HOME", t.TempDir())
712 dest := config.UserConfigPath()
713 snapshot := []byte("default_model = \"snapshot\"\n")
714 if err := os.WriteFile(dest, snapshot, 0o600); err != nil {
715 t.Fatal(err)
716 }
717 if err := RecordHealthyConfig("v1"); err != nil {
718 t.Fatal(err)
719 }
720 snapshots, err := ListConfigSnapshots()
721 if err != nil || len(snapshots) != 1 {
722 t.Fatalf("snapshots = %+v, %v", snapshots, err)
723 }
724 if err := os.Remove(dest); err != nil {
725 t.Fatal(err)
726 }
727 if err := os.MkdirAll(repairTransactionPath(), 0o700); err != nil {
728 t.Fatal(err)
729 }
730
731 if _, err := RestoreConfigSnapshot(snapshots[0].ID); err == nil {
732 t.Fatal("restore succeeded without durable undo intent")
733 }
734 if _, err := os.Lstat(dest); !os.IsNotExist(err) {
735 t.Fatalf("config was published without durable undo intent: %v", err)
736 }
737 }
738
739 func TestUndoRepairRestoresQuarantinedConfig(t *testing.T) {
740 home := t.TempDir()
741 t.Setenv("REASONIX_HOME", home)
742 path := filepath.Join(home, "config.toml")
743 bad := []byte("[broken\n")
744 if err := os.WriteFile(path, bad, 0o600); err != nil {
745 t.Fatal(err)
746 }
747 if _, err := InspectAndRepairConfig(ConfigOptions{Root: t.TempDir(), Apply: true}); err != nil {
748 t.Fatal(err)
749 }
750 if _, err := UndoLastRepair(); err != nil {
751 t.Fatal(err)
752 }
753 got, err := os.ReadFile(path)
754 if err != nil {
755 t.Fatal(err)
756 }
757 if string(got) != string(bad) {
758 t.Fatalf("undone config = %q", got)
759 }
760 }
761
762 func TestUndoRepairPreservesConcurrentRecreate(t *testing.T) {
763 home := t.TempDir()
764 t.Setenv("REASONIX_HOME", home)
765 path := config.UserConfigPath()
766 original := []byte("[broken\n")
767 if err := os.WriteFile(path, original, 0o600); err != nil {
768 t.Fatal(err)
769 }
770 if _, err := InspectAndRepairConfig(ConfigOptions{Root: t.TempDir(), Apply: true}); err != nil {
771 t.Fatal(err)
772 }
773 repaired := []byte("default_model = \"repaired\"\n")
774 if err := os.WriteFile(path, repaired, 0o600); err != nil {
775 t.Fatal(err)
776 }
777
778 concurrent := []byte("default_model = \"concurrent\"\n")
779 originalHook := repairMutationAfterRename
780 repairMutationAfterRename = func(target string) {
781 if target == path {
782 if err := os.WriteFile(path, concurrent, 0o600); err != nil {
783 t.Fatal(err)
784 }
785 }
786 }
787 t.Cleanup(func() { repairMutationAfterRename = originalHook })
788
789 if _, err := UndoLastRepair(); err == nil {
790 t.Fatal("undo accepted a target recreated after retaining redo")
791 }
792 if got, err := os.ReadFile(path); err != nil || string(got) != string(concurrent) {
793 t.Fatalf("concurrent config was overwritten: %q, %v", got, err)
794 }
795 redos, err := filepath.Glob(path + ".reasonix-redo-*")
796 if err != nil || len(redos) != 1 {
797 t.Fatalf("redo candidates = %v, %v", redos, err)
798 }
799 if got, err := os.ReadFile(redos[0]); err != nil || string(got) != string(repaired) {
800 t.Fatalf("retained repaired config = %q, %v; want %q", got, err, repaired)
801 }
802 last, err := ReadLastRepair()
803 if err != nil || last.Undone || last.Changes[0].Undone {
804 t.Fatalf("failed undo transaction = %+v, %v", last, err)
805 }
806 if got, err := os.ReadFile(last.Changes[0].PreviousPath); err != nil || string(got) != string(original) {
807 t.Fatalf("undo source = %q, %v; want %q", got, err, original)
808 }
809 }
810
811 func TestUndoRepairRejectsQuarantineDrift(t *testing.T) {
812 t.Setenv("REASONIX_HOME", t.TempDir())
813 path := config.UserConfigPath()
814 original := []byte("[broken\n")
815 if err := os.WriteFile(path, original, 0o600); err != nil {
816 t.Fatal(err)
817 }
818 if _, err := InspectAndRepairConfig(ConfigOptions{Root: t.TempDir(), Apply: true}); err != nil {
819 t.Fatal(err)
820 }
821 last, err := ReadLastRepair()
822 if err != nil || len(last.Changes) != 1 || last.Changes[0].PreviousStateID == "" {
823 t.Fatalf("last repair lacks previous-state identity: %+v, %v", last, err)
824 }
825 repaired := []byte("default_model = \"repaired\"\n")
826 if err := os.WriteFile(path, repaired, 0o600); err != nil {
827 t.Fatal(err)
828 }
829 if err := os.WriteFile(last.Changes[0].PreviousPath, []byte("tampered"), 0o600); err != nil {
830 t.Fatal(err)
831 }
832
833 if _, err := UndoLastRepair(); err == nil || !strings.Contains(err.Error(), "previous state changed") {
834 t.Fatalf("undo error = %v, want quarantine-drift rejection", err)
835 }
836 if got, err := os.ReadFile(path); err != nil || string(got) != string(repaired) {
837 t.Fatalf("undo changed repaired config after quarantine drift: %q, %v", got, err)
838 }
839 if got, err := os.ReadFile(last.Changes[0].PreviousPath); err != nil || string(got) != "tampered" {
840 t.Fatalf("undo changed drifted quarantine: %q, %v", got, err)
841 }
842 }
843
844 func TestConfigRepairCommitsWhenAuditLogFails(t *testing.T) {
845 home := t.TempDir()
846 t.Setenv("REASONIX_HOME", home)
847 path := config.UserConfigPath()
848 bad := []byte("[broken\n")
849 if err := os.WriteFile(path, bad, 0o600); err != nil {
850 t.Fatal(err)
851 }
852 if err := os.MkdirAll(repairLogPath(), 0o700); err != nil {
853 t.Fatal(err)
854 }
855
856 if _, err := InspectAndRepairConfig(ConfigOptions{Root: t.TempDir(), Apply: true}); err != nil {
857 t.Fatalf("repair must commit despite a failing audit log: %v", err)
858 }
859 if _, err := UndoLastRepair(); err != nil {
860 t.Fatalf("undo after audit-log failure: %v", err)
861 }
862 if got, err := os.ReadFile(path); err != nil || string(got) != string(bad) {
863 t.Fatalf("undone config = %q (%v), want %q", got, err, bad)
864 }
865 }
866
867 func TestUndoRejectsTamperedRepairTarget(t *testing.T) {
868 home := t.TempDir()
869 t.Setenv("REASONIX_HOME", home)
870 previous := filepath.Join(home, "unrelated.previous")
871 if err := os.WriteFile(previous, []byte("x"), 0o600); err != nil {
872 t.Fatal(err)
873 }
874 tx := newRepairTransaction(time.Now())
875 tx.Changes = append(tx.Changes, RepairChange{Scope: "global", TargetPath: filepath.Join(t.TempDir(), "arbitrary.txt"), PreviousPath: previous})
876 if err := persistRepairTransaction(tx); err != nil {
877 t.Fatal(err)
878 }
879 if _, err := UndoLastRepair(); err == nil {
880 t.Fatal("tampered repair transaction was accepted")
881 }
882 }
883
884 func TestSnapshotUndoAcrossSeparateStateHome(t *testing.T) {
885 home := t.TempDir()
886 stateHome := t.TempDir()
887 t.Setenv("REASONIX_HOME", home)
888 t.Setenv("REASONIX_STATE_HOME", stateHome)
889 path := filepath.Join(home, "config.toml")
890 if err := os.WriteFile(path, []byte("default_model = \"before\"\n"), 0o600); err != nil {
891 t.Fatal(err)
892 }
893 if err := RecordHealthyConfig("v1"); err != nil {
894 t.Fatal(err)
895 }
896 snapshots, err := ListConfigSnapshots()
897 if err != nil || len(snapshots) != 1 {
898 t.Fatalf("snapshots = %+v, err = %v", snapshots, err)
899 }
900 current := []byte("default_model = \"current\"\n")
901 if err := os.WriteFile(path, current, 0o600); err != nil {
902 t.Fatal(err)
903 }
904 if _, err := RestoreConfigSnapshot(snapshots[0].ID); err != nil {
905 t.Fatal(err)
906 }
907 if _, err := UndoLastRepair(); err != nil {
908 t.Fatal(err)
909 }
910 got, err := os.ReadFile(path)
911 if err != nil {
912 t.Fatal(err)
913 }
914 if string(got) != string(current) {
915 t.Fatalf("undo restored %q, want %q", got, current)
916 }
917 }
918
919 // TestRestoreConfigSnapshotPreservesSymlinkThroughUndo pins the dotfiles
920 // contract: restoring a snapshot over a symlinked config materializes the
921 // snapshot as a plain file (without writing through the link), and undo
922 // brings back the original symlink node itself.
923 func TestRestoreConfigSnapshotPreservesSymlinkThroughUndo(t *testing.T) {
924 home := t.TempDir()
925 t.Setenv("REASONIX_HOME", home)
926 dest := config.UserConfigPath()
927 dotfiles := filepath.Join(t.TempDir(), "dotfiles-config.toml")
928 if err := os.WriteFile(dotfiles, []byte("default_model = \"good\"\n"), 0o600); err != nil {
929 t.Fatal(err)
930 }
931 if err := os.Symlink(dotfiles, dest); err != nil {
932 t.Fatal(err)
933 }
934 if err := RecordHealthyConfig("v1"); err != nil {
935 t.Fatal(err)
936 }
937 snapshots, err := ListConfigSnapshots()
938 if err != nil || len(snapshots) != 1 {
939 t.Fatalf("snapshots = %+v, err = %v", snapshots, err)
940 }
941 if err := os.WriteFile(dotfiles, []byte("default_model = \"drifted\"\n"), 0o600); err != nil {
942 t.Fatal(err)
943 }
944 if _, err := RestoreConfigSnapshot(snapshots[0].ID); err != nil {
945 t.Fatal(err)
946 }
947 info, err := os.Lstat(dest)
948 if err != nil {
949 t.Fatal(err)
950 }
951 if info.Mode()&os.ModeSymlink != 0 {
952 t.Fatal("restore should materialize the snapshot as a plain file")
953 }
954 if got, _ := os.ReadFile(dest); string(got) != "default_model = \"good\"\n" {
955 t.Fatalf("restored config = %q", got)
956 }
957 if got, _ := os.ReadFile(dotfiles); string(got) != "default_model = \"drifted\"\n" {
958 t.Fatalf("restore wrote through the symlink: %q", got)
959 }
960 if _, err := UndoLastRepair(); err != nil {
961 t.Fatal(err)
962 }
963 info, err = os.Lstat(dest)
964 if err != nil {
965 t.Fatal(err)
966 }
967 if info.Mode()&os.ModeSymlink == 0 {
968 t.Fatalf("undo materialized a regular file, want symlink (mode %v)", info.Mode())
969 }
970 if got, err := os.Readlink(dest); err != nil || got != dotfiles {
971 t.Fatalf("restored link target = %q (%v), want %q", got, err, dotfiles)
972 }
973 if got, _ := os.ReadFile(dest); string(got) != "default_model = \"drifted\"\n" {
974 t.Fatalf("config through restored link = %q", got)
975 }
976 }
977
978 // TestRestoreConfigSnapshotCrossDeviceCleanupKeepsPlainConfig pins the
979 // fail-safe contract when the state dir sits on another filesystem: the
980 // fallback atomically moves the original node to a sibling backup, and a
981 // transaction-save failure restores it without overwriting another writer.
982 func TestRestoreConfigSnapshotCrossDeviceCleanupKeepsPlainConfig(t *testing.T) {
983 home := t.TempDir()
984 t.Setenv("REASONIX_HOME", home)
985 dest := config.UserConfigPath()
986 original := []byte("default_model = \"original\"\n")
987 if err := os.WriteFile(dest, original, 0o600); err != nil {
988 t.Fatal(err)
989 }
990 if err := RecordHealthyConfig("v1"); err != nil {
991 t.Fatal(err)
992 }
993 snapshots, err := ListConfigSnapshots()
994 if err != nil || len(snapshots) != 1 {
995 t.Fatalf("snapshots = %+v, err = %v", snapshots, err)
996 }
997
998 originalRename := snapshotRename
999 snapshotRename = func(oldpath, newpath string) error {
1000 if filepath.Dir(oldpath) != filepath.Dir(newpath) {
1001 return errors.New("injected cross-device rename")
1002 }
1003 return os.Rename(oldpath, newpath)
1004 }
1005 t.Cleanup(func() { snapshotRename = originalRename })
1006 // Make saveRepairTransaction fail: its atomic write cannot replace a
1007 // directory squatting on the transaction path.
1008 if err := os.MkdirAll(repairTransactionPath(), 0o700); err != nil {
1009 t.Fatal(err)
1010 }
1011
1012 if _, err := RestoreConfigSnapshot(snapshots[0].ID); err == nil {
1013 t.Fatal("restore with failing transaction save should error")
1014 }
1015 got, err := os.ReadFile(dest)
1016 if err != nil {
1017 t.Fatalf("config.toml must survive the failed restore: %v", err)
1018 }
1019 if string(got) != string(original) {
1020 t.Fatalf("config after cleanup = %q, want %q", got, original)
1021 }
1022 }
1023
1024 // TestRestoreConfigSnapshotCrossDeviceCleanupRestoresSymlink is the symlink
1025 // variant: the sibling backup keeps the original link node, and failure
1026 // cleanup must put that link back without writing through it.
1027 func TestRestoreConfigSnapshotCrossDeviceCleanupRestoresSymlink(t *testing.T) {
1028 home := t.TempDir()
1029 t.Setenv("REASONIX_HOME", home)
1030 dest := config.UserConfigPath()
1031 dotfiles := filepath.Join(t.TempDir(), "dotfiles-config.toml")
1032 if err := os.WriteFile(dotfiles, []byte("default_model = \"linked\"\n"), 0o600); err != nil {
1033 t.Fatal(err)
1034 }
1035 if err := os.Symlink(dotfiles, dest); err != nil {
1036 t.Fatal(err)
1037 }
1038 if err := RecordHealthyConfig("v1"); err != nil {
1039 t.Fatal(err)
1040 }
1041 snapshots, err := ListConfigSnapshots()
1042 if err != nil || len(snapshots) != 1 {
1043 t.Fatalf("snapshots = %+v, err = %v", snapshots, err)
1044 }
1045
1046 originalRename := snapshotRename
1047 snapshotRename = func(oldpath, newpath string) error {
1048 if filepath.Dir(oldpath) != filepath.Dir(newpath) {
1049 return errors.New("injected cross-device rename")
1050 }
1051 return os.Rename(oldpath, newpath)
1052 }
1053 t.Cleanup(func() { snapshotRename = originalRename })
1054 if err := os.MkdirAll(repairTransactionPath(), 0o700); err != nil {
1055 t.Fatal(err)
1056 }
1057
1058 if _, err := RestoreConfigSnapshot(snapshots[0].ID); err == nil {
1059 t.Fatal("restore with failing transaction save should error")
1060 }
1061 info, err := os.Lstat(dest)
1062 if err != nil {
1063 t.Fatalf("config.toml must survive the failed restore: %v", err)
1064 }
1065 if info.Mode()&os.ModeSymlink == 0 {
1066 t.Fatalf("cleanup materialized a regular file, want symlink (mode %v)", info.Mode())
1067 }
1068 if got, err := os.Readlink(dest); err != nil || got != dotfiles {
1069 t.Fatalf("restored link target = %q (%v), want %q", got, err, dotfiles)
1070 }
1071 if got, _ := os.ReadFile(dotfiles); string(got) != "default_model = \"linked\"\n" {
1072 t.Fatalf("dotfiles content = %q, must be untouched", got)
1073 }
1074 }
1075
1076 // TestRestoreConfigSnapshotCommitsWhenAuditLogFails pins the commit boundary:
1077 // last-repair.json is the durable undo state; the append-only audit log is
1078 // best-effort. A failing audit append must not roll the restore back — that
1079 // would consume the backup the just-persisted transaction points to, wedging
1080 // every later UndoLastRepair — and undo itself must still succeed while the
1081 // log stays unwritable.
1082 func TestRestoreConfigSnapshotCommitsWhenAuditLogFails(t *testing.T) {
1083 home := t.TempDir()
1084 t.Setenv("REASONIX_HOME", home)
1085 dest := config.UserConfigPath()
1086 original := []byte("default_model = \"original\"\n")
1087 if err := os.WriteFile(dest, original, 0o600); err != nil {
1088 t.Fatal(err)
1089 }
1090 if err := RecordHealthyConfig("v1"); err != nil {
1091 t.Fatal(err)
1092 }
1093 snapshots, err := ListConfigSnapshots()
1094 if err != nil || len(snapshots) != 1 {
1095 t.Fatalf("snapshots = %+v, err = %v", snapshots, err)
1096 }
1097 current := []byte("default_model = \"current\"\n")
1098 if err := os.WriteFile(dest, current, 0o600); err != nil {
1099 t.Fatal(err)
1100 }
1101 // Wedge the append-only audit log: a directory squatting on its path makes
1102 // appendRepairLog fail while last-repair.json still persists fine.
1103 if err := os.MkdirAll(repairLogPath(), 0o700); err != nil {
1104 t.Fatal(err)
1105 }
1106
1107 if _, err := RestoreConfigSnapshot(snapshots[0].ID); err != nil {
1108 t.Fatalf("restore must commit despite a failing audit log: %v", err)
1109 }
1110 if got, _ := os.ReadFile(dest); string(got) != string(original) {
1111 t.Fatalf("restored config = %q, want %q", got, original)
1112 }
1113 if _, err := UndoLastRepair(); err != nil {
1114 t.Fatalf("undo after audit-log failure: %v", err)
1115 }
1116 if got, _ := os.ReadFile(dest); string(got) != string(current) {
1117 t.Fatalf("undone config = %q, want %q", got, current)
1118 }
1119 }
1120
1120 lines GO