After main's #177 floor (AssertNoLeakFrom needs at least one read that returns the inside note), this test went red on merge: the reader queried only the sentinel, so the regular note could never be a hit and the race had been passing vacuously. Query both terms. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
240 lines
9.2 KiB
Go
240 lines
9.2 KiB
Go
package search
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"syscall"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs/brainfstest"
|
|
)
|
|
|
|
// 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))
|
|
}
|
|
|
|
// A search root that is a symlink staying inside the brain (wiki/<w> -> ..)
|
|
// was followed by fs.WalkDir, aliasing raw/ and root files into results
|
|
// (PR #166 review). The root is now Lstat-checked and skipped.
|
|
func TestQueryDoesNotFollowASymlinkedWingRootInsideTheBrain(t *testing.T) {
|
|
brain, _, _ := searchBrain(t)
|
|
require.NoError(t, os.MkdirAll(filepath.Join(brain, "raw"), 0o755))
|
|
require.NoError(t, os.WriteFile(filepath.Join(brain, "raw", "r.md"), []byte("# r\n\nzqrawonly"), 0o644))
|
|
require.NoError(t, os.Symlink("..", filepath.Join(brain, "wiki", "alias")))
|
|
rs, err := Query(brain, QueryOptions{Query: "zqrawonly", Wing: "alias", Limit: 10})
|
|
require.NoError(t, err)
|
|
assert.Empty(t, rs)
|
|
}
|
|
|
|
// With a hall the search root is wiki/<wing>/<hall>; a symlinked <wing> with
|
|
// a real <hall> directory under its target was followed (PR #166 round 2).
|
|
func TestQueryDoesNotFollowASymlinkedWingUnderAWingAndHallRoot(t *testing.T) {
|
|
brain, _, _ := searchBrain(t)
|
|
require.NoError(t, os.MkdirAll(filepath.Join(brain, "raw", "facts"), 0o755))
|
|
require.NoError(t, os.WriteFile(filepath.Join(brain, "raw", "facts", "r.md"), []byte("# r\n\nzqrawhall"), 0o644))
|
|
require.NoError(t, os.Symlink(filepath.Join("..", "raw"), filepath.Join(brain, "wiki", "alias")))
|
|
rs, err := Query(brain, QueryOptions{Query: "zqrawhall", Wing: "alias", Hall: "facts", Limit: 10})
|
|
require.NoError(t, err)
|
|
assert.Empty(t, rs)
|
|
}
|
|
|
|
// swapInto keeps renaming a regular note, and either a symlink to outside or
|
|
// a FIFO, into path. Each FIFO has a writer held open (as any local process
|
|
// can): a FIFO with no writer reads EOF at once, which would let a reader
|
|
// that skips the handle check pass (PR #161 round 2: with a held writer, a
|
|
// bypass of readOpened hung Query after 5 iterations).
|
|
// startSwap runs swapInto until the test ends and waits for it before
|
|
// t.TempDir's cleanup (hyperguild#162).
|
|
// It returns the count of symlinks or FIFOs actually renamed into path
|
|
// (hyperguild#177: a swapper whose every swap fails tests nothing).
|
|
func startSwap(t *testing.T, path, outside string, fifo bool) *atomic.Int64 {
|
|
t.Helper()
|
|
var stop atomic.Bool
|
|
var swaps atomic.Int64
|
|
var wg sync.WaitGroup
|
|
wg.Add(1)
|
|
go func() { defer wg.Done(); swapInto(path, outside, fifo, &stop, &swaps) }()
|
|
t.Cleanup(func() { stop.Store(true); wg.Wait() })
|
|
return &swaps
|
|
}
|
|
|
|
func swapInto(path, outside string, fifo bool, stop *atomic.Bool, swaps *atomic.Int64) {
|
|
tmp := path + ".swap"
|
|
// Writers stay open until the swapper exits, so a reader parked on a
|
|
// FIFO stays parked (closing one would hand the reader EOF and hide the
|
|
// hang). Past the fd limit, new FIFOs simply have no writer.
|
|
var writers []*os.File
|
|
defer func() {
|
|
for _, w := range writers {
|
|
_ = w.Close()
|
|
}
|
|
}()
|
|
for i := 0; !stop.Load(); i++ {
|
|
_ = os.Remove(tmp)
|
|
var made error
|
|
switch {
|
|
case i%2 == 0:
|
|
_ = os.WriteFile(tmp, []byte("# r\n\nzqinside "+"zqracenote"), 0o644)
|
|
case fifo:
|
|
if made = syscall.Mkfifo(tmp, 0o644); made == nil {
|
|
if w, err := os.OpenFile(tmp, os.O_RDWR, 0); err == nil {
|
|
writers = append(writers, w)
|
|
}
|
|
}
|
|
default:
|
|
made = os.Symlink(outside, tmp)
|
|
}
|
|
if err := os.Rename(tmp, path); err == nil && made == nil && i%2 == 1 {
|
|
swaps.Add(1)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestQueryLeaksNothingUnderASymlinkSwapRace(t *testing.T) {
|
|
brain, facts, outside := searchBrain(t)
|
|
swaps := startSwap(t, filepath.Join(facts, "r.md"), outside, false)
|
|
leaks, iters, insideReads := 0, 0, 0
|
|
for deadline := time.Now().Add(1500 * time.Millisecond); time.Now().Before(deadline); iters++ {
|
|
rs, err := Query(brain, QueryOptions{Query: sentinel157, Limit: 10})
|
|
require.NoError(t, err)
|
|
leaks += leaked(rs)
|
|
// Positive control (hyperguild#177): a Query that always errors
|
|
// returns nothing and so never leaks.
|
|
rs, err = Query(brain, QueryOptions{Query: "zqracenote", Limit: 10})
|
|
require.NoError(t, err)
|
|
if len(rs) > 0 {
|
|
insideReads++
|
|
}
|
|
}
|
|
if leaks > 0 {
|
|
t.Fatalf("outside file leaked %d times in %d iterations", leaks, iters)
|
|
}
|
|
require.Positive(t, swaps.Load(), "no symlink was swapped in: the race did not run")
|
|
require.Positive(t, insideReads, "no Query returned the inside note")
|
|
}
|
|
|
|
func TestQueryDoesNotHangUnderAFIFOSwapRace(t *testing.T) {
|
|
brain, facts, outside := searchBrain(t)
|
|
startSwap(t, filepath.Join(facts, "r.md"), outside, true)
|
|
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:
|
|
case <-time.After(6 * time.Second):
|
|
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)
|
|
}
|
|
|
|
// hyperguild#171: os.Root refuses links that leave the brain on its own, so
|
|
// the outside-target cases above would stay green for a caller that read
|
|
// without brainfs's Lstat and SameFile checks. These targets are inside the
|
|
// brain (raw/, which is not a search root).
|
|
func inBrainTarget(t *testing.T, brain string) string {
|
|
t.Helper()
|
|
return brainfstest.WriteSentinelAt(t, filepath.Join(brain, "raw", "s.md"))
|
|
}
|
|
|
|
func TestQueryDoesNotReturnAnInBrainSymlinkTargetDuringASampledSwapRace(t *testing.T) {
|
|
brain, facts, _ := searchBrain(t)
|
|
target := inBrainTarget(t, brain)
|
|
read := func() string {
|
|
// Both terms, so the regular note is a possible hit: a reader that
|
|
// never returns the inside note would pass vacuously (#177 floor).
|
|
rs, _ := Query(brain, QueryOptions{Query: brainfstest.Sentinel + " zqinside", Limit: 10})
|
|
s := ""
|
|
for _, r := range rs {
|
|
s += r.Title + r.Excerpt
|
|
}
|
|
return s
|
|
}
|
|
brainfstest.AssertNoLeakFrom(t, filepath.Join(facts, "r.md"), "# r\n\nzqinside", target, 1500*time.Millisecond, read)
|
|
require.NoError(t, os.Symlink(filepath.Join("..", "..", "..", "raw", "s.md"), filepath.Join(facts, "inlink.md")))
|
|
assert.NotContains(t, read(), brainfstest.Sentinel, "static in-brain link")
|
|
}
|
|
|
|
func TestHydrateRefusesAnInBrainSymlinkAndDoesNotReturnItsTargetDuringASampledSwapRace(t *testing.T) {
|
|
brain, facts, _ := searchBrain(t)
|
|
target := inBrainTarget(t, brain)
|
|
require.NoError(t, os.Symlink(filepath.Join("..", "..", "..", "raw", "s.md"), filepath.Join(facts, "inlink.md")))
|
|
r, err := hydrate(brain, "wiki/w/facts/inlink.md")
|
|
assert.Error(t, err)
|
|
assert.NotContains(t, r.Excerpt+r.Title, brainfstest.Sentinel)
|
|
|
|
p := filepath.Join(facts, "r.md")
|
|
require.NoError(t, os.WriteFile(p, []byte("# r\n\nzqinside"), 0o644))
|
|
r, err = hydrate(brain, "wiki/w/facts/r.md")
|
|
require.NoError(t, err)
|
|
assert.Contains(t, r.Excerpt, "zqinside", "positive control: a regular note hydrates")
|
|
brainfstest.AssertNoLeakFrom(t, p, "# r\n\nzqinside", target, 1500*time.Millisecond, func() string {
|
|
r, _ := hydrate(brain, "wiki/w/facts/r.md")
|
|
return r.Title + r.Excerpt
|
|
})
|
|
}
|