fix(update,session): brain_update and session_log confine caller paths (#169)
Review round 1 on PR #170 found two more handlers with the #169 class of bug (verified by execution): - brain_update resolved its caller path with resolveWithin, a lexical containment check: any brain file could be superseded (classification.yaml, raw/, sessions/), and a final symlink was written through. UpdateNote now resolves like brain_edit (notes.Resolve, ScopeNotes: knowledge/ and wiki/ .md notes), and reads and rewrites through brainfs (ReadRegular, WriteRegular). - session_log joined the caller's session_id onto the sessions dir and opened it O_CREATE|O_APPEND: an id with path segments created or appended a .jsonl file anywhere the pod could write. The id must now be a plain name (^[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$, which every live session file matches), and the open adds O_NOFOLLOW|O_NONBLOCK. Read checks the id too. - Tunnel tests: the .txt case now names an existing file (it failed on "missing" before), the symlink test adds an in-brain target, and the FIFO comment no longer claims the handle check is the only guard. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
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")
|
||||
}
|
||||
Reference in New Issue
Block a user