fix(search): walk and read the brain through an os.Root (#157, slice 1)
CI / Lint / Test / Vet (pull_request) Successful in 48s
CI / Mirror to GitHub (pull_request) Skipped

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>
This commit is contained in:
2026-10-03 18:18:54 +02:00
co-authored by Claude Opus 5.5
parent 687afa0145
commit 9c9ba31770
3 changed files with 250 additions and 10 deletions
+28 -10
View File
@@ -5,6 +5,7 @@ import (
"bufio"
"context"
"fmt"
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
sharedfm "github.com/mathiasbq/hyperguild/ingestion/internal/frontmatter"
"log/slog"
"os"
@@ -119,20 +120,30 @@ func QueryContext(ctx context.Context, brainDir string, opts QueryOptions) ([]Re
return nil, err
}
// Walk and read through an os.Root on the brain dir, regular files only
// (hyperguild#157): a symlink planted in the Syncthing-shared brain must
// not put an outside file in results, nor a FIFO hang the query.
bfs, err := brainfs.Open(brainDir)
if err != nil {
return nil, fmt.Errorf("search: %w", err)
}
defer func() { _ = bfs.Close() }()
var results []Result
for _, dir := range roots {
if _, statErr := os.Stat(dir); os.IsNotExist(statErr) {
continue
}
err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
if err != nil {
slog.Warn("search: skipping path", "path", path, "err", err)
relDir, relErr := bfs.Rel(dir)
if relErr != nil {
return nil, fmt.Errorf("search: %w", relErr)
}
err := bfs.WalkRegular(relDir, func(relPath string) error {
if !strings.HasSuffix(relPath, ".md") {
return nil
}
if d.IsDir() || !strings.HasSuffix(path, ".md") {
return nil
}
content, err := os.ReadFile(path)
path := filepath.Join(brainDir, filepath.FromSlash(relPath))
content, err := bfs.ReadRegular(relPath)
if err != nil {
slog.Warn("search: skipping unreadable file", "path", path, "err", err)
return nil
@@ -155,7 +166,7 @@ func QueryContext(ctx context.Context, brainDir string, opts QueryOptions) ([]Re
origin, writtenBy := extractProvenance(string(content))
results = append(results, Result{
Path: rel,
Title: extractTitle(string(content), d.Name()),
Title: extractTitle(string(content), filepath.Base(relPath)),
Excerpt: excerpt(string(content), 300),
Score: score,
Wing: wing,
@@ -166,6 +177,8 @@ func QueryContext(ctx context.Context, brainDir string, opts QueryOptions) ([]Re
WrittenBy: writtenBy,
})
return nil
}, func(p string, err error) {
slog.Warn("search: skipping path", "path", p, "err", err)
})
if err != nil {
return nil, err
@@ -275,8 +288,13 @@ func pathInScope(relPath, wing, hall string) bool {
// excerpt, wing, and hall populated. Used for paths that surface only
// via vector search.
func hydrate(brainDir, relPath string) (Result, error) {
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 Result{}, err
}
defer func() { _ = bfs.Close() }()
content, err := bfs.ReadRegular(filepath.ToSlash(relPath))
if err != nil {
return Result{}, err
}