fix(brainfs): bound the read in ReadRegularMax; honest test names (#157)
CI / Lint / Test / Vet (pull_request) Successful in 41s
CI / Mirror to GitHub (pull_request) Skipped

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 <Reader>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 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 00:33:36 +02:00
co-authored by Claude Opus 5.5
parent b6f7dac46e
commit 02cecb25b1
8 changed files with 84 additions and 14 deletions
+6 -1
View File
@@ -15,7 +15,7 @@ import (
// hyperguild#157 slice 2: ReadNote (brain_get, read-after-write) read by // hyperguild#157 slice 2: ReadNote (brain_get, read-after-write) read by
// path; it now reads through brainfs. // path; it now reads through brainfs.
func TestReadNoteIsConfinedToRegularFilesInTheBrain(t *testing.T) { func TestReadNoteRefusesSymlinksAndSurvivesSwapRaces(t *testing.T) {
brain := t.TempDir() brain := t.TempDir()
facts := filepath.Join(brain, "wiki", "w", "facts") facts := filepath.Join(brain, "wiki", "w", "facts")
require.NoError(t, os.MkdirAll(facts, 0o755)) require.NoError(t, os.MkdirAll(facts, 0o755))
@@ -25,6 +25,11 @@ func TestReadNoteIsConfinedToRegularFilesInTheBrain(t *testing.T) {
assert.NotContains(t, body, brainfstest.Sentinel) assert.NotContains(t, body, brainfstest.Sentinel)
p := filepath.Join(facts, "r.md") 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 { read := func() string {
fm, body, _, _ := api.ReadNote(brain, "wiki/w/facts/r.md") fm, body, _, _ := api.ReadNote(brain, "wiki/w/facts/r.md")
s := body s := body
+20 -1
View File
@@ -108,9 +108,28 @@ func (b *Brain) readOpenedMax(rel string, max int64) ([]byte, error) {
if max >= 0 && hfi.Size() > max { if max >= 0 && hfi.Size() > max {
return nil, fmt.Errorf("%s is %d bytes (limit %d): %w", rel, hfi.Size(), max, ErrTooLarge) 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 // WalkRegular walks a brain-relative directory through the root and calls fn
// for each regular file (directories are descended; symlinks, FIFOs and // for each regular file (directories are descended; symlinks, FIFOs and
// devices are skipped, never followed). An entry error is passed to onErr and // devices are skipped, never followed). An entry error is passed to onErr and
@@ -2,6 +2,7 @@ package brainfs
import ( import (
"errors" "errors"
"io"
"os" "os"
"path/filepath" "path/filepath"
"syscall" "syscall"
@@ -103,3 +104,38 @@ func TestReadRegularMaxRefusesAFileOverTheLimit(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
assert.Len(t, got, 100) 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
}
@@ -1,8 +1,9 @@
// Package brainfstest is the shared harness for hyperguild#157's behavioural // Package brainfstest is the shared harness for hyperguild#157's swap-race
// confinement tests: it keeps swapping a regular note, a symlink to an // tests: it keeps swapping a regular note, a symlink to an outside file, or a
// outside file, or a FIFO (with a writer held open) into a path while a // FIFO (with a writer held open) into a path while a reader under test loops.
// reader under test loops. A reader is confined if no sentinel byte ever // It observes two things: no sentinel byte from the outside file reaches the
// reaches its output and it never hangs. // 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 package brainfstest
import ( import (
@@ -15,7 +15,7 @@ import (
// hyperguild#157 slice 2: readExcerpt (brain_context) read by path, so a // 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 // symlink in the Syncthing-shared brain put an outside file in context, and
// a FIFO hung it. It now reads through brainfs. // a FIFO hung it. It now reads through brainfs.
func TestReadExcerptIsConfinedToRegularFilesInTheBrain(t *testing.T) { func TestReadExcerptRefusesSymlinksAndSurvivesSwapRaces(t *testing.T) {
brain := t.TempDir() brain := t.TempDir()
facts := filepath.Join(brain, "wiki", "w", "facts") facts := filepath.Join(brain, "wiki", "w", "facts")
require.NoError(t, os.MkdirAll(facts, 0o755)) require.NoError(t, os.MkdirAll(facts, 0o755))
+1 -2
View File
@@ -75,8 +75,7 @@ func readContained(t Target) ([]byte, error) {
// Through the brain root, regular files only, size checked on the opened // 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 // handle (hyperguild#157): Resolve's Lstat could be raced by a swap, and
// os.ReadFile blocked on a FIFO. // os.ReadFile blocked on a FIFO.
brainDir := strings.TrimSuffix(t.Abs, string(filepath.Separator)+filepath.FromSlash(t.Rel)) bfs, err := brainfs.Open(t.Brain)
bfs, err := brainfs.Open(brainDir)
if err != nil { if err != nil {
return nil, fmt.Errorf("notes: %w", err) return nil, fmt.Errorf("notes: %w", err)
} }
+4 -3
View File
@@ -58,8 +58,9 @@ var (
// Target is a resolved, contained path. // Target is a resolved, contained path.
type Target struct { type Target struct {
Rel string // brain-relative, cleaned Rel string // brain-relative, cleaned
Abs string // absolute, symlink-checked 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, // 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{}, 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 // allow enforces the scope's allowlist. The two scopes are DISJOINT, not
+10 -1
View File
@@ -15,7 +15,7 @@ import (
// hyperguild#157 slice 2: ReadScoped (brain_read) Lstat-checked the final // hyperguild#157 slice 2: ReadScoped (brain_read) Lstat-checked the final
// component, then reopened by name with os.ReadFile: a swap after the check // 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. // 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() brain := t.TempDir()
facts := filepath.Join(brain, "wiki", "w", "facts") facts := filepath.Join(brain, "wiki", "w", "facts")
require.NoError(t, os.MkdirAll(facts, 0o755)) 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.AssertNoLeak(t, p, "inside", 1500*time.Millisecond, read)
brainfstest.AssertNoHang(t, p, "inside", 1500*time.Millisecond, 6*time.Second, func() { _ = 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)
}