- Reverting the ingest call sites to extract.Text(path) stayed green; a test now fails if anything outside internal/extract calls extract.Text( (only TextIn, through an os.Root, may be used). - The watcher symlink test passed for a watcher that ingested nothing, or stopped at the first symlink (SkipAll: one synced link halting all ingestion). It now plants a real note after the symlink and asserts exactly one ingest, and the symlinked-raw refusal has its own test. - A FIFO swapped in between Lstat and Open hung open(2) forever. extract.OpenRegular opens non-blocking and checks the type on the handle; readRawNote, TextIn and the watcher's archive copy use it. - The symlinked-root test covers pending, promote, reject, resolve and ReadIngestFile, and is named for exactly those. - The hard-link residual is documented where it is accepted. Out of scope, filed: #157 (symlinks elsewhere in the Syncthing-shared brain dir reach search and other readers), #158 (writes under raw/ follow symlinks). Refs #153 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
68 lines
2.1 KiB
Go
68 lines
2.1 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
|
|
}
|
|
|
|
// 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 := io.ReadAll(f)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return b, fi, nil
|
|
}
|