Option 1 of #157, slice 2. readExcerpt (brain_context), api.ReadNote (brain_get / read-after-write) and notes.ReadScoped (brain_read) read the Syncthing-shared brain by path. Under a symlink swap race each leaked an outside file hundreds of thousands of times in 1.5s, including brain_read, whose Lstat check was raced by the reopen-by-name; a FIFO blocked os.ReadFile. - All three read through brainfs (os.Root, regular files only, non-blocking open, type check on the handle). - brainfs.ReadRegularMax checks a size limit on the opened handle; notes keeps its ErrTooLarge. - New internal/brainfs/brainfstest: the shared swap-race harness (symlink and held-writer FIFO swappers, awaited before TempDir cleanup; AssertNoLeak / AssertNoHang) for this and later slices. Tests red first: each reader under a symlink-swap race and a held-writer FIFO race, plus static checks. Flipped: readExcerpt back to os.ReadFile is red. Refs #157 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
204 lines
7.7 KiB
Go
204 lines
7.7 KiB
Go
package api
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
|
"github.com/mathiasbq/hyperguild/ingestion/internal/claim"
|
|
)
|
|
|
|
// ContentHash returns the lowercase hex sha256 of b. It is the note's
|
|
// content_hash handle: brain_write / brain_update return it, brain_get
|
|
// recomputes it from the file on disk, and brain_update stamps the prior
|
|
// note's hash into the new note's `supersedes` frontmatter.
|
|
func ContentHash(b []byte) string {
|
|
sum := sha256.Sum256(b)
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
// resolveWithin maps a brainDir-relative path to an absolute path and
|
|
// guarantees it does not escape brainDir. Returns the cleaned relPath
|
|
// (forward-slashed) and the absolute path.
|
|
func resolveWithin(brainDir, relPath string) (rel, abs string, err error) {
|
|
clean := filepath.Clean("/" + filepath.ToSlash(relPath))
|
|
rel = strings.TrimPrefix(clean, "/")
|
|
abs = filepath.Join(brainDir, filepath.FromSlash(rel))
|
|
check, err := filepath.Rel(brainDir, abs)
|
|
if err != nil || check == ".." || strings.HasPrefix(check, ".."+string(filepath.Separator)) {
|
|
return "", "", fmt.Errorf("path %q escapes brain dir", relPath)
|
|
}
|
|
return rel, abs, nil
|
|
}
|
|
|
|
// UpdateNoteOptions identifies the note to supersede and supplies its new
|
|
// body. Path takes precedence; otherwise the target is resolved from
|
|
// Wing/Hall/Slug via brain.NotePath.
|
|
type UpdateNoteOptions struct {
|
|
Path string // brainDir-relative path; takes precedence over wing/hall/slug
|
|
Wing string
|
|
Hall string
|
|
Slug string
|
|
Content string // new full body (whole-note replace)
|
|
Reason string // optional; stamped as supersede_reason
|
|
// Origin/WrittenBy re-stamp provenance to the updating principal: the
|
|
// body is replaced wholesale, so the last writer is the author of what
|
|
// a reader sees (hyperguild#145). Never taken from Content's fragment.
|
|
Origin string
|
|
WrittenBy string
|
|
}
|
|
|
|
// UpdateNote supersedes an existing note in place. It replaces the body
|
|
// with opts.Content, preserves the existing frontmatter (created_at,
|
|
// wing, hall, and any custom fields), and stamps updated_at, supersedes
|
|
// (the prior content hash), and supersede_reason (when given).
|
|
//
|
|
// It never creates: if the target does not exist, it returns an error so
|
|
// the caller can fall back to brain_write. Returns the note's relPath,
|
|
// the new content hash, and the prior content hash.
|
|
//
|
|
// Embeddings are NOT refreshed here. The rewritten file's mtime advances,
|
|
// which the mtime-driven vectorstore.Sync ticker uses to re-embed it on
|
|
// its next pass — the same out-of-band mechanism brain_write relies on.
|
|
func UpdateNote(brainDir string, opts UpdateNoteOptions) (relPath, contentHash, priorHash string, err error) {
|
|
if opts.Content == "" {
|
|
return "", "", "", fmt.Errorf("content is required")
|
|
}
|
|
|
|
// Same reason as WriteNote: the strip belongs in the primitive so every
|
|
// door inherits it.
|
|
clean, _, err := brain.StripUnresolvedLinks(brainDir, opts.Content)
|
|
if err != nil {
|
|
return "", "", "", err
|
|
}
|
|
opts.Content = clean
|
|
|
|
var rel string
|
|
if opts.Path != "" {
|
|
rel = opts.Path
|
|
} else {
|
|
full, perr := brain.NotePath(brainDir, opts.Wing, opts.Hall, opts.Slug)
|
|
if perr != nil {
|
|
return "", "", "", perr
|
|
}
|
|
rel, _ = filepath.Rel(brainDir, full)
|
|
rel = filepath.ToSlash(rel)
|
|
}
|
|
|
|
rel, abs, err := resolveWithin(brainDir, rel)
|
|
if err != nil {
|
|
return "", "", "", err
|
|
}
|
|
|
|
prior, err := os.ReadFile(abs)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return "", "", "", fmt.Errorf("note %q does not exist: use brain_write to create", rel)
|
|
}
|
|
return "", "", "", fmt.Errorf("read target: %w", err)
|
|
}
|
|
priorHash = ContentHash(prior)
|
|
|
|
// A caller may lead opts.Content with its own frontmatter fence to set or
|
|
// change fields on supersede (e.g. the claim envelope). Split it off
|
|
// before it reaches the body — left un-split, it would render as a
|
|
// second, inert `---`-fenced block of literal text rather than taking
|
|
// effect, and any claim-envelope fields inside it would reach disk
|
|
// unvalidated (hyperguild#111: the same rule that guards note birth in
|
|
// writeHallNote must guard a change to the same fields).
|
|
contentFM, contentBody := parseFrontmatter(opts.Content)
|
|
|
|
fm, _ := parseFrontmatter(string(prior))
|
|
for _, l := range contentFM.lines {
|
|
// updated_at/supersedes/supersede_reason are this function's own
|
|
// output, not something a caller's fragment sets — updated_at and
|
|
// supersedes are already unconditionally re-set below regardless of
|
|
// what the loop does with them, but supersede_reason is only
|
|
// re-set when opts.Reason is non-empty, so without this skip a
|
|
// caller could set it via its own fragment and have it survive
|
|
// even when Reason is empty, bypassing the one intended entry
|
|
// point for that field (hyperguild#125, found in #111's review).
|
|
// Skipping all three here rather than relying on the unconditional
|
|
// overwrites below to save the other two keeps the invariant
|
|
// explicit at the one place it can be silently broken by a future
|
|
// reordering, not implicit in call order.
|
|
if l.key == "" || l.key == "updated_at" || l.key == "supersedes" || l.key == "supersede_reason" ||
|
|
l.key == "origin" || l.key == "written_by" {
|
|
continue
|
|
}
|
|
fm.set(l.key, l.value)
|
|
}
|
|
fm.set("updated_at", time.Now().UTC().Format(time.RFC3339))
|
|
fm.set("supersedes", priorHash)
|
|
if opts.Reason != "" {
|
|
fm.set("supersede_reason", opts.Reason)
|
|
}
|
|
|
|
// Validate the envelope AFTER the merge, against the full field set that
|
|
// is about to reach disk — not just the caller's fragment. Validating the
|
|
// fragment alone would miss a caller that supersedes claim_provenance to
|
|
// empty while half_life/applies survive untouched from the prior note:
|
|
// each side looks individually fine, but the merged result is an
|
|
// unprovenanced envelope, which is the exact laundering this check
|
|
// exists to catch. (Caught in review before merge, not filed after —
|
|
// worth being honest about in the history: this second pass was the
|
|
// actual fix; the first one validated the wrong document.)
|
|
envelope := make(map[string]string, len(fm.lines))
|
|
for _, l := range fm.lines {
|
|
if l.key != "" {
|
|
envelope[l.key] = l.value
|
|
}
|
|
}
|
|
if err := claim.ValidateEnvelope(envelope); err != nil {
|
|
return "", "", "", fmt.Errorf("claim envelope: %w", err)
|
|
}
|
|
|
|
stamped, err := StampProvenance(fm.render()+contentBody, opts.Origin, opts.WrittenBy)
|
|
if err != nil {
|
|
return "", "", "", err
|
|
}
|
|
out := []byte(stamped)
|
|
if err := os.WriteFile(abs, out, 0o644); err != nil {
|
|
return "", "", "", fmt.Errorf("write: %w", err)
|
|
}
|
|
return rel, ContentHash(out), priorHash, nil
|
|
}
|
|
|
|
// ReadNote reads the note at the brainDir-relative relPath and returns
|
|
// its parsed frontmatter, body, and content hash. It is the read-after-
|
|
// write primitive behind brain_get: the hash it returns equals the hash
|
|
// brain_write / brain_update returned for the same bytes.
|
|
func ReadNote(brainDir, relPath string) (fm map[string]string, body, contentHash string, err error) {
|
|
rel, _, err := resolveWithin(brainDir, relPath)
|
|
if err != nil {
|
|
return nil, "", "", err
|
|
}
|
|
// Through the brain root, regular files only (hyperguild#157).
|
|
bfs, err := brainfs.Open(brainDir)
|
|
if err != nil {
|
|
return nil, "", "", fmt.Errorf("read note: %w", err)
|
|
}
|
|
defer func() { _ = bfs.Close() }()
|
|
raw, err := bfs.ReadRegular(rel)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, "", "", fmt.Errorf("note %q does not exist", relPath)
|
|
}
|
|
return nil, "", "", fmt.Errorf("read note: %w", err)
|
|
}
|
|
parsed, body := parseFrontmatter(string(raw))
|
|
fm = make(map[string]string, len(parsed.lines))
|
|
for _, l := range parsed.lines {
|
|
if l.key != "" {
|
|
fm[l.key] = l.value
|
|
}
|
|
}
|
|
return fm, body, ContentHash(raw), nil
|
|
}
|