raw/ is writable via Syncthing (a sendreceive folder shared with flamingo, symlinks synced) and by the brain CronJobs, and several readers followed symlinks out of it: - /pending and brain_pending showed a symlink target's contents; brain_promote would copy it into wiki/ (then brain-sync to Gitea); brain_reject would copy it into raw/rejected/. - The watcher, which needs no API call at all, ingested symlinked files (WalkDir yields them; extract.Text followed them). Not in the issue; found while fixing it. - Ingest checked a path then reopened it by name (TOCTOU). OpenRawRoot opens brain/raw as an os.Root and refuses a raw/ that is itself a symlink. ListPending, PromoteNote and RejectNote read through it and refuse anything not a regular file. extract.TextIn reads through a root (temp copy for pdf/docmark); /ingest-path, brain_ingest and the watcher use it, and the watcher archives via the root too, so a file swapped for a symlink after a check still cannot lead out. Refs #153 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
60 lines
1.7 KiB
Go
60 lines
1.7 KiB
Go
package api
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
)
|
|
|
|
// errNotANote is returned for a raw/ entry that is not a regular file — in
|
|
// particular a symlink, whose target may be anywhere (hyperguild#153).
|
|
var errNotANote = errors.New("not a regular note file in raw/ (symlinks are refused)")
|
|
|
|
// errRawNotDir means brain/raw is not a real directory (e.g. a symlink), so
|
|
// containment inside it would mean nothing (hyperguild#153 item 4).
|
|
var errRawNotDir = errors.New("brain/raw is not a real directory")
|
|
|
|
// OpenRawRoot opens brain/raw as an os.Root, refusing a raw/ that is itself a
|
|
// symlink. Reads through the root cannot leave it, even if an entry is
|
|
// swapped for a symlink between a check and the read. It returns the root
|
|
// and raw/'s path. A missing raw/ returns an error wrapping os.ErrNotExist.
|
|
func OpenRawRoot(brainDir string) (*os.Root, string, error) {
|
|
p := filepath.Join(brainDir, "raw")
|
|
fi, err := os.Lstat(p)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
if fi.Mode()&os.ModeSymlink != 0 || !fi.IsDir() {
|
|
return nil, "", errRawNotDir
|
|
}
|
|
r, err := os.OpenRoot(p)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("open raw root: %w", err)
|
|
}
|
|
return r, p, nil
|
|
}
|
|
|
|
// readRawNote reads one raw/ entry by basename through the root, refusing
|
|
// anything that is not a regular file.
|
|
func readRawNote(r *os.Root, base string) ([]byte, os.FileInfo, error) {
|
|
fi, err := r.Lstat(base)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if !fi.Mode().IsRegular() {
|
|
return nil, nil, errNotANote
|
|
}
|
|
f, err := r.Open(base)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
defer func() { _ = f.Close() }()
|
|
b, err := io.ReadAll(f)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return b, fi, nil
|
|
}
|