brain_read returns a note verbatim; brain_edit is str_replace with an exactly-once match, operating on the tree brain-sync commits from. An adversarial design review before implementation changed three things: 1. Governance files are not in the model-facing tool. Brain content is ingested from the outside world and reaches a model's context, and that model would hold the editor — one poisoned page could rewrite the CLAUDE.md a .dispatch-allow agent later loads as instructions. And brain-sync merges with --strategy-option=ours, so the live tree beats a human's corrective push on the conflicting lines. CLAUDE.md now goes through brain_edit_root: de-surfaced from tools/list and gated on the local static principal. The scopes are disjoint — the privileged door has the smaller reach. 2. Reads needed the same allowlist, and brain_get already lacked it: it returned verbatim session transcripts under archive/ by path. A test proved the exposure live before the fix. 3. Canonicalise before allowlisting — knowledge/../.git/hooks/post-commit would otherwise reach a hook that brain-sync executes in a pod holding the Gitea push credentials. Plus overlapping match counting, UTF-8 validation both ways, refusal while git is mid-operation, flock with the read inside the lock, atomic same-dir rename, and reindex only when frontmatter is touched. Refs brain#4. Co-authored-by: Mathias <mthbqv@gmail.com>
257 lines
7.6 KiB
Go
257 lines
7.6 KiB
Go
package notes
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
)
|
|
|
|
func seed(t *testing.T, dir, rel, content string) string {
|
|
t.Helper()
|
|
abs := filepath.Join(dir, rel)
|
|
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(abs, []byte(content), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return abs
|
|
}
|
|
|
|
func read(t *testing.T, abs string) string {
|
|
t.Helper()
|
|
b, err := os.ReadFile(abs)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
func TestRead_ReturnsVerbatimBytes(t *testing.T) {
|
|
dir := newBrain(t)
|
|
const body = "---\ntitle: 'x'\n---\n\nbody with double spaces\r\nand a CRLF line\nno trailing newline"
|
|
seed(t, dir, "knowledge/verbatim.md", body)
|
|
|
|
got, err := Read(dir, "knowledge/verbatim.md")
|
|
if err != nil {
|
|
t.Fatalf("Read: %v", err)
|
|
}
|
|
if string(got) != body {
|
|
t.Errorf("Read returned %q, want the file byte-for-byte", got)
|
|
}
|
|
}
|
|
|
|
func TestRead_RefusesNonUTF8RatherThanCorruptingIt(t *testing.T) {
|
|
// The response is marshalled into a JSON string, and encoding/json
|
|
// replaces invalid UTF-8 with U+FFFD. Returning it would mean every
|
|
// old_str the caller derives from that region can never match, and
|
|
// pasting it back would write the corruption to disk permanently.
|
|
dir := newBrain(t)
|
|
seed(t, dir, "knowledge/bad.md", "ok \xff\xfe broken\n")
|
|
|
|
if _, err := Read(dir, "knowledge/bad.md"); err == nil {
|
|
t.Fatal("Read accepted invalid UTF-8; it must refuse rather than silently substitute")
|
|
}
|
|
}
|
|
|
|
func TestEdit_ReplacesExactlyOnceAndLeavesEveryOtherByteAlone(t *testing.T) {
|
|
dir := newBrain(t)
|
|
const body = "---\ntitle: 'x'\n---\n\nalpha\nbeta\ngamma\n"
|
|
abs := seed(t, dir, "knowledge/n.md", body)
|
|
|
|
res, err := Edit(dir, "knowledge/n.md", "beta", "BETA", ScopeNotes)
|
|
if err != nil {
|
|
t.Fatalf("Edit: %v", err)
|
|
}
|
|
want := strings.Replace(body, "beta", "BETA", 1)
|
|
if got := read(t, abs); got != want {
|
|
t.Errorf("file = %q, want %q", got, want)
|
|
}
|
|
if res.ContentHash == "" {
|
|
t.Error("result should carry a content hash for read-after-write confirmation")
|
|
}
|
|
}
|
|
|
|
func TestEdit_RefusesAmbiguousAndAbsentMatchesWithoutWriting(t *testing.T) {
|
|
dir := newBrain(t)
|
|
const body = "alpha\nalpha\n"
|
|
abs := seed(t, dir, "knowledge/n.md", body)
|
|
|
|
cases := []struct{ name, old, want string }{
|
|
{"two matches", "alpha", "2"},
|
|
{"no match", "delta", "0"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
_, err := Edit(dir, "knowledge/n.md", tc.old, "x", ScopeNotes)
|
|
if err == nil {
|
|
t.Fatal("want a loud refusal")
|
|
}
|
|
if !strings.Contains(err.Error(), tc.want) {
|
|
t.Errorf("error must state the match count, got %v", err)
|
|
}
|
|
if got := read(t, abs); got != body {
|
|
t.Errorf("file changed on a refused edit: %q", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEdit_CountsOverlappingMatches(t *testing.T) {
|
|
// bytes.Count counts NON-overlapping occurrences: "aa" in "aaa" is 1, so
|
|
// a Count-based uniqueness check calls an ambiguous edit unique.
|
|
dir := newBrain(t)
|
|
seed(t, dir, "knowledge/n.md", "aaa\n")
|
|
|
|
if _, err := Edit(dir, "knowledge/n.md", "aa", "b", ScopeNotes); err == nil {
|
|
t.Fatal("overlapping matches must be reported as ambiguous")
|
|
}
|
|
}
|
|
|
|
func TestEdit_RejectsDegenerateArguments(t *testing.T) {
|
|
dir := newBrain(t)
|
|
seed(t, dir, "knowledge/n.md", "alpha\n")
|
|
|
|
if _, err := Edit(dir, "knowledge/n.md", "", "x", ScopeNotes); err == nil {
|
|
t.Error("empty old_str must be rejected")
|
|
}
|
|
if _, err := Edit(dir, "knowledge/n.md", "alpha", "alpha", ScopeNotes); err == nil {
|
|
t.Error("a no-op edit must be rejected rather than burning a sync cycle")
|
|
}
|
|
}
|
|
|
|
func TestEdit_DeletionIsLegal(t *testing.T) {
|
|
dir := newBrain(t)
|
|
abs := seed(t, dir, "knowledge/n.md", "keep\ndrop\n")
|
|
|
|
if _, err := Edit(dir, "knowledge/n.md", "drop\n", "", ScopeNotes); err != nil {
|
|
t.Fatalf("Edit: %v", err)
|
|
}
|
|
if got := read(t, abs); got != "keep\n" {
|
|
t.Errorf("file = %q, want %q", got, "keep\n")
|
|
}
|
|
}
|
|
|
|
func TestEdit_ReportsWhenOnlyLineEndingsDiffer(t *testing.T) {
|
|
// The likeliest real failure: the caller's old_str lost its \r. Never
|
|
// normalise silently — that breaks byte-identity — but say so, because an
|
|
// opaque "no match" is unfixable from the caller's side.
|
|
dir := newBrain(t)
|
|
seed(t, dir, "knowledge/n.md", "alpha\r\nbeta\r\n")
|
|
|
|
_, err := Edit(dir, "knowledge/n.md", "alpha\nbeta", "x", ScopeNotes)
|
|
if err == nil {
|
|
t.Fatal("want a refusal")
|
|
}
|
|
if !strings.Contains(err.Error(), "line ending") {
|
|
t.Errorf("error should point at CRLF, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestEdit_RefusesWhileGitIsMidOperation(t *testing.T) {
|
|
// brain-sync runs `git merge -X ours origin/main`, and aborts on conflict
|
|
// with a hard reset. An edit landing inside that window is either erased
|
|
// or committed as a wrongly-resolved conflict, and the caller was told it
|
|
// succeeded.
|
|
dir := newBrain(t)
|
|
seed(t, dir, "knowledge/n.md", "alpha\n")
|
|
|
|
for _, marker := range []string{"MERGE_HEAD", "index.lock", "REBASE_HEAD"} {
|
|
t.Run(marker, func(t *testing.T) {
|
|
p := filepath.Join(dir, ".git", marker)
|
|
if err := os.WriteFile(p, []byte("x"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() { _ = os.Remove(p) }()
|
|
|
|
if _, err := Edit(dir, "knowledge/n.md", "alpha", "beta", ScopeNotes); err == nil {
|
|
t.Errorf("edit accepted while %s exists", marker)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEdit_RefusesAFileHoldingConflictMarkers(t *testing.T) {
|
|
dir := newBrain(t)
|
|
seed(t, dir, "knowledge/n.md", "<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> origin/main\n")
|
|
|
|
if _, err := Edit(dir, "knowledge/n.md", "ours", "x", ScopeNotes); err == nil {
|
|
t.Fatal("editing one side of an unresolved conflict must be refused")
|
|
}
|
|
}
|
|
|
|
func TestEdit_ConcurrentEditsBothApply(t *testing.T) {
|
|
// Read-modify-write with no lock loses one update AND reports success for
|
|
// both. old_str uniqueness does not protect against it: uniqueness is
|
|
// evaluated against the stale read.
|
|
dir := newBrain(t)
|
|
abs := seed(t, dir, "knowledge/n.md", "alpha\nbeta\n")
|
|
|
|
var wg sync.WaitGroup
|
|
errs := make([]error, 2)
|
|
for i, pair := range [][2]string{{"alpha", "ALPHA"}, {"beta", "BETA"}} {
|
|
wg.Add(1)
|
|
go func(i int, old, new string) {
|
|
defer wg.Done()
|
|
_, errs[i] = Edit(dir, "knowledge/n.md", old, new, ScopeNotes)
|
|
}(i, pair[0], pair[1])
|
|
}
|
|
wg.Wait()
|
|
|
|
for i, err := range errs {
|
|
if err != nil {
|
|
t.Fatalf("edit %d failed: %v", i, err)
|
|
}
|
|
}
|
|
if got := read(t, abs); got != "ALPHA\nBETA\n" {
|
|
t.Errorf("file = %q, want both edits applied", got)
|
|
}
|
|
}
|
|
|
|
func TestEdit_LeavesNoTemporaryFileBehind(t *testing.T) {
|
|
dir := newBrain(t)
|
|
seed(t, dir, "knowledge/n.md", "alpha\n")
|
|
|
|
if _, err := Edit(dir, "knowledge/n.md", "alpha", "beta", ScopeNotes); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
entries, err := os.ReadDir(filepath.Join(dir, "knowledge"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, e := range entries {
|
|
if e.Name() != "note.md" && e.Name() != "n.md" {
|
|
t.Errorf("stray file left in the tree: %q (git add -A would commit it)", e.Name())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestEdit_PreservesReadableMode(t *testing.T) {
|
|
// Other uids write this tree (brain-fix, brain-enrich). os.CreateTemp
|
|
// yields 0600 and rename preserves it, which would break them silently.
|
|
dir := newBrain(t)
|
|
abs := seed(t, dir, "knowledge/n.md", "alpha\n")
|
|
|
|
if _, err := Edit(dir, "knowledge/n.md", "alpha", "beta", ScopeNotes); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fi, err := os.Stat(abs)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if fi.Mode().Perm() != 0o644 {
|
|
t.Errorf("mode = %v, want 0644", fi.Mode().Perm())
|
|
}
|
|
}
|
|
|
|
func TestEdit_RefusesNonUTF8Replacement(t *testing.T) {
|
|
dir := newBrain(t)
|
|
seed(t, dir, "knowledge/n.md", "alpha\n")
|
|
|
|
if _, err := Edit(dir, "knowledge/n.md", "alpha", "bad \xff", ScopeNotes); err == nil {
|
|
t.Fatal("new_str with invalid UTF-8 must be refused, not written")
|
|
}
|
|
}
|