Review round 1 on PR #166: fs.WalkDir stats its root with a follow, and os.Root allows a symlink that stays inside the brain, so knowledge -> . aliased raw/, sessions/ and root files into the embeddings (and a wiki/<w> -> .. root into search results). On main, filepath.WalkDir Lstat'ed the root and did not descend. - WalkRegular Lstats its root and requires a real directory; anything else goes to onErr as ErrNotRegular and nothing is yielded. - Sync skips a missing scan root via bfs.Lstat before walking, and every other walk error, including a symlinked root, aborts before the delete pass. This removes the unpinned rel == sub special case. - Search warns and skips a symlinked root (new test). - Test renamed to TestSyncRefusesSymlinkedNotesAndSurvivesSwapRaces. - wrapAfterStat comment: brainfs tests must not use t.Parallel. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
127 lines
4.5 KiB
Go
127 lines
4.5 KiB
Go
package vectorstore_test
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs/brainfstest"
|
|
"github.com/mathiasbq/hyperguild/ingestion/internal/vectorstore"
|
|
)
|
|
|
|
// recordingEmbedder keeps every text it was asked to embed.
|
|
type recordingEmbedder struct {
|
|
mu sync.Mutex
|
|
texts []string
|
|
}
|
|
|
|
func (e *recordingEmbedder) Embed(_ context.Context, text string) ([]float32, error) {
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
e.texts = append(e.texts, text)
|
|
return []float32{1}, nil
|
|
}
|
|
|
|
func (e *recordingEmbedder) all() string {
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
return strings.Join(e.texts, "\n")
|
|
}
|
|
|
|
// hyperguild#157 slice 3: Sync walked wiki/ and knowledge/ and read each .md
|
|
// with os.ReadFile, so a symlinked note had its target embedded and served by
|
|
// semantic search, and a FIFO stalled the sync.
|
|
func TestSyncRefusesSymlinkedNotesAndSurvivesSwapRaces(t *testing.T) {
|
|
brain := t.TempDir()
|
|
writeNote(t, brain, "wiki/w/facts/ok.md", "inside note")
|
|
require.NoError(t, os.Symlink(brainfstest.Outside(t), filepath.Join(brain, "wiki", "w", "facts", "leak.md")))
|
|
|
|
emb := &recordingEmbedder{}
|
|
store := &stubStore{}
|
|
_, err := vectorstore.Sync(context.Background(), brain, store, emb)
|
|
require.NoError(t, err)
|
|
assert.Contains(t, emb.all(), "inside note", "positive control: the regular note is embedded")
|
|
assert.NotContains(t, emb.all(), brainfstest.Sentinel)
|
|
for p := range store.upserts {
|
|
assert.NotContains(t, p, "leak.md")
|
|
}
|
|
|
|
p := filepath.Join(brain, "knowledge", "r.md")
|
|
require.NoError(t, os.MkdirAll(filepath.Dir(p), 0o755))
|
|
sync := func() string {
|
|
e := &recordingEmbedder{}
|
|
_, _ = vectorstore.Sync(context.Background(), brain, &stubStore{}, e)
|
|
return e.all()
|
|
}
|
|
brainfstest.AssertNoLeak(t, p, "inside", 1500*time.Millisecond, sync)
|
|
brainfstest.AssertNoHang(t, p, "inside", 1500*time.Millisecond, 6*time.Second, func() { _ = sync() })
|
|
}
|
|
|
|
// Chunks embedded from a symlinked note before this fix are deleted: the
|
|
// note is no longer seen, so the delete pass drops its rows.
|
|
func TestSyncDeletesChunksOfASymlinkedNote(t *testing.T) {
|
|
brain := t.TempDir()
|
|
writeNote(t, brain, "wiki/w/facts/ok.md", "inside note")
|
|
require.NoError(t, os.Symlink(brainfstest.Outside(t), filepath.Join(brain, "wiki", "w", "facts", "leak.md")))
|
|
store := &stubStore{known: map[string]time.Time{
|
|
"wiki/w/facts/leak.md#0001": {},
|
|
"wiki/w/facts/ok.md#0001": {},
|
|
}}
|
|
_, err := vectorstore.Sync(context.Background(), brain, store, &recordingEmbedder{})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []string{"wiki/w/facts/leak.md#0001"}, store.deletes)
|
|
}
|
|
|
|
// A walk error aborts Sync before the delete pass, so chunks of files the
|
|
// walk could not see are kept.
|
|
func TestSyncAbortsBeforeDeletingWhenASubdirIsUnreadable(t *testing.T) {
|
|
if os.Geteuid() == 0 {
|
|
t.Skip("root ignores directory permissions")
|
|
}
|
|
brain := t.TempDir()
|
|
writeNote(t, brain, "wiki/w/facts/ok.md", "inside note")
|
|
locked := filepath.Join(brain, "wiki", "w")
|
|
require.NoError(t, os.Chmod(locked, 0o000))
|
|
t.Cleanup(func() { _ = os.Chmod(locked, 0o755) })
|
|
store := &stubStore{known: map[string]time.Time{"wiki/w/facts/ok.md#0001": {}}}
|
|
_, err := vectorstore.Sync(context.Background(), brain, store, &recordingEmbedder{})
|
|
assert.Error(t, err)
|
|
assert.Empty(t, store.deletes)
|
|
}
|
|
|
|
// A scan root that is a symlink inside the brain (knowledge -> .) was
|
|
// followed by fs.WalkDir, aliasing raw/, sessions/ and root files into the
|
|
// index. Sync now aborts on it, before embedding or deleting anything.
|
|
func TestSyncAbortsOnASymlinkedScanRoot(t *testing.T) {
|
|
brain := t.TempDir()
|
|
writeNote(t, brain, "wiki/w/a.md", "wiki note")
|
|
writeNote(t, brain, "sessions/s1.md", "session note")
|
|
require.NoError(t, os.Symlink(".", filepath.Join(brain, "knowledge")))
|
|
emb := &recordingEmbedder{}
|
|
store := &stubStore{known: map[string]time.Time{"wiki/w/a.md#0001": {}}}
|
|
_, err := vectorstore.Sync(context.Background(), brain, store, emb)
|
|
assert.Error(t, err)
|
|
assert.NotContains(t, emb.all(), "session note")
|
|
for p := range store.upserts {
|
|
assert.False(t, strings.HasPrefix(p, "knowledge/"), "upserted %s", p)
|
|
}
|
|
assert.Empty(t, store.deletes)
|
|
}
|
|
|
|
// A missing scan root is skipped; the other root still syncs.
|
|
func TestSyncSkipsAMissingScanRoot(t *testing.T) {
|
|
brain := t.TempDir()
|
|
writeNote(t, brain, "wiki/w/a.md", "wiki note")
|
|
emb := &recordingEmbedder{}
|
|
_, err := vectorstore.Sync(context.Background(), brain, &stubStore{}, emb)
|
|
require.NoError(t, err)
|
|
assert.Contains(t, emb.all(), "wiki note")
|
|
}
|