package dream import ( "context" "os" "path/filepath" "testing" "time" ) // stubConsolidator returns a fixed result and records that it was called. type stubConsolidator struct { result ConsolidateResult called bool } func (s *stubConsolidator) Consolidate(context.Context, ConsolidateInput) (ConsolidateResult, error) { s.called = true return s.result, nil } // TestRunEmptyMemoryDir: an empty memory dir yields an all-zero Report with // status ok (no error). Reconcile tolerates the missing scopes. func TestRunEmptyMemoryDir(t *testing.T) { root := t.TempDir() r := &Runner{MemoryRoot: root} rep, err := r.Run(context.Background(), RunOptions{}) if err != nil { t.Fatalf("Run: %v", err) } if rep.FilesBefore != 0 || rep.BytesBefore != 0 || rep.FilesAfter != 0 || rep.BytesAfter != 0 { t.Fatalf("expected all-zero report, got %+v", rep) } if rep.Deduped != 0 || rep.Merged != 0 || rep.Pruned != 0 || rep.PathsCleaned != 0 { t.Fatalf("expected zero counters, got %+v", rep) } // State should be written with status ok for a non-dry-run. st, err := LoadState(root) if err != nil { t.Fatalf("LoadState: %v", err) } if st.LastStatus != "ok" { t.Fatalf("LastStatus = %q, want ok", st.LastStatus) } } // TestRunDryRunWritesNothing: dry-run computes counts, writes no files, does not // update state, but still acquires + releases the lock. func TestRunDryRunWritesNothing(t *testing.T) { root := t.TempDir() // Two byte-identical files → one dedupe candidate. a := writeMemFile(t, root, "global/user/a.md", "same content") b := writeMemFile(t, root, "global/user/b.md", "same content") r := &Runner{MemoryRoot: root} rep, err := r.Run(context.Background(), RunOptions{DryRun: true}) if err != nil { t.Fatalf("Run: %v", err) } if !rep.DryRun { t.Fatal("Report.DryRun = false, want true") } if rep.Deduped != 1 { t.Fatalf("Deduped = %d, want 1 (predicted)", rep.Deduped) } // Both files must still exist — dry-run writes nothing. if _, err := os.Stat(a); err != nil { t.Fatalf("file a removed in dry-run: %v", err) } if _, err := os.Stat(b); err != nil { t.Fatalf("file b removed in dry-run: %v", err) } // State must NOT be updated (never-run remains). st, err := LoadState(root) if err != nil { t.Fatalf("LoadState: %v", err) } if !st.LastRunAt.IsZero() || st.LastStatus != "" { t.Fatalf("dry-run updated state: %+v", st) } // Lock must have been released (a fresh acquire succeeds). lk, err := AcquireLock(root) if err != nil { t.Fatalf("lock not released after dry-run: %v", err) } lk.Release() } // TestRunLockedSkips: when a live lock is already held, Run returns a zero-count // report and NO error (exit-0 "skipped" semantics), and does not touch state. func TestRunLockedSkips(t *testing.T) { root := t.TempDir() writeMemFile(t, root, "global/user/a.md", "content") held, err := AcquireLock(root) if err != nil { t.Fatalf("pre-acquire lock: %v", err) } defer held.Release() r := &Runner{MemoryRoot: root} rep, err := r.Run(context.Background(), RunOptions{}) if err != nil { t.Fatalf("Run under held lock should not error, got %v", err) } if rep.FilesBefore != 0 || rep.Deduped != 0 { t.Fatalf("skipped run should have zero report, got %+v", rep) } // State untouched (never ran). st, _ := LoadState(root) if !st.LastRunAt.IsZero() || st.LastStatus != "" { t.Fatalf("skipped run touched state: %+v", st) } } // TestRunAppliesDedupe: a non-dry-run removes exact duplicates, updates state, // and reflects counts in the Report. func TestRunAppliesDedupe(t *testing.T) { root := t.TempDir() a := writeMemFile(t, root, "global/user/a.md", "same content") b := writeMemFile(t, root, "global/user/b.md", "same content") r := &Runner{MemoryRoot: root} rep, err := r.Run(context.Background(), RunOptions{}) if err != nil { t.Fatalf("Run: %v", err) } if rep.Deduped != 1 { t.Fatalf("Deduped = %d, want 1", rep.Deduped) } // Exactly one of the two duplicates must survive (the sorted-first: a.md). _, errA := os.Stat(a) _, errB := os.Stat(b) if errA != nil { t.Fatalf("representative a.md removed: %v", errA) } if errB == nil { t.Fatal("duplicate b.md should have been removed") } if rep.FilesAfter != 1 { t.Fatalf("FilesAfter = %d, want 1", rep.FilesAfter) } st, _ := LoadState(root) if st.LastStatus != "ok" || st.LastReport == nil { t.Fatalf("state not updated: %+v", st) } } // TestWithinScope: the path-boundary guard accepts in-scope targets and rejects // everything outside /global and the active project's directory. func TestWithinScope(t *testing.T) { root := t.TempDir() projectDir := t.TempDir() pid := projectID(projectDir) otherPID := "0123456789ab" // a different, unrelated project id cases := []struct { name string project string target string want bool }{ {"global file", projectDir, filepath.Join(root, "global", "user", "x.md"), true}, {"active project file", projectDir, filepath.Join(root, "projects", pid, "notes", "y.md"), true}, {"global root itself", projectDir, filepath.Join(root, "global"), true}, {"unrelated project rejected", projectDir, filepath.Join(root, "projects", otherPID, "z.md"), false}, {"any project rejected when global-only", "", filepath.Join(root, "projects", pid, "y.md"), false}, {"global still ok when global-only", "", filepath.Join(root, "global", "x.md"), true}, {"sessions scope rejected", projectDir, filepath.Join(root, "sessions", "s1", "checkpoint.md"), false}, {"outside root rejected", projectDir, filepath.Join(root, "..", "evil.md"), false}, {"sibling prefix not confused", projectDir, filepath.Join(root, "globalX", "z.md"), false}, {"absolute escape rejected", projectDir, "/etc/passwd", false}, {"empty target rejected", projectDir, "", false}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { if got := withinScope(root, tc.project, tc.target); got != tc.want { t.Fatalf("withinScope(%q, %q, %q) = %v, want %v", root, tc.project, tc.target, got, tc.want) } }) } if withinScope("", projectDir, filepath.Join(root, "global", "x.md")) { t.Fatal("empty memoryRoot must reject") } } // TestWithinScopeSymlinkEscape: a symlink planted inside an allowed scope must // not let a target escape memoryRoot. The guard resolves symlinks on existing // ancestors before the containment check (SPEC §7.1 defense-in-depth). func TestWithinScopeSymlinkEscape(t *testing.T) { root := t.TempDir() outside := t.TempDir() // a directory fully outside memoryRoot globalDir := filepath.Join(root, "global") if err := os.MkdirAll(globalDir, 0o755); err != nil { t.Fatalf("mkdir global: %v", err) } // /global/escape -> link := filepath.Join(globalDir, "escape") if err := os.Symlink(outside, link); err != nil { t.Skipf("symlink unsupported: %v", err) } // Lexically this looks in-scope (/global/escape/evil.md) but resolves // to /evil.md, which must be rejected. target := filepath.Join(link, "evil.md") if withinScope(root, "", target) { t.Fatalf("symlink escape target accepted: %q", target) } } // TestRunPathClean: a memory file referencing a non-existent local path has that // reference stripped and counted. func TestRunPathClean(t *testing.T) { root := t.TempDir() proj := t.TempDir() missing := filepath.Join(proj, "does", "not", "exist.go") body := "See `" + missing + "` for details." f := writeMemFile(t, root, "global/reference/r.md", body) r := &Runner{MemoryRoot: root} rep, err := r.Run(context.Background(), RunOptions{ProjectDir: proj}) if err != nil { t.Fatalf("Run: %v", err) } if rep.PathsCleaned != 1 { t.Fatalf("PathsCleaned = %d, want 1", rep.PathsCleaned) } raw, err := os.ReadFile(f) if err != nil { t.Fatalf("read cleaned file: %v", err) } if got := string(raw); got == body { t.Fatalf("body unchanged after path-clean: %q", got) } } // TestRunConsolidatorApplied: an injected Consolidator's new-entry write and // counters flow through, and the write lands within scope. func TestRunConsolidatorApplied(t *testing.T) { root := t.TempDir() writeMemFile(t, root, "global/user/a.md", "hello") newPath := filepath.Join(root, "global", "user", "distilled.md") stub := &stubConsolidator{result: ConsolidateResult{ NewEntries: []NewEntry{{Path: newPath, Body: "distilled fact"}}, Distilled: 1, Merged: 2, Pruned: 3, Notes: []string{"note"}, }} r := &Runner{MemoryRoot: root, Consolidator: stub} rep, err := r.Run(context.Background(), RunOptions{}) if err != nil { t.Fatalf("Run: %v", err) } if !stub.called { t.Fatal("Consolidator was not called") } if rep.Distilled != 1 || rep.Merged != 2 || rep.Pruned != 3 { t.Fatalf("counters not surfaced: %+v", rep) } if _, err := os.Stat(newPath); err != nil { t.Fatalf("new entry not written: %v", err) } } // TestApplyConsolidationRejectsOutOfScope: a Consolidator that tries to write // outside the memory root is rejected by the guard. func TestApplyConsolidationRejectsOutOfScope(t *testing.T) { root := t.TempDir() outside := filepath.Join(t.TempDir(), "escape.md") cres := ConsolidateResult{ NewEntries: []NewEntry{{Path: outside, Body: "x"}}, } if err := applyConsolidation(root, "", cres, nil); err == nil { t.Fatal("expected out-of-scope write to be rejected") } if _, err := os.Stat(outside); err == nil { t.Fatal("out-of-scope file must not be created") } } // TestRunDryRunLeavesStaleLockTakeable is a small guard that dry-run's lock is // released promptly (no leftover live lock). func TestRunDryRunLockReleased(t *testing.T) { root := t.TempDir() r := &Runner{MemoryRoot: root} if _, err := r.Run(context.Background(), RunOptions{DryRun: true}); err != nil { t.Fatalf("Run: %v", err) } // Fresh acquire must succeed immediately (lock released, not merely stale). // Force a long stale window so a leftover *live* lock would block acquisition, // proving Run released it rather than leaving it to be reclaimed as stale. // Restore the package default afterward so this does not leak into other tests. orig := DefaultStaleAfter DefaultStaleAfter = time.Hour defer func() { DefaultStaleAfter = orig }() lk, err := AcquireLock(root) if err != nil { t.Fatalf("lock not released: %v", err) } lk.Release() }