os.Root follows an intermediate directory symlink that stays inside the brain. A dir symlink inside wiki/ pointing elsewhere in the brain let brain_update and brain_tunnel rewrite an existing .md there (e.g. under raw/), and session_log followed a symlinked sessions dir. - WriteRegular, ReadRegular and ReadRegularMax require every directory above the file to be a real directory (realDir). Reads too: brain_tunnel read the redirected target, then rewrote the source before the target's write was refused; and brain_read/brain_get could read another part of the brain through such a path. - session.Append refuses a sessions dir that is not a real directory. - A directory swapped in after the check remains the #167 residual. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
304 lines
9.8 KiB
Go
304 lines
9.8 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
|
|
}
|
|
|
|
// ReadDirDirs lists the real subdirectories directly in a brain-relative
|
|
// directory, by name. Every component of relDir must be a real directory
|
|
// (ErrNotRegular otherwise); symlinks to directories are left out.
|
|
func (b *Brain) ReadDirDirs(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().IsDir() {
|
|
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) {
|
|
if err := b.realDir(path.Dir(filepath.ToSlash(rel))); err != nil {
|
|
return nil, err
|
|
}
|
|
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, -1, fi)
|
|
}
|
|
|
|
// 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, nil) }
|
|
|
|
// 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) {
|
|
if err := b.realDir(path.Dir(filepath.ToSlash(rel))); err != nil {
|
|
return nil, err
|
|
}
|
|
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, fi)
|
|
}
|
|
|
|
// readOpenedMax opens rel and reads it if the opened handle is a regular file
|
|
// and, when lfi is set, the same file the caller Lstat-ed: os.Root follows an
|
|
// in-root final symlink even with O_NOFOLLOW, so a symlink to another brain
|
|
// file swapped in between the Lstat and the open is refused only here.
|
|
func (b *Brain) readOpenedMax(rel string, max int64, lfi fs.FileInfo) ([]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 lfi != nil && !os.SameFile(lfi, hfi) {
|
|
return nil, fmt.Errorf("%s: changed while opening: %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)
|
|
})
|
|
}
|
|
|
|
// WriteRegular replaces the contents of an existing regular file at a
|
|
// brain-relative path. It never creates a file and never writes through a
|
|
// final symlink: os.Root.OpenFile follows an in-root final symlink even with
|
|
// O_NOFOLLOW (verified on go1.27), so the path is Lstat-ed through the root
|
|
// first and the opened handle must be that same file (os.SameFile), which
|
|
// also catches a swap between the two. O_NONBLOCK keeps a FIFO from blocking
|
|
// the open. Every directory above the file must be a real directory
|
|
// (hyperguild#173); a directory swapped in after that check is the #167
|
|
// residual.
|
|
func (b *Brain) WriteRegular(rel string, data []byte) error {
|
|
rel = filepath.ToSlash(rel)
|
|
// Every directory above the file must be real: os.Root follows an
|
|
// in-brain intermediate symlink, which would redirect the write
|
|
// elsewhere in the brain (hyperguild#173). A swap after this check is
|
|
// the #167 residual.
|
|
if err := b.realDir(path.Dir(rel)); err != nil {
|
|
return err
|
|
}
|
|
lfi, err := b.root.Lstat(rel)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !lfi.Mode().IsRegular() {
|
|
return fmt.Errorf("%s: %w", rel, ErrNotRegular)
|
|
}
|
|
f, err := b.root.OpenFile(rel, os.O_WRONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = f.Close() }()
|
|
fi, err := f.Stat()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !fi.Mode().IsRegular() || !os.SameFile(lfi, fi) {
|
|
return fmt.Errorf("%s: changed while opening: %w", rel, ErrNotRegular)
|
|
}
|
|
if err := f.Truncate(0); err != nil {
|
|
return err
|
|
}
|
|
if _, err := f.Write(data); err != nil {
|
|
return err
|
|
}
|
|
return f.Close()
|
|
}
|