From b6f7dac46e7e6e421f9e3aab85023000d7d4bb55 Mon Sep 17 00:00:00 2001 From: Mathias Date: Sun, 4 Oct 2026 00:22:47 +0200 Subject: [PATCH 1/2] fix(brain): context, brain_get and brain_read read through brainfs (#157, slice 2) Option 1 of #157, slice 2. readExcerpt (brain_context), api.ReadNote (brain_get / read-after-write) and notes.ReadScoped (brain_read) read the Syncthing-shared brain by path. Under a symlink swap race each leaked an outside file hundreds of thousands of times in 1.5s, including brain_read, whose Lstat check was raced by the reopen-by-name; a FIFO blocked os.ReadFile. - All three read through brainfs (os.Root, regular files only, non-blocking open, type check on the handle). - brainfs.ReadRegularMax checks a size limit on the opened handle; notes keeps its ErrTooLarge. - New internal/brainfs/brainfstest: the shared swap-race harness (symlink and held-writer FIFO swappers, awaited before TempDir cleanup; AssertNoLeak / AssertNoHang) for this and later slices. Tests red first: each reader under a symlink-swap race and a held-writer FIFO race, plus static checks. Flipped: readExcerpt back to os.ReadFile is red. Refs #157 Co-Authored-By: Claude Opus 5.5 --- ingestion/internal/api/readnote_root_test.go | 38 +++++++ ingestion/internal/api/update.go | 11 +- ingestion/internal/brainfs/brainfs.go | 23 +++- ingestion/internal/brainfs/brainfs_test.go | 13 +++ .../brainfs/brainfstest/brainfstest.go | 102 ++++++++++++++++++ .../internal/mcp/readexcerpt_root_test.go | 34 ++++++ ingestion/internal/mcp/tools_context.go | 11 +- ingestion/internal/notes/edit.go | 16 ++- ingestion/internal/notes/read_root_test.go | 31 ++++++ 9 files changed, 268 insertions(+), 11 deletions(-) create mode 100644 ingestion/internal/api/readnote_root_test.go create mode 100644 ingestion/internal/brainfs/brainfstest/brainfstest.go create mode 100644 ingestion/internal/mcp/readexcerpt_root_test.go create mode 100644 ingestion/internal/notes/read_root_test.go diff --git a/ingestion/internal/api/readnote_root_test.go b/ingestion/internal/api/readnote_root_test.go new file mode 100644 index 0000000..0a7353d --- /dev/null +++ b/ingestion/internal/api/readnote_root_test.go @@ -0,0 +1,38 @@ +package api_test + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mathiasbq/hyperguild/ingestion/internal/api" + "github.com/mathiasbq/hyperguild/ingestion/internal/brainfs/brainfstest" +) + +// hyperguild#157 slice 2: ReadNote (brain_get, read-after-write) read by +// path; it now reads through brainfs. +func TestReadNoteIsConfinedToRegularFilesInTheBrain(t *testing.T) { + brain := t.TempDir() + facts := filepath.Join(brain, "wiki", "w", "facts") + require.NoError(t, os.MkdirAll(facts, 0o755)) + require.NoError(t, os.Symlink(brainfstest.Outside(t), filepath.Join(facts, "leak.md"))) + _, body, _, err := api.ReadNote(brain, "wiki/w/facts/leak.md") + assert.Error(t, err) + assert.NotContains(t, body, brainfstest.Sentinel) + + p := filepath.Join(facts, "r.md") + read := func() string { + fm, body, _, _ := api.ReadNote(brain, "wiki/w/facts/r.md") + s := body + for k, v := range fm { + s += k + v + } + return s + } + brainfstest.AssertNoLeak(t, p, "---\nx: y\n---\ninside", 1500*time.Millisecond, read) + brainfstest.AssertNoHang(t, p, "---\nx: y\n---\ninside", 1500*time.Millisecond, 6*time.Second, func() { _ = read() }) +} diff --git a/ingestion/internal/api/update.go b/ingestion/internal/api/update.go index 442a0a8..9301068 100644 --- a/ingestion/internal/api/update.go +++ b/ingestion/internal/api/update.go @@ -4,6 +4,7 @@ import ( "crypto/sha256" "encoding/hex" "fmt" + "github.com/mathiasbq/hyperguild/ingestion/internal/brainfs" "os" "path/filepath" "strings" @@ -174,11 +175,17 @@ func UpdateNote(brainDir string, opts UpdateNoteOptions) (relPath, contentHash, // write primitive behind brain_get: the hash it returns equals the hash // brain_write / brain_update returned for the same bytes. func ReadNote(brainDir, relPath string) (fm map[string]string, body, contentHash string, err error) { - _, abs, err := resolveWithin(brainDir, relPath) + rel, _, err := resolveWithin(brainDir, relPath) if err != nil { return nil, "", "", err } - raw, err := os.ReadFile(abs) + // Through the brain root, regular files only (hyperguild#157). + bfs, err := brainfs.Open(brainDir) + if err != nil { + return nil, "", "", fmt.Errorf("read note: %w", err) + } + defer func() { _ = bfs.Close() }() + raw, err := bfs.ReadRegular(rel) if err != nil { if os.IsNotExist(err) { return nil, "", "", fmt.Errorf("note %q does not exist", relPath) diff --git a/ingestion/internal/brainfs/brainfs.go b/ingestion/internal/brainfs/brainfs.go index c370d7b..a182238 100644 --- a/ingestion/internal/brainfs/brainfs.go +++ b/ingestion/internal/brainfs/brainfs.go @@ -20,6 +20,9 @@ import ( // ErrNotRegular is returned for an entry that is not a regular file. var ErrNotRegular = errors.New("not a regular file") +// ErrTooLarge is returned by ReadRegularMax for a file over the limit. +var ErrTooLarge = errors.New("file too large") + // Brain is an open brain directory. type Brain struct { root *os.Root @@ -74,7 +77,22 @@ func (b *Brain) ReadRegular(rel string) ([]byte, error) { // cannot create one; fs.protected_hardlinks=1 and the pod's secrets are on a // different filesystem from the brain volume, so a local caller cannot link // them in. Refusing Nlink > 1 is the next step if either changes. -func (b *Brain) readOpened(rel string) ([]byte, error) { +func (b *Brain) readOpened(rel string) ([]byte, error) { return b.readOpenedMax(rel, -1) } + +// ReadRegularMax is ReadRegular with a size limit, checked on the opened +// handle before reading (max < 0 means no limit). +func (b *Brain) ReadRegularMax(rel string, max int64) ([]byte, error) { + fi, err := b.root.Lstat(rel) + if err != nil { + return nil, err + } + if !fi.Mode().IsRegular() { + return nil, fmt.Errorf("%s: %w", rel, ErrNotRegular) + } + return b.readOpenedMax(rel, max) +} + +func (b *Brain) readOpenedMax(rel string, max int64) ([]byte, error) { f, err := b.root.OpenFile(rel, os.O_RDONLY|syscall.O_NONBLOCK, 0) if err != nil { return nil, err @@ -87,6 +105,9 @@ func (b *Brain) readOpened(rel string) ([]byte, error) { if !hfi.Mode().IsRegular() { return nil, fmt.Errorf("%s: %w", rel, ErrNotRegular) } + if max >= 0 && hfi.Size() > max { + return nil, fmt.Errorf("%s is %d bytes (limit %d): %w", rel, hfi.Size(), max, ErrTooLarge) + } return io.ReadAll(f) } diff --git a/ingestion/internal/brainfs/brainfs_test.go b/ingestion/internal/brainfs/brainfs_test.go index 4d4b2dc..3c48a00 100644 --- a/ingestion/internal/brainfs/brainfs_test.go +++ b/ingestion/internal/brainfs/brainfs_test.go @@ -90,3 +90,16 @@ func TestWalkRegularYieldsOnlyRegularFiles(t *testing.T) { require.NoError(t, b.WalkRegular(".", func(rel string) error { got = append(got, rel); return nil }, nil)) assert.Equal(t, []string{"a/n.md"}, got) } + +func TestReadRegularMaxRefusesAFileOverTheLimit(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "big.md"), make([]byte, 100), 0o644)) + b, err := Open(dir) + require.NoError(t, err) + defer func() { _ = b.Close() }() + _, err = b.ReadRegularMax("big.md", 99) + assert.True(t, errors.Is(err, ErrTooLarge), "got %v", err) + got, err := b.ReadRegularMax("big.md", 100) + require.NoError(t, err) + assert.Len(t, got, 100) +} diff --git a/ingestion/internal/brainfs/brainfstest/brainfstest.go b/ingestion/internal/brainfs/brainfstest/brainfstest.go new file mode 100644 index 0000000..56f6bc8 --- /dev/null +++ b/ingestion/internal/brainfs/brainfstest/brainfstest.go @@ -0,0 +1,102 @@ +// Package brainfstest is the shared harness for hyperguild#157's behavioural +// confinement tests: it keeps swapping a regular note, a symlink to an +// outside file, or a FIFO (with a writer held open) into a path while a +// reader under test loops. A reader is confined if no sentinel byte ever +// reaches its output and it never hangs. +package brainfstest + +import ( + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "syscall" + "testing" + "time" +) + +// Sentinel marks the outside file's contents. +const Sentinel = "zqbrainfssentinelonefiftyseven" + +// Outside writes a file outside any brain dir containing Sentinel. +func Outside(t *testing.T) string { + t.Helper() + p := filepath.Join(t.TempDir(), "secret.md") + if err := os.WriteFile(p, []byte("# secret\n\n"+Sentinel), 0o644); err != nil { + t.Fatal(err) + } + return p +} + +// StartSwap renames, in a loop, a regular note (content inside) and either +// a symlink to outside or a FIFO into path, until the test ends. FIFOs get a +// writer held open until the swapper exits, so a reader that skips the +// handle check parks forever instead of reading EOF. The swapper is stopped +// and awaited before t.TempDir cleanup. +func StartSwap(t *testing.T, path, inside, outside string, fifo bool) { + t.Helper() + var stop atomic.Bool + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + tmp := path + ".swap" + var writers []*os.File + defer func() { + for _, w := range writers { + _ = w.Close() + } + }() + for i := 0; !stop.Load(); i++ { + _ = os.Remove(tmp) + switch { + case i%2 == 0: + _ = os.WriteFile(tmp, []byte(inside), 0o644) + case fifo: + if err := syscall.Mkfifo(tmp, 0o644); err == nil { + if w, err := os.OpenFile(tmp, os.O_RDWR, 0); err == nil { + writers = append(writers, w) + } + } + default: + _ = os.Symlink(outside, tmp) + } + _ = os.Rename(tmp, path) + } + }() + t.Cleanup(func() { stop.Store(true); wg.Wait() }) +} + +// AssertNoLeak loops read for d while a symlink swapper runs on path, and +// fails if any output contains Sentinel. +func AssertNoLeak(t *testing.T, path, inside string, d time.Duration, read func() string) { + t.Helper() + StartSwap(t, path, inside, Outside(t), false) + leaks, iters := 0, 0 + for deadline := time.Now().Add(d); time.Now().Before(deadline); iters++ { + leaks += strings.Count(read(), Sentinel) + } + if leaks > 0 { + t.Fatalf("outside file leaked %d times in %d iterations", leaks, iters) + } +} + +// AssertNoHang loops read for d while a FIFO swapper (held writers) runs on +// path, and fails if a read does not return within timeout. +func AssertNoHang(t *testing.T, path, inside string, d, timeout time.Duration, read func()) { + t.Helper() + StartSwap(t, path, inside, Outside(t), true) + done := make(chan struct{}) + go func() { + for deadline := time.Now().Add(d); time.Now().Before(deadline); { + read() + } + close(done) + }() + select { + case <-done: + case <-time.After(timeout): + t.Fatal("reader hung on a swapped-in FIFO with a writer held open") + } +} diff --git a/ingestion/internal/mcp/readexcerpt_root_test.go b/ingestion/internal/mcp/readexcerpt_root_test.go new file mode 100644 index 0000000..bb26d40 --- /dev/null +++ b/ingestion/internal/mcp/readexcerpt_root_test.go @@ -0,0 +1,34 @@ +package mcp + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mathiasbq/hyperguild/ingestion/internal/brainfs/brainfstest" +) + +// hyperguild#157 slice 2: readExcerpt (brain_context) read by path, so a +// symlink in the Syncthing-shared brain put an outside file in context, and +// a FIFO hung it. It now reads through brainfs. +func TestReadExcerptIsConfinedToRegularFilesInTheBrain(t *testing.T) { + brain := t.TempDir() + facts := filepath.Join(brain, "wiki", "w", "facts") + require.NoError(t, os.MkdirAll(facts, 0o755)) + require.NoError(t, os.Symlink(brainfstest.Outside(t), filepath.Join(facts, "leak.md"))) + assert.NotContains(t, readExcerpt(brain, "wiki/w/facts/leak.md", 500), brainfstest.Sentinel) + + p := filepath.Join(facts, "r.md") + require.NoError(t, os.WriteFile(p, []byte("---\nx: y\n---\ninside"), 0o644)) + assert.Equal(t, "inside", readExcerpt(brain, "wiki/w/facts/r.md", 500)) + brainfstest.AssertNoLeak(t, p, "---\nx: y\n---\ninside", 1500*time.Millisecond, func() string { + return readExcerpt(brain, "wiki/w/facts/r.md", 500) + }) + brainfstest.AssertNoHang(t, p, "---\nx: y\n---\ninside", 1500*time.Millisecond, 6*time.Second, func() { + _ = readExcerpt(brain, "wiki/w/facts/r.md", 500) + }) +} diff --git a/ingestion/internal/mcp/tools_context.go b/ingestion/internal/mcp/tools_context.go index d906f20..7d8bf70 100644 --- a/ingestion/internal/mcp/tools_context.go +++ b/ingestion/internal/mcp/tools_context.go @@ -4,7 +4,7 @@ import ( "context" "encoding/json" "fmt" - "os" + "github.com/mathiasbq/hyperguild/ingestion/internal/brainfs" "path/filepath" "sort" "strings" @@ -181,8 +181,13 @@ func readExcerpt(brainDir, relPath string, maxLen int) string { if relPath == "" { return "" } - full := filepath.Join(brainDir, filepath.FromSlash(relPath)) - content, err := os.ReadFile(full) + // Through the brain root, regular files only (hyperguild#157). + bfs, err := brainfs.Open(brainDir) + if err != nil { + return "" + } + defer func() { _ = bfs.Close() }() + content, err := bfs.ReadRegular(filepath.ToSlash(relPath)) if err != nil { return "" } diff --git a/ingestion/internal/notes/edit.go b/ingestion/internal/notes/edit.go index 99e86b1..1bf4f9b 100644 --- a/ingestion/internal/notes/edit.go +++ b/ingestion/internal/notes/edit.go @@ -5,6 +5,7 @@ import ( "encoding/hex" "errors" "fmt" + "github.com/mathiasbq/hyperguild/ingestion/internal/brainfs" "os" "path/filepath" "strings" @@ -71,14 +72,19 @@ func ReadScoped(brainDir, path string, scope Scope) ([]byte, error) { } func readContained(t Target) ([]byte, error) { - fi, err := os.Stat(t.Abs) + // Through the brain root, regular files only, size checked on the opened + // handle (hyperguild#157): Resolve's Lstat could be raced by a swap, and + // os.ReadFile blocked on a FIFO. + brainDir := strings.TrimSuffix(t.Abs, string(filepath.Separator)+filepath.FromSlash(t.Rel)) + bfs, err := brainfs.Open(brainDir) if err != nil { - return nil, fmt.Errorf("notes: stat %s: %w", t.Rel, err) + return nil, fmt.Errorf("notes: %w", err) } - if fi.Size() > maxNoteBytes { - return nil, fmt.Errorf("%w: %s is %d bytes (limit %d)", ErrTooLarge, t.Rel, fi.Size(), maxNoteBytes) + defer func() { _ = bfs.Close() }() + b, err := bfs.ReadRegularMax(t.Rel, maxNoteBytes) + if errors.Is(err, brainfs.ErrTooLarge) { + return nil, fmt.Errorf("%w: %s (limit %d)", ErrTooLarge, t.Rel, maxNoteBytes) } - b, err := os.ReadFile(t.Abs) if err != nil { return nil, fmt.Errorf("notes: read %s: %w", t.Rel, err) } diff --git a/ingestion/internal/notes/read_root_test.go b/ingestion/internal/notes/read_root_test.go new file mode 100644 index 0000000..8e87a38 --- /dev/null +++ b/ingestion/internal/notes/read_root_test.go @@ -0,0 +1,31 @@ +package notes + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mathiasbq/hyperguild/ingestion/internal/brainfs/brainfstest" +) + +// hyperguild#157 slice 2: ReadScoped (brain_read) Lstat-checked the final +// component, then reopened by name with os.ReadFile: a swap after the check +// was followed, and a FIFO blocked the read. It now reads through brainfs. +func TestReadScopedIsConfinedToRegularFilesInTheBrain(t *testing.T) { + brain := t.TempDir() + facts := filepath.Join(brain, "wiki", "w", "facts") + require.NoError(t, os.MkdirAll(facts, 0o755)) + p := filepath.Join(facts, "r.md") + read := func() string { + b, _ := ReadScoped(brain, "wiki/w/facts/r.md", ScopeNotes) + return string(b) + } + require.NoError(t, os.WriteFile(p, []byte("inside"), 0o644)) + assert.Equal(t, "inside", read()) + brainfstest.AssertNoLeak(t, p, "inside", 1500*time.Millisecond, read) + brainfstest.AssertNoHang(t, p, "inside", 1500*time.Millisecond, 6*time.Second, func() { _ = read() }) +} -- 2.54.0 From 02cecb25b10c2cfb1f857bd222e5d457d5ef7d8d Mon Sep 17 00:00:00 2001 From: Mathias Date: Sun, 4 Oct 2026 00:33:36 +0200 Subject: [PATCH 2/2] fix(brainfs): bound the read in ReadRegularMax; honest test names (#157) Review round 1 on PR #165: - ReadRegularMax checked the size on the opened handle, then read with an unbounded io.ReadAll, so a file growing after the check returned past the limit. The read now goes through io.LimitReader(max+1). A test seam grows the file between the check and the read and counts bytes consumed. - New: ReadScoped returns ErrTooLarge for a note over maxNoteBytes; red if readContained drops the limit. - notes.Target carries the brain root instead of deriving it by TrimSuffix. - Rule 9: reader tests renamed to RefusesSymlinksAndSurvivesSwapRaces; ReadNote test has a positive control; brainfstest comment states what the harness observes and what it does not. Co-Authored-By: Claude Opus 5.5 --- ingestion/internal/api/readnote_root_test.go | 7 +++- ingestion/internal/brainfs/brainfs.go | 21 ++++++++++- ingestion/internal/brainfs/brainfs_test.go | 36 +++++++++++++++++++ .../brainfs/brainfstest/brainfstest.go | 11 +++--- .../internal/mcp/readexcerpt_root_test.go | 2 +- ingestion/internal/notes/edit.go | 3 +- ingestion/internal/notes/path.go | 7 ++-- ingestion/internal/notes/read_root_test.go | 11 +++++- 8 files changed, 84 insertions(+), 14 deletions(-) diff --git a/ingestion/internal/api/readnote_root_test.go b/ingestion/internal/api/readnote_root_test.go index 0a7353d..94fedff 100644 --- a/ingestion/internal/api/readnote_root_test.go +++ b/ingestion/internal/api/readnote_root_test.go @@ -15,7 +15,7 @@ import ( // hyperguild#157 slice 2: ReadNote (brain_get, read-after-write) read by // path; it now reads through brainfs. -func TestReadNoteIsConfinedToRegularFilesInTheBrain(t *testing.T) { +func TestReadNoteRefusesSymlinksAndSurvivesSwapRaces(t *testing.T) { brain := t.TempDir() facts := filepath.Join(brain, "wiki", "w", "facts") require.NoError(t, os.MkdirAll(facts, 0o755)) @@ -25,6 +25,11 @@ func TestReadNoteIsConfinedToRegularFilesInTheBrain(t *testing.T) { assert.NotContains(t, body, brainfstest.Sentinel) p := filepath.Join(facts, "r.md") + require.NoError(t, os.WriteFile(p, []byte("---\nx: y\n---\ninside"), 0o644)) + fm, body, _, err := api.ReadNote(brain, "wiki/w/facts/r.md") + require.NoError(t, err) + assert.Equal(t, "y", fm["x"]) + assert.Contains(t, body, "inside") read := func() string { fm, body, _, _ := api.ReadNote(brain, "wiki/w/facts/r.md") s := body diff --git a/ingestion/internal/brainfs/brainfs.go b/ingestion/internal/brainfs/brainfs.go index a182238..250244f 100644 --- a/ingestion/internal/brainfs/brainfs.go +++ b/ingestion/internal/brainfs/brainfs.go @@ -108,9 +108,28 @@ func (b *Brain) readOpenedMax(rel string, max int64) ([]byte, error) { if max >= 0 && hfi.Size() > max { return nil, fmt.Errorf("%s is %d bytes (limit %d): %w", rel, hfi.Size(), max, ErrTooLarge) } - return io.ReadAll(f) + var r io.Reader = f + if wrapAfterStat != nil { + r = wrapAfterStat(r) + } + if max < 0 { + return io.ReadAll(r) + } + // The handle's size can grow after the check; bound the read itself. + data, err := io.ReadAll(io.LimitReader(r, max+1)) + if err != nil { + return nil, err + } + if int64(len(data)) > max { + return nil, fmt.Errorf("%s grew past %d bytes while read: %w", rel, max, ErrTooLarge) + } + return data, nil } +// wrapAfterStat is a test seam: it runs between the size check and the read, +// and wraps the reader the read consumes. +var wrapAfterStat func(io.Reader) io.Reader + // WalkRegular walks a brain-relative directory through the root and calls fn // for each regular file (directories are descended; symlinks, FIFOs and // devices are skipped, never followed). An entry error is passed to onErr and diff --git a/ingestion/internal/brainfs/brainfs_test.go b/ingestion/internal/brainfs/brainfs_test.go index 3c48a00..8a09e01 100644 --- a/ingestion/internal/brainfs/brainfs_test.go +++ b/ingestion/internal/brainfs/brainfs_test.go @@ -2,6 +2,7 @@ package brainfs import ( "errors" + "io" "os" "path/filepath" "syscall" @@ -103,3 +104,38 @@ func TestReadRegularMaxRefusesAFileOverTheLimit(t *testing.T) { require.NoError(t, err) assert.Len(t, got, 100) } + +func TestReadRegularMaxRefusesAndStopsReadingAFileThatGrowsPastTheLimit(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "grow.md") + require.NoError(t, os.WriteFile(p, make([]byte, 10), 0o644)) + consumed := &countingReader{} + wrapAfterStat = func(r io.Reader) io.Reader { + f, err := os.OpenFile(p, os.O_WRONLY|os.O_APPEND, 0) + require.NoError(t, err) + _, err = f.Write(make([]byte, 1000)) + require.NoError(t, err) + require.NoError(t, f.Close()) + consumed.r = r + return consumed + } + t.Cleanup(func() { wrapAfterStat = nil }) + b, err := Open(dir) + require.NoError(t, err) + defer func() { _ = b.Close() }() + got, err := b.ReadRegularMax("grow.md", 100) + assert.True(t, errors.Is(err, ErrTooLarge), "got %v", err) + assert.Nil(t, got) + assert.LessOrEqual(t, consumed.n, 101, "read past max+1 bytes") +} + +type countingReader struct { + r io.Reader + n int +} + +func (c *countingReader) Read(p []byte) (int, error) { + n, err := c.r.Read(p) + c.n += n + return n, err +} diff --git a/ingestion/internal/brainfs/brainfstest/brainfstest.go b/ingestion/internal/brainfs/brainfstest/brainfstest.go index 56f6bc8..3cf4e14 100644 --- a/ingestion/internal/brainfs/brainfstest/brainfstest.go +++ b/ingestion/internal/brainfs/brainfstest/brainfstest.go @@ -1,8 +1,9 @@ -// Package brainfstest is the shared harness for hyperguild#157's behavioural -// confinement tests: it keeps swapping a regular note, a symlink to an -// outside file, or a FIFO (with a writer held open) into a path while a -// reader under test loops. A reader is confined if no sentinel byte ever -// reaches its output and it never hangs. +// Package brainfstest is the shared harness for hyperguild#157's swap-race +// tests: it keeps swapping a regular note, a symlink to an outside file, or a +// FIFO (with a writer held open) into a path while a reader under test loops. +// It observes two things: no sentinel byte from the outside file reaches the +// reader's output, and the reader returns within a timeout. It does not cover +// hard links, other paths into the reader, or races it does not generate. package brainfstest import ( diff --git a/ingestion/internal/mcp/readexcerpt_root_test.go b/ingestion/internal/mcp/readexcerpt_root_test.go index bb26d40..9ba1748 100644 --- a/ingestion/internal/mcp/readexcerpt_root_test.go +++ b/ingestion/internal/mcp/readexcerpt_root_test.go @@ -15,7 +15,7 @@ import ( // hyperguild#157 slice 2: readExcerpt (brain_context) read by path, so a // symlink in the Syncthing-shared brain put an outside file in context, and // a FIFO hung it. It now reads through brainfs. -func TestReadExcerptIsConfinedToRegularFilesInTheBrain(t *testing.T) { +func TestReadExcerptRefusesSymlinksAndSurvivesSwapRaces(t *testing.T) { brain := t.TempDir() facts := filepath.Join(brain, "wiki", "w", "facts") require.NoError(t, os.MkdirAll(facts, 0o755)) diff --git a/ingestion/internal/notes/edit.go b/ingestion/internal/notes/edit.go index 1bf4f9b..c289db8 100644 --- a/ingestion/internal/notes/edit.go +++ b/ingestion/internal/notes/edit.go @@ -75,8 +75,7 @@ func readContained(t Target) ([]byte, error) { // Through the brain root, regular files only, size checked on the opened // handle (hyperguild#157): Resolve's Lstat could be raced by a swap, and // os.ReadFile blocked on a FIFO. - brainDir := strings.TrimSuffix(t.Abs, string(filepath.Separator)+filepath.FromSlash(t.Rel)) - bfs, err := brainfs.Open(brainDir) + bfs, err := brainfs.Open(t.Brain) if err != nil { return nil, fmt.Errorf("notes: %w", err) } diff --git a/ingestion/internal/notes/path.go b/ingestion/internal/notes/path.go index e54a026..42ac5a0 100644 --- a/ingestion/internal/notes/path.go +++ b/ingestion/internal/notes/path.go @@ -58,8 +58,9 @@ var ( // Target is a resolved, contained path. type Target struct { - Rel string // brain-relative, cleaned - Abs string // absolute, symlink-checked + Rel string // brain-relative, cleaned + Abs string // absolute, symlink-checked + Brain string // the brain root Rel is relative to } // Resolve canonicalises path, then checks it against the scope's allowlist, @@ -109,7 +110,7 @@ func Resolve(brainDir, path string, scope Scope) (Target, error) { return Target{}, err } - return Target{Rel: clean, Abs: abs}, nil + return Target{Rel: clean, Abs: abs, Brain: brainDir}, nil } // allow enforces the scope's allowlist. The two scopes are DISJOINT, not diff --git a/ingestion/internal/notes/read_root_test.go b/ingestion/internal/notes/read_root_test.go index 8e87a38..a699fd4 100644 --- a/ingestion/internal/notes/read_root_test.go +++ b/ingestion/internal/notes/read_root_test.go @@ -15,7 +15,7 @@ import ( // hyperguild#157 slice 2: ReadScoped (brain_read) Lstat-checked the final // component, then reopened by name with os.ReadFile: a swap after the check // was followed, and a FIFO blocked the read. It now reads through brainfs. -func TestReadScopedIsConfinedToRegularFilesInTheBrain(t *testing.T) { +func TestReadScopedRefusesSymlinksAndSurvivesSwapRaces(t *testing.T) { brain := t.TempDir() facts := filepath.Join(brain, "wiki", "w", "facts") require.NoError(t, os.MkdirAll(facts, 0o755)) @@ -29,3 +29,12 @@ func TestReadScopedIsConfinedToRegularFilesInTheBrain(t *testing.T) { brainfstest.AssertNoLeak(t, p, "inside", 1500*time.Millisecond, read) brainfstest.AssertNoHang(t, p, "inside", 1500*time.Millisecond, 6*time.Second, func() { _ = read() }) } + +func TestReadScopedRefusesANoteOverMaxNoteBytes(t *testing.T) { + brain := t.TempDir() + facts := filepath.Join(brain, "wiki", "w", "facts") + require.NoError(t, os.MkdirAll(facts, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(facts, "big.md"), make([]byte, maxNoteBytes+1), 0o644)) + _, err := ReadScoped(brain, "wiki/w/facts/big.md", ScopeNotes) + assert.ErrorIs(t, err, ErrTooLarge) +} -- 2.54.0