Files
hyperguild/ingestion/internal/search/brainroot_test.go
T
mathiasandClaude Opus 5.5 9c9ba31770
CI / Lint / Test / Vet (pull_request) Successful in 48s
CI / Mirror to GitHub (pull_request) Skipped
fix(search): walk and read the brain through an os.Root (#157, slice 1)
Option 1 of #157, first slice. Search walked and read the brain by
path, so a symlink planted in the Syncthing-shared brain dir put an
outside file's contents in brain_query results, and a FIFO swapped into
the wiki hung the query forever (reproduced: the FIFO race test blocked
for the full 6s timeout before this change).

New package internal/brainfs: Open (os.Root on the brain dir),
ReadRegular (Lstat + non-blocking open + type check on the handle) and
WalkRegular (walks the root's FS, yields regular files only, never
follows a link). search.Query and hydrate use it.

Tests (red first): a wiki symlink is not followed, a symlinked dir is not
descended, hydrate refuses a symlink, and race tests swap a symlink or a
FIFO into a wiki note while Query loops; any leaked byte or a hang fails.

Refs #157

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 18:18:54 +02:00

125 lines
3.9 KiB
Go

package search
import (
"os"
"path/filepath"
"strings"
"sync/atomic"
"syscall"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// hyperguild#157 option 1: search walked and read the brain by path, so a
// symlink anywhere in the Syncthing-shared brain dir (wiki/<w>/<h>/leak.md ->
// /any/file) put the target's contents in brain_query results (reproduced in
// the PR #156 review). Search now walks and reads through an os.Root on the
// brain dir and only reads regular files.
const sentinel157 = "zqsentinelonefiftyseven"
func searchBrain(t *testing.T) (brain, facts, outside string) {
t.Helper()
brain = t.TempDir()
facts = filepath.Join(brain, "wiki", "w", "facts")
require.NoError(t, os.MkdirAll(facts, 0o755))
require.NoError(t, os.MkdirAll(filepath.Join(brain, "knowledge"), 0o755))
outside = filepath.Join(t.TempDir(), "secret.md")
require.NoError(t, os.WriteFile(outside, []byte("# secret\n\n"+sentinel157), 0o644))
return brain, facts, outside
}
func leaked(rs []Result) int {
n := 0
for _, r := range rs {
n += strings.Count(r.Excerpt+r.Title, sentinel157)
}
return n
}
func TestQueryDoesNotFollowASymlinkInTheWiki(t *testing.T) {
brain, facts, outside := searchBrain(t)
require.NoError(t, os.Symlink(outside, filepath.Join(facts, "leak.md")))
require.NoError(t, os.WriteFile(filepath.Join(facts, "real.md"), []byte("# real\n\nzqrealnote"), 0o644))
rs, err := Query(brain, QueryOptions{Query: sentinel157, Limit: 10})
require.NoError(t, err)
assert.Zero(t, leaked(rs))
rs, err = Query(brain, QueryOptions{Query: "zqrealnote", Limit: 10})
require.NoError(t, err)
assert.Len(t, rs, 1, "regular notes are still found")
}
func TestQueryDoesNotFollowASymlinkedDirectory(t *testing.T) {
brain, _, outside := searchBrain(t)
require.NoError(t, os.Symlink(filepath.Dir(outside), filepath.Join(brain, "wiki", "linked")))
rs, err := Query(brain, QueryOptions{Query: sentinel157, Limit: 10})
require.NoError(t, err)
assert.Zero(t, leaked(rs))
}
func swapInto(path, outside string, fifo bool, stop *atomic.Bool) {
tmp := path + ".swap"
for i := 0; !stop.Load(); i++ {
_ = os.Remove(tmp)
switch {
case i%2 == 0:
_ = os.WriteFile(tmp, []byte("# r\n\nzqinside "+"zqracenote"), 0o644)
case fifo:
_ = syscall.Mkfifo(tmp, 0o644)
default:
_ = os.Symlink(outside, tmp)
}
_ = os.Rename(tmp, path)
}
}
func TestQueryLeaksNothingUnderASymlinkSwapRace(t *testing.T) {
brain, facts, outside := searchBrain(t)
var stop atomic.Bool
go swapInto(filepath.Join(facts, "r.md"), outside, false, &stop)
defer stop.Store(true)
leaks, iters := 0, 0
for deadline := time.Now().Add(1500 * time.Millisecond); time.Now().Before(deadline); iters++ {
rs, _ := Query(brain, QueryOptions{Query: sentinel157, Limit: 10})
leaks += leaked(rs)
}
if leaks > 0 {
t.Fatalf("outside file leaked %d times in %d iterations", leaks, iters)
}
}
func TestQueryDoesNotHangUnderAFIFOSwapRace(t *testing.T) {
brain, facts, outside := searchBrain(t)
var stop atomic.Bool
go swapInto(filepath.Join(facts, "r.md"), outside, true, &stop)
done := make(chan struct{})
go func() {
for deadline := time.Now().Add(1500 * time.Millisecond); time.Now().Before(deadline); {
_, _ = Query(brain, QueryOptions{Query: "zqracenote", Limit: 10})
}
close(done)
}()
select {
case <-done:
stop.Store(true)
case <-time.After(6 * time.Second):
stop.Store(true)
if f, err := os.OpenFile(filepath.Join(facts, "r.md"), os.O_WRONLY|syscall.O_NONBLOCK, 0); err == nil {
_ = f.Close()
}
t.Fatal("search hung on a swapped-in FIFO")
}
}
// hydrate (vector-only hits) reads by relative path; it must refuse a symlink.
func TestHydrateRefusesASymlink(t *testing.T) {
brain, facts, outside := searchBrain(t)
require.NoError(t, os.Symlink(outside, filepath.Join(facts, "leak.md")))
r, err := hydrate(brain, "wiki/w/facts/leak.md")
assert.Error(t, err)
assert.NotContains(t, r.Excerpt, sentinel157)
}