graphsync.IndexDoc/BackfillFromBrainDir and wiki.LoadInventory/ RebuildIndex read notes by path (os.ReadFile, os.Open, os.ReadDir + open). A symlinked note put the target's title, aliases and wikilinks into the knowledge graph, the wiki inventory (and the LLM prompts built from it) and wiki/index.md; a FIFO hung each read. - graphsync: IndexDoc and the backfill read through brainfs; the backfill walks with WalkRegular, skips a missing root and aborts on any other walk error, including a symlinked root. - wiki: LoadInventory lists type dirs with the new brainfs.ReadDirRegular (every component a real dir, regular files only) and reads frontmatter through the root; a symlinked type dir is an error. RebuildIndex reads first sentences through the root. - brainfstest's outside file now carries the sentinel in frontmatter title and aliases, a heading and a wikilink, so extractors that ignore the body are tested too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
224 lines
7.0 KiB
Go
224 lines
7.0 KiB
Go
// Package brainfs reads the brain directory through an os.Root (hyperguild#157).
|
|
// The brain dir is a Syncthing sendreceive folder and Syncthing syncs
|
|
// symlinks; a reader that walks and reads by path follows a planted symlink
|
|
// out of the brain, and blocks forever on a planted FIFO. Readers that go
|
|
// through a Brain only ever read regular files inside the brain: os.Root
|
|
// refuses to leave the root even if an entry is swapped mid-read, and every
|
|
// open is non-blocking with the type checked on the opened handle.
|
|
package brainfs
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"strings"
|
|
"syscall"
|
|
)
|
|
|
|
// ErrNotRegular is returned for an entry that is not a regular file.
|
|
var ErrNotRegular = errors.New("not a regular file")
|
|
|
|
// ErrTooLarge is returned by ReadRegularMax for a file over the limit.
|
|
var ErrTooLarge = errors.New("file too large")
|
|
|
|
// Brain is an open brain directory.
|
|
type Brain struct {
|
|
root *os.Root
|
|
dir string
|
|
}
|
|
|
|
// Open opens brainDir (which may itself be a mount symlink) as a root.
|
|
func Open(brainDir string) (*Brain, error) {
|
|
r, err := os.OpenRoot(brainDir)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open brain root: %w", err)
|
|
}
|
|
return &Brain{root: r, dir: brainDir}, nil
|
|
}
|
|
|
|
// Close releases the root.
|
|
func (b *Brain) Close() error { return b.root.Close() }
|
|
|
|
// Rel converts a path under the brain dir to a root-relative path.
|
|
func (b *Brain) Rel(path string) (string, error) {
|
|
rel, err := filepath.Rel(b.dir, path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if rel == ".." || len(rel) > 2 && rel[:3] == ".."+string(filepath.Separator) {
|
|
return "", fmt.Errorf("%s is outside the brain dir", path)
|
|
}
|
|
return filepath.ToSlash(rel), nil
|
|
}
|
|
|
|
// Lstat stats a brain-relative path through the root without following a
|
|
// final symlink.
|
|
func (b *Brain) Lstat(rel string) (fs.FileInfo, error) {
|
|
return b.root.Lstat(filepath.ToSlash(rel))
|
|
}
|
|
|
|
// realDir returns ErrNotRegular unless every component of rel is a real
|
|
// directory. A swap after the check is not caught (accepted residual,
|
|
// hyperguild#157).
|
|
func (b *Brain) realDir(rel string) error {
|
|
if rel == "." {
|
|
return nil
|
|
}
|
|
prefix := ""
|
|
for _, part := range strings.Split(rel, "/") {
|
|
prefix = path.Join(prefix, part)
|
|
fi, err := b.root.Lstat(prefix)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !fi.IsDir() {
|
|
return fmt.Errorf("%s: walk root component %s is not a directory: %w", rel, prefix, ErrNotRegular)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ReadDirRegular lists the regular files directly in a brain-relative
|
|
// directory, by name. Every component of relDir must be a real directory
|
|
// (ErrNotRegular otherwise); symlinks, FIFOs, devices and subdirectories in
|
|
// it are left out.
|
|
func (b *Brain) ReadDirRegular(relDir string) ([]string, error) {
|
|
relDir = filepath.ToSlash(relDir)
|
|
if err := b.realDir(relDir); err != nil {
|
|
return nil, err
|
|
}
|
|
ents, err := fs.ReadDir(b.root.FS(), relDir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var names []string
|
|
for _, e := range ents {
|
|
if e.Type().IsRegular() {
|
|
names = append(names, e.Name())
|
|
}
|
|
}
|
|
return names, nil
|
|
}
|
|
|
|
// ReadRegular reads a brain-relative path, refusing anything that is not a
|
|
// regular file, both before the open and on the opened handle.
|
|
func (b *Brain) ReadRegular(rel string) ([]byte, error) {
|
|
fi, err := b.root.Lstat(rel)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !fi.Mode().IsRegular() {
|
|
return nil, fmt.Errorf("%s: %w", rel, ErrNotRegular)
|
|
}
|
|
return b.readOpened(rel)
|
|
}
|
|
|
|
// readOpened opens rel non-blocking and reads it only if the OPENED handle is
|
|
// a regular file. This is the check that matters under a race: an entry
|
|
// swapped for a FIFO after the Lstat, with a writer holding it open, would
|
|
// otherwise park io.ReadAll forever (PR #161 review: removing it hung search
|
|
// after 16 iterations).
|
|
//
|
|
// Accepted residual, as in api/rawroot.go: a HARD link to an outside file is
|
|
// a regular file and is read. Syncthing syncs contents, not links, so a peer
|
|
// cannot create one; fs.protected_hardlinks=1 and the pod's secrets are on a
|
|
// different filesystem from the brain volume, so a local caller cannot link
|
|
// them in. Refusing Nlink > 1 is the next step if either changes.
|
|
func (b *Brain) readOpened(rel string) ([]byte, error) { return b.readOpenedMax(rel, -1) }
|
|
|
|
// ReadRegularMax is ReadRegular with a size limit, checked on the opened
|
|
// handle before reading (max < 0 means no limit).
|
|
func (b *Brain) ReadRegularMax(rel string, max int64) ([]byte, error) {
|
|
fi, err := b.root.Lstat(rel)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !fi.Mode().IsRegular() {
|
|
return nil, fmt.Errorf("%s: %w", rel, ErrNotRegular)
|
|
}
|
|
return b.readOpenedMax(rel, max)
|
|
}
|
|
|
|
func (b *Brain) readOpenedMax(rel string, max int64) ([]byte, error) {
|
|
f, err := b.root.OpenFile(rel, os.O_RDONLY|syscall.O_NONBLOCK, 0)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() { _ = f.Close() }()
|
|
hfi, err := f.Stat()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !hfi.Mode().IsRegular() {
|
|
return nil, fmt.Errorf("%s: %w", rel, ErrNotRegular)
|
|
}
|
|
if max >= 0 && hfi.Size() > max {
|
|
return nil, fmt.Errorf("%s is %d bytes (limit %d): %w", rel, hfi.Size(), max, ErrTooLarge)
|
|
}
|
|
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. A package global: brainfs tests
|
|
// must not use t.Parallel.
|
|
var wrapAfterStat func(io.Reader) io.Reader
|
|
|
|
// WalkRegular walks a brain-relative directory through the root and calls fn
|
|
// for each regular file. Directories are descended; symlinks, FIFOs and
|
|
// devices found by the walk are skipped. A walk root with any component that
|
|
// is not a real directory goes to onErr as ErrNotRegular and nothing is
|
|
// yielded. An entry error is passed to onErr and the walk continues; a nil
|
|
// onErr ignores it.
|
|
//
|
|
// Accepted residual (hyperguild#157): a directory swapped for a symlink
|
|
// between the listing and the read is followed. os.Root still keeps the read
|
|
// inside the brain, but not inside relDir.
|
|
func (b *Brain) WalkRegular(relDir string, fn func(rel string) error, onErr func(rel string, err error)) error {
|
|
relDir = filepath.ToSlash(relDir)
|
|
// fs.WalkDir stats its root with a follow, and os.Root allows a symlink
|
|
// that stays inside the brain: knowledge -> . would alias the whole
|
|
// brain. Require every component of the root to be a real directory
|
|
// (Lstat checks only the last one).
|
|
err := b.realDir(relDir)
|
|
if err != nil {
|
|
if onErr != nil {
|
|
onErr(relDir, err)
|
|
}
|
|
return nil
|
|
}
|
|
return fs.WalkDir(b.root.FS(), relDir, func(p string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
if onErr != nil {
|
|
onErr(p, err)
|
|
}
|
|
if d != nil && d.IsDir() {
|
|
return fs.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
if d.IsDir() || !d.Type().IsRegular() {
|
|
return nil
|
|
}
|
|
return fn(p)
|
|
})
|
|
}
|