Files
hyperguild/ingestion/internal/api/rawroot.go
T
mathiasandClaude Sonnet 5.5 49286ba8b8
CI / Lint / Test / Vet (pull_request) Successful in 1m2s
CI / Mirror to GitHub (pull_request) Skipped
fix(api,watcher,server): reject moves non-markdown untouched; read caps; BOM; strict routing flag (review of #211, pass 2)
- brain_reject on a parked pdf/txt moves the bytes; it no longer prepends YAML
- raw/ notes are read through a cap (readCapped); an endless reader test fails
  instead of exhausting memory
- a byte-order mark no longer hides a note's wing: key
- INGEST_WING_ROUTING is parsed strictly; a typo stops the server rather than
  leaving routing silently off
- tests for the reserved-wing check in every disguise (Sources, sources., ...)
Mutation-checked.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 20:49:10 +02:00

85 lines
2.7 KiB
Go

package api
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"github.com/mathiasbq/hyperguild/ingestion/internal/extract"
)
// 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
}
// maxRawNoteBytes bounds what promote, reject and the pending listing read from
// one raw/ entry. A variable so a test can lower it.
var maxRawNoteBytes int64 = 64 << 20
// readRawNote reads one raw/ entry by basename through the root, refusing
// anything that is not a regular file.
//
// Accepted residual: 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 live 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 assumption changes.
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 := extract.OpenRegular(r, base)
if err != nil {
return nil, nil, err
}
defer func() { _ = f.Close() }()
b, err := readCapped(f, maxRawNoteBytes)
if err != nil {
return nil, nil, fmt.Errorf("%s: %w", base, err)
}
return b, fi, nil
}
// readCapped reads r but never more than max+1 bytes, so an oversize or growing
// file costs at most that much memory.
func readCapped(r io.Reader, max int64) ([]byte, error) {
b, err := io.ReadAll(io.LimitReader(r, max+1))
if err != nil {
return nil, err
}
if int64(len(b)) > max {
return nil, fmt.Errorf("note exceeds %d bytes", max)
}
return b, nil
}