package notes import ( "os" "path/filepath" "strings" "testing" ) // newBrain lays out a tree shaped like the live one: a git dir, note dirs, // and the non-note areas that must never be reachable. func newBrain(t *testing.T) string { t.Helper() dir := t.TempDir() for _, d := range []string{ ".git/hooks", "knowledge", "wiki/homelab/facts", "archive/claude-sessions/koala", "sessions", "raw", ".obsidian", } { if err := os.MkdirAll(filepath.Join(dir, d), 0o755); err != nil { t.Fatal(err) } } write := func(p, s string) { t.Helper() if err := os.WriteFile(filepath.Join(dir, p), []byte(s), 0o644); err != nil { t.Fatal(err) } } write("CLAUDE.md", "# instructions\n") write("log.md", "ignored scratch\n") write("knowledge/note.md", "# note\n") write("wiki/homelab/facts/f.md", "# fact\n") write("wiki/homelab/_index.md", "# index\n") write("archive/claude-sessions/koala/session-1.md", "verbatim transcript\n") write("sessions/s.jsonl", "{}\n") write(".git/hooks/post-commit", "#!/bin/sh\n") return dir } func TestResolve_AcceptsNotePaths(t *testing.T) { dir := newBrain(t) for _, p := range []string{"knowledge/note.md", "wiki/homelab/facts/f.md"} { got, err := Resolve(dir, p, ScopeNotes) if err != nil { t.Errorf("Resolve(%q): %v", p, err) continue } if !strings.HasPrefix(got.Abs, dir) { t.Errorf("Resolve(%q).Abs = %q, want inside %q", p, got.Abs, dir) } } } func TestResolve_RefusesEscapesAndNonNoteAreas(t *testing.T) { dir := newBrain(t) // Canonicalisation must happen BEFORE the allowlist check. A prefix test // on the raw argument accepts every one of these: "knowledge/..%2f" style // inputs collapse to .git, and .git/hooks/post-commit is executed by // brain-sync inside a pod holding the Gitea push credentials. refuse := []string{ "knowledge/../.git/hooks/post-commit", "wiki/../.git/config", "./knowledge/../.git/config", "knowledge//../.git/config", "../../etc/passwd", "/etc/passwd", ".git/config", "archive/claude-sessions/koala/session-1.md", // verbatim transcripts "sessions/s.jsonl", "raw/x.md", ".obsidian/workspace.json", "log.md", // gitignored: an edit here would never sync (brain#2) "wiki/homelab/_index.md", // generated; brain-sync resolves it in origin's favour "knowledge/note.txt", // not markdown "CLAUDE.md", // root file — not reachable in the notes scope } for _, p := range refuse { if _, err := Resolve(dir, p, ScopeNotes); err == nil { t.Errorf("Resolve(%q) = nil error, want refusal", p) } } } func TestResolve_RootScopeAdmitsOnlyNamedFiles(t *testing.T) { dir := newBrain(t) if _, err := Resolve(dir, "CLAUDE.md", ScopeRoot); err != nil { t.Errorf("Resolve(CLAUDE.md, ScopeRoot): %v", err) } // log.md is the reason this is an exact-name list and not "root *.md": // it is gitignored, so an edit would apply locally and never reach gitea. for _, p := range []string{"log.md", "AGENTS.md", "knowledge/note.md", ".git/config"} { if _, err := Resolve(dir, p, ScopeRoot); err == nil { t.Errorf("Resolve(%q, ScopeRoot) = nil error, want refusal", p) } } } func TestResolve_RefusesSymlinkedTargets(t *testing.T) { dir := newBrain(t) outside := filepath.Join(t.TempDir(), "secret.md") if err := os.WriteFile(outside, []byte("secret\n"), 0o644); err != nil { t.Fatal(err) } // The tree is Syncthing-managed, so a symlink can appear without anyone // adding one deliberately. Containment must survive it. if err := os.Symlink(outside, filepath.Join(dir, "knowledge/link.md")); err != nil { t.Skipf("symlinks unavailable: %v", err) } if _, err := Resolve(dir, "knowledge/link.md", ScopeNotes); err == nil { t.Error("Resolve followed a symlink out of the brain dir") } if err := os.Symlink(filepath.Dir(outside), filepath.Join(dir, "wiki/escape")); err != nil { t.Skipf("symlinks unavailable: %v", err) } if _, err := Resolve(dir, "wiki/escape/secret.md", ScopeNotes); err == nil { t.Error("Resolve followed a symlinked directory out of the brain dir") } } func TestResolve_RefusesWhenTheTreeIsNotTheSyncSource(t *testing.T) { // brain#2 was "wrote to a directory the sync does not commit from". No // code review prevents that recurring — a wrong INGEST_BRAIN_DIR in a // future manifest reintroduces it and every unit test still passes. So // assert the property at runtime, the way brain-sync itself does. dir := t.TempDir() if err := os.MkdirAll(filepath.Join(dir, "knowledge"), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, "knowledge/note.md"), []byte("x"), 0o644); err != nil { t.Fatal(err) } // Reads are fine anywhere; the guard belongs to writes. if _, err := Resolve(dir, "knowledge/note.md", ScopeNotes); err != nil { t.Fatalf("Resolve should not require .git for a read: %v", err) } _, err := Edit(dir, "knowledge/note.md", "x", "y", ScopeNotes) if err == nil { t.Fatal("Edit accepted a tree with no .git — the write would never be committed") } if !strings.Contains(err.Error(), "sync source") { t.Errorf("error should name the cause, got %v", err) } }