brain_tunnel accepts only clean wiki/.../*.md paths and reads/rewrites both notes through brainfs; new brainfs.WriteRegular never creates, never writes through a final symlink (Lstat + O_NOFOLLOW|O_NONBLOCK + os.SameFile on the handle, since os.Root follows in-root final symlinks even with O_NOFOLLOW). brain_update resolves like brain_edit (knowledge/ and wiki/ notes only) and reads/rewrites through brainfs. session_log requires a plain-name session id and opens with O_NOFOLLOW|O_NONBLOCK. Fixes #169. Adversarial review: BLOCKED, BLOCKED, PASS (round 3 by the estate session's reviewer). Follow-ups: #173, #174. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Mathias <mthbqv@gmail.com>
This commit was merged in pull request #170.
This commit is contained in:
@@ -3,15 +3,18 @@ package api
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/claim"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/notes"
|
||||
)
|
||||
|
||||
// ContentHash returns the lowercase hex sha256 of b. It is the note's
|
||||
@@ -91,14 +94,23 @@ func UpdateNote(brainDir string, opts UpdateNoteOptions) (relPath, contentHash,
|
||||
rel = filepath.ToSlash(rel)
|
||||
}
|
||||
|
||||
rel, abs, err := resolveWithin(brainDir, rel)
|
||||
// The path is caller-supplied (brain_update): resolve it as brain_edit
|
||||
// does, knowledge/ and wiki/ notes only, and read and rewrite it through
|
||||
// the brain root, never through a symlink (hyperguild#169).
|
||||
t, err := notes.Resolve(brainDir, rel, notes.ScopeNotes)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
prior, err := os.ReadFile(abs)
|
||||
rel = t.Rel
|
||||
bfs, err := brainfs.Open(brainDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", "", "", fmt.Errorf("open brain: %w", err)
|
||||
}
|
||||
defer func() { _ = bfs.Close() }()
|
||||
|
||||
prior, err := bfs.ReadRegular(rel)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return "", "", "", fmt.Errorf("note %q does not exist: use brain_write to create", rel)
|
||||
}
|
||||
return "", "", "", fmt.Errorf("read target: %w", err)
|
||||
@@ -164,7 +176,7 @@ func UpdateNote(brainDir string, opts UpdateNoteOptions) (relPath, contentHash,
|
||||
return "", "", "", err
|
||||
}
|
||||
out := []byte(stamped)
|
||||
if err := os.WriteFile(abs, out, 0o644); err != nil {
|
||||
if err := bfs.WriteRegular(rel, out); err != nil {
|
||||
return "", "", "", fmt.Errorf("write: %w", err)
|
||||
}
|
||||
return rel, ContentHash(out), priorHash, nil
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
||||
)
|
||||
|
||||
// brain_update passed the caller's path through resolveWithin, a lexical
|
||||
// containment check only: any file in the brain could be superseded
|
||||
// (classification.yaml, raw/, sessions/), and a final symlink was written
|
||||
// through. It now resolves like brain_edit: knowledge/ and wiki/ notes only,
|
||||
// read and rewritten through the brain root.
|
||||
func TestUpdateNoteRefusesPathsOutsideKnowledgeAndWikiNotes(t *testing.T) {
|
||||
brain := t.TempDir()
|
||||
files := map[string]string{
|
||||
"classification.yaml": "levels: {}\n",
|
||||
"raw/r.md": "raw\n",
|
||||
"sessions/s.jsonl": "{}\n",
|
||||
"wiki/w/facts/ok.md": "---\ntitle: ok\n---\nold\n",
|
||||
"knowledge/k.md": "---\ntitle: k\n---\nold\n",
|
||||
"wiki/w/facts/x.txt": "txt\n",
|
||||
}
|
||||
for rel, body := range files {
|
||||
p := filepath.Join(brain, filepath.FromSlash(rel))
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(p), 0o755))
|
||||
require.NoError(t, os.WriteFile(p, []byte(body), 0o644))
|
||||
}
|
||||
for _, rel := range []string{"classification.yaml", "raw/r.md", "sessions/s.jsonl", "wiki/w/facts/x.txt", "wiki/w/../../raw/r.md"} {
|
||||
_, _, _, err := api.UpdateNote(brain, api.UpdateNoteOptions{Path: rel, Content: "replaced"})
|
||||
assert.Error(t, err, rel)
|
||||
}
|
||||
for rel, body := range files {
|
||||
if rel == "wiki/w/facts/ok.md" || rel == "knowledge/k.md" {
|
||||
continue
|
||||
}
|
||||
got, err := os.ReadFile(filepath.Join(brain, filepath.FromSlash(rel)))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, body, string(got), rel)
|
||||
}
|
||||
for _, rel := range []string{"wiki/w/facts/ok.md", "knowledge/k.md"} {
|
||||
_, _, _, err := api.UpdateNote(brain, api.UpdateNoteOptions{Path: rel, Content: "new body"})
|
||||
require.NoError(t, err, "positive control: %s", rel)
|
||||
got, _ := os.ReadFile(filepath.Join(brain, filepath.FromSlash(rel)))
|
||||
assert.Contains(t, string(got), "new body")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateNoteDoesNotWriteThroughASymlinkedNote(t *testing.T) {
|
||||
brain := t.TempDir()
|
||||
facts := filepath.Join(brain, "wiki", "w", "facts")
|
||||
require.NoError(t, os.MkdirAll(facts, 0o755))
|
||||
victim := filepath.Join(brain, "raw", "victim.md")
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(victim), 0o755))
|
||||
require.NoError(t, os.WriteFile(victim, []byte("untouched\n"), 0o644))
|
||||
require.NoError(t, os.Symlink(filepath.Join("..", "..", "..", "raw", "victim.md"), filepath.Join(facts, "l.md")))
|
||||
_, _, _, err := api.UpdateNote(brain, api.UpdateNoteOptions{Path: "wiki/w/facts/l.md", Content: "replaced"})
|
||||
assert.Error(t, err)
|
||||
got, _ := os.ReadFile(victim)
|
||||
assert.Equal(t, "untouched\n", string(got))
|
||||
}
|
||||
|
||||
// The note is swapped between a regular file and an in-brain symlink to a
|
||||
// raw/ file while UpdateNote runs: the read and the write each refuse a
|
||||
// symlink, so the raw/ file is never rewritten.
|
||||
func TestUpdateNoteNeverRewritesASymlinkSwappedInForTheNote(t *testing.T) {
|
||||
brain := t.TempDir()
|
||||
facts := filepath.Join(brain, "wiki", "w", "facts")
|
||||
require.NoError(t, os.MkdirAll(facts, 0o755))
|
||||
victim := filepath.Join(brain, "raw", "victim.md")
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(victim), 0o755))
|
||||
require.NoError(t, os.WriteFile(victim, []byte("untouched\n"), 0o644))
|
||||
note := filepath.Join(facts, "n.md")
|
||||
body := "---\ntitle: n\n---\nold\n"
|
||||
require.NoError(t, os.WriteFile(note, []byte(body), 0o644))
|
||||
|
||||
var stop atomic.Bool
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
tmp := note + ".swap"
|
||||
for i := 0; !stop.Load(); i++ {
|
||||
_ = os.Remove(tmp)
|
||||
if i%2 == 0 {
|
||||
_ = os.WriteFile(tmp, []byte(body), 0o644)
|
||||
} else {
|
||||
_ = os.Symlink(filepath.Join("..", "..", "..", "raw", "victim.md"), tmp)
|
||||
}
|
||||
_ = os.Rename(tmp, note)
|
||||
}
|
||||
}()
|
||||
ok := 0
|
||||
for deadline := time.Now().Add(1500 * time.Millisecond); time.Now().Before(deadline); {
|
||||
if _, _, _, err := api.UpdateNote(brain, api.UpdateNoteOptions{Path: "wiki/w/facts/n.md", Content: "new"}); err == nil {
|
||||
ok++
|
||||
}
|
||||
}
|
||||
stop.Store(true)
|
||||
wg.Wait()
|
||||
got, _ := os.ReadFile(victim)
|
||||
assert.Equal(t, "untouched\n", string(got))
|
||||
assert.Positive(t, ok, "positive control: some updates reach the regular note")
|
||||
}
|
||||
Reference in New Issue
Block a user