package api_test import ( "os" "path/filepath" "sync" "sync/atomic" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/mathiasbq/hyperguild/ingestion/internal/api" ) // brain_update passed the caller's path through resolveWithin, a lexical // containment check only: any file in the brain could be superseded // (classification.yaml, raw/, sessions/), and a final symlink was written // through. It now resolves like brain_edit: knowledge/ and wiki/ notes only, // read and rewritten through the brain root. func TestUpdateNoteRefusesPathsOutsideKnowledgeAndWikiNotes(t *testing.T) { brain := t.TempDir() files := map[string]string{ "classification.yaml": "levels: {}\n", "raw/r.md": "raw\n", "sessions/s.jsonl": "{}\n", "wiki/w/facts/ok.md": "---\ntitle: ok\n---\nold\n", "knowledge/k.md": "---\ntitle: k\n---\nold\n", "wiki/w/facts/x.txt": "txt\n", } for rel, body := range files { p := filepath.Join(brain, filepath.FromSlash(rel)) require.NoError(t, os.MkdirAll(filepath.Dir(p), 0o755)) require.NoError(t, os.WriteFile(p, []byte(body), 0o644)) } for _, rel := range []string{"classification.yaml", "raw/r.md", "sessions/s.jsonl", "wiki/w/facts/x.txt", "wiki/w/../../raw/r.md"} { _, _, _, err := api.UpdateNote(brain, api.UpdateNoteOptions{Path: rel, Content: "replaced"}) assert.Error(t, err, rel) } for rel, body := range files { if rel == "wiki/w/facts/ok.md" || rel == "knowledge/k.md" { continue } got, err := os.ReadFile(filepath.Join(brain, filepath.FromSlash(rel))) require.NoError(t, err) assert.Equal(t, body, string(got), rel) } for _, rel := range []string{"wiki/w/facts/ok.md", "knowledge/k.md"} { _, _, _, err := api.UpdateNote(brain, api.UpdateNoteOptions{Path: rel, Content: "new body"}) require.NoError(t, err, "positive control: %s", rel) got, _ := os.ReadFile(filepath.Join(brain, filepath.FromSlash(rel))) assert.Contains(t, string(got), "new body") } } func TestUpdateNoteDoesNotWriteThroughASymlinkedNote(t *testing.T) { brain := t.TempDir() facts := filepath.Join(brain, "wiki", "w", "facts") require.NoError(t, os.MkdirAll(facts, 0o755)) victim := filepath.Join(brain, "raw", "victim.md") require.NoError(t, os.MkdirAll(filepath.Dir(victim), 0o755)) require.NoError(t, os.WriteFile(victim, []byte("untouched\n"), 0o644)) require.NoError(t, os.Symlink(filepath.Join("..", "..", "..", "raw", "victim.md"), filepath.Join(facts, "l.md"))) _, _, _, err := api.UpdateNote(brain, api.UpdateNoteOptions{Path: "wiki/w/facts/l.md", Content: "replaced"}) assert.Error(t, err) got, _ := os.ReadFile(victim) assert.Equal(t, "untouched\n", string(got)) } // The note is swapped between a regular file and an in-brain symlink to a // raw/ file while UpdateNote runs: the read and the write each refuse a // symlink, so the raw/ file is never rewritten. func TestUpdateNoteNeverRewritesASymlinkSwappedInForTheNote(t *testing.T) { brain := t.TempDir() facts := filepath.Join(brain, "wiki", "w", "facts") require.NoError(t, os.MkdirAll(facts, 0o755)) victim := filepath.Join(brain, "raw", "victim.md") require.NoError(t, os.MkdirAll(filepath.Dir(victim), 0o755)) require.NoError(t, os.WriteFile(victim, []byte("untouched\n"), 0o644)) note := filepath.Join(facts, "n.md") body := "---\ntitle: n\n---\nold\n" require.NoError(t, os.WriteFile(note, []byte(body), 0o644)) var stop atomic.Bool var wg sync.WaitGroup wg.Add(1) go func() { defer wg.Done() tmp := note + ".swap" for i := 0; !stop.Load(); i++ { _ = os.Remove(tmp) if i%2 == 0 { _ = os.WriteFile(tmp, []byte(body), 0o644) } else { _ = os.Symlink(filepath.Join("..", "..", "..", "raw", "victim.md"), tmp) } _ = os.Rename(tmp, note) } }() ok := 0 for deadline := time.Now().Add(1500 * time.Millisecond); time.Now().Before(deadline); { if _, _, _, err := api.UpdateNote(brain, api.UpdateNoteOptions{Path: "wiki/w/facts/n.md", Content: "new"}); err == nil { ok++ } } stop.Store(true) wg.Wait() got, _ := os.ReadFile(victim) assert.Equal(t, "untouched\n", string(got)) assert.Positive(t, ok, "positive control: some updates reach the regular note") }