Files
hyperguild/ingestion/internal/notes/edit.go
T
mathiasandClaude Opus 5.5 497cd5b6f3 fix(brain_edit): snapshot, rollback and lock file go through the brain root (#192)
brain_edit did its own I/O by path around notes.Edit:
- the pre-edit snapshot used os.ReadFile, which hung on a FIFO note held
  open by a writer;
- the rollback (when the provenance stamp fails) used os.WriteFile;
- the edit lock "<note>.lock" was opened with O_CREATE by path, so a
  planted dangling .lock symlink made a successful edit create a file at
  the link's target, in the brain or outside it.

Snapshot is brainfs ReadRegular, rollback WriteRegular, and the lock is
opened and removed through new brainfs OpenLock/RemoveLock: clean path,
real parent directories, an existing lock must be regular, and the opened
handle must be the file at the path (SameFile).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:34:42 +02:00

316 lines
10 KiB
Go

package notes
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
"os"
"path/filepath"
"strings"
"sync"
"syscall"
"unicode/utf8"
)
// maxNoteBytes caps what Read will hand back. Generous for knowledge/ and
// wiki/ (the largest note today is far under it) and small enough that a
// pathological file cannot flood a caller's context.
const maxNoteBytes = 256 << 10
// Errors callers distinguish.
var (
ErrNoMatch = errors.New("notes: old_str not found")
ErrAmbiguous = errors.New("notes: old_str matches more than once")
ErrNotUTF8 = errors.New("notes: content is not valid UTF-8")
ErrGitBusy = errors.New("notes: a git operation is in progress")
ErrTooLarge = errors.New("notes: file exceeds the readable size limit")
ErrDegenerate = errors.New("notes: edit is degenerate")
ErrInConflict = errors.New("notes: file contains unresolved conflict markers")
)
// pathLocks serialises edits per file within this process. The flock below
// covers the cross-process case (two pods share the hostPath during a rolling
// update, and brain-fix/brain-enrich write the same tree).
var pathLocks sync.Map // string -> *sync.Mutex
// Result is what a successful edit reports.
type Result struct {
Path string `json:"path"`
// PrevContentHash is the sha256 of the file as it was before this edit.
// Paired with ContentHash it identifies a rewrite in an audit trail or an
// alert without carrying any of the bytes (hyperguild#97).
PrevContentHash string `json:"prev_content_hash"`
ContentHash string `json:"content_hash"`
BytesBefore int `json:"bytes_before"`
BytesAfter int `json:"bytes_after"`
ReindexError string `json:"reindex_error,omitempty"`
}
// Read returns a note's bytes verbatim.
//
// No frontmatter parsing, no excerpting, no normalisation: the entire purpose
// is to give a caller an exact basis for an edit. An excerpt from brain_query
// would be truncated and normalised, and every old_str built from one would
// miss.
func Read(brainDir, path string) ([]byte, error) {
t, err := Resolve(brainDir, path, ScopeNotes)
if err != nil {
return nil, err
}
return readContained(t)
}
// ReadScoped is Read for a caller already holding the privileged scope.
func ReadScoped(brainDir, path string, scope Scope) ([]byte, error) {
t, err := Resolve(brainDir, path, scope)
if err != nil {
return nil, err
}
return readContained(t)
}
func readContained(t Target) ([]byte, error) {
// Through the brain root, regular files only, size checked on the opened
// handle (hyperguild#157): Resolve's Lstat could be raced by a swap, and
// os.ReadFile blocked on a FIFO.
bfs, err := brainfs.Open(t.Brain)
if err != nil {
return nil, fmt.Errorf("notes: %w", err)
}
defer func() { _ = bfs.Close() }()
b, err := bfs.ReadRegularMax(t.Rel, maxNoteBytes)
if errors.Is(err, brainfs.ErrTooLarge) {
return nil, fmt.Errorf("%w: %s (limit %d)", ErrTooLarge, t.Rel, maxNoteBytes)
}
if err != nil {
return nil, fmt.Errorf("notes: read %s: %w", t.Rel, err)
}
// The MCP response marshals this into a JSON string, and encoding/json
// substitutes U+FFFD for invalid UTF-8 — silently returning different
// bytes than the file holds, which defeats "verbatim" entirely.
if !utf8.Valid(b) {
return nil, fmt.Errorf("%w: %s", ErrNotUTF8, t.Rel)
}
return b, nil
}
// Edit replaces exactly one occurrence of oldStr with newStr, in place.
//
// Fails loudly and writes nothing on: zero matches, more than one match
// (counted with overlap), degenerate arguments, invalid UTF-8, a git operation
// in flight, or unresolved conflict markers in the target.
func Edit(brainDir, path, oldStr, newStr string, scope Scope) (Result, error) {
t, err := Resolve(brainDir, path, scope)
if err != nil {
return Result{}, err
}
if oldStr == "" {
return Result{}, fmt.Errorf("%w: old_str is empty", ErrDegenerate)
}
if oldStr == newStr {
return Result{}, fmt.Errorf("%w: old_str equals new_str", ErrDegenerate)
}
if !utf8.ValidString(newStr) {
return Result{}, fmt.Errorf("%w: new_str", ErrNotUTF8)
}
// brain#2 guard: refuse to write to a tree the sync does not commit from.
if err := assertSyncSource(brainDir); err != nil {
return Result{}, err
}
if err := assertGitIdle(brainDir); err != nil {
return Result{}, err
}
mu := lockFor(t.Abs)
mu.Lock()
defer mu.Unlock()
unlock, err := flockFile(t)
if err != nil {
return Result{}, err
}
defer unlock()
// Read INSIDE the lock: the match count must be evaluated against the
// bytes we are about to overwrite, or a concurrent edit is silently lost
// while both callers are told they succeeded.
before, err := readContained(t)
if err != nil {
return Result{}, err
}
if hasConflictMarkers(before) {
return Result{}, fmt.Errorf("%w: %s", ErrInConflict, t.Rel)
}
switch n := countOverlapping(string(before), oldStr); {
case n == 0:
return Result{}, fmt.Errorf("%w in %s (0 matches)%s", ErrNoMatch, t.Rel, crlfHint(string(before), oldStr))
case n > 1:
return Result{}, fmt.Errorf("%w in %s (%d matches) — extend old_str until it is unique",
ErrAmbiguous, t.Rel, n)
}
after := strings.Replace(string(before), oldStr, newStr, 1)
if err := writeAtomic(t.Abs, []byte(after)); err != nil {
return Result{}, err
}
sum := sha256.Sum256([]byte(after))
prev := sha256.Sum256(before)
return Result{
Path: t.Rel,
PrevContentHash: hex.EncodeToString(prev[:]),
ContentHash: hex.EncodeToString(sum[:]),
BytesBefore: len(before),
BytesAfter: len(after),
}, nil
}
// countOverlapping counts occurrences allowing overlap. bytes.Count does not:
// "aa" occurs once in "aaa" by its reckoning, so a Count-based uniqueness
// check accepts an ambiguous edit as unique.
func countOverlapping(s, sub string) int {
var n, from int
for {
i := strings.Index(s[from:], sub)
if i < 0 {
return n
}
n++
from += i + 1
}
}
// crlfHint reports whether the caller's old_str would have matched if line
// endings agreed. It never normalises — that would break byte-identity — but
// an opaque "no match" is unfixable from the caller's side, and a stripped \r
// is the likeliest cause.
func crlfHint(content, oldStr string) string {
if strings.Contains(content, "\r\n") && !strings.Contains(oldStr, "\r\n") {
if strings.Contains(strings.ReplaceAll(content, "\r\n", "\n"), oldStr) {
return " — the file uses CRLF line endings and old_str does not; include the \\r"
}
}
return ""
}
func hasConflictMarkers(b []byte) bool {
s := string(b)
return strings.Contains(s, "\n<<<<<<< ") || strings.HasPrefix(s, "<<<<<<< ")
}
// assertGitIdle refuses while brain-sync (or brain-fix, or a human) has a git
// operation in flight. An edit landing between `git merge` and its abort is
// either hard-reset away or committed as a wrongly resolved conflict — and the
// caller was told it succeeded.
func assertGitIdle(brainDir string) error {
for _, marker := range []string{"MERGE_HEAD", "REBASE_HEAD", "CHERRY_PICK_HEAD", "index.lock"} {
if _, err := os.Stat(filepath.Join(brainDir, ".git", marker)); err == nil {
return fmt.Errorf("%w (.git/%s present) — retry after the sync settles", ErrGitBusy, marker)
}
}
return nil
}
func lockFor(abs string) *sync.Mutex {
v, _ := pathLocks.LoadOrStore(abs, &sync.Mutex{})
return v.(*sync.Mutex)
}
// flockFile takes an advisory lock covering other processes sharing the
// hostPath — during a RollingUpdate two ingestion pods do. The lock file is
// opened and removed through the brain root, never through a symlink
// (hyperguild#192): a planted dangling "<note>.lock" link made a successful
// edit create a file at its target.
//
// Unlock removes the lock file while still holding the flock, so a waiter
// can wake holding a lock on an orphaned inode while the next caller locks a
// fresh file: two editors at once (measured on main). After taking the
// flock, the file must still be the one at the path; otherwise retry.
func flockFile(t Target) (func(), error) {
lockRel := t.Rel + ".lock"
bfs, err := brainfs.Open(t.Brain)
if err != nil {
return nil, fmt.Errorf("notes: lock %s: %w", lockRel, err)
}
for attempt := 0; attempt < 1000; attempt++ {
f, err := bfs.OpenLock(lockRel)
if errors.Is(err, brainfs.ErrLockChanged) {
continue
}
if err != nil {
_ = bfs.Close()
return nil, fmt.Errorf("notes: lock %s: %w", lockRel, err)
}
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil {
_ = f.Close()
_ = bfs.Close()
return nil, fmt.Errorf("notes: flock %s: %w", lockRel, err)
}
if !bfs.LockIsCurrent(lockRel, f) {
_ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
_ = f.Close()
continue
}
return func() {
_ = bfs.Remove(lockRel)
_ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
_ = f.Close()
_ = bfs.Close()
}, nil
}
_ = bfs.Close()
return nil, fmt.Errorf("notes: lock %s: kept changing while locking", lockRel)
}
// writeAtomic replaces the file via a same-directory temp + rename, then
// fsyncs the directory so the rename survives a node crash.
//
// Same directory because a rename across the hostPath boundary fails with
// EXDEV; the ".tmp" suffix because the tree's .gitignore already covers it, so
// a leaked temp is not committed by `git add -A`.
func writeAtomic(abs string, content []byte) error {
dir := filepath.Dir(abs)
tmp, err := os.CreateTemp(dir, filepath.Base(abs)+".*.tmp")
if err != nil {
return fmt.Errorf("notes: temp file: %w", err)
}
tmpName := tmp.Name()
defer func() { _ = os.Remove(tmpName) }() // no-op once renamed
if _, err := tmp.Write(content); err != nil {
_ = tmp.Close()
return fmt.Errorf("notes: write temp: %w", err)
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
return fmt.Errorf("notes: sync temp: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("notes: close temp: %w", err)
}
// CreateTemp makes 0600; every other writer of this tree uses 0644 and
// other uids read it.
if err := os.Chmod(tmpName, 0o644); err != nil {
return fmt.Errorf("notes: chmod temp: %w", err)
}
if err := os.Rename(tmpName, abs); err != nil {
return fmt.Errorf("notes: rename: %w", err)
}
return fsyncDir(dir)
}
func fsyncDir(dir string) error {
d, err := os.Open(dir)
if err != nil {
return fmt.Errorf("notes: open dir: %w", err)
}
defer func() { _ = d.Close() }()
if err := d.Sync(); err != nil {
return fmt.Errorf("notes: fsync dir: %w", err)
}
return nil
}