Compare commits

...
Author SHA1 Message Date
mathiasandClaude Opus 5.5 47354dc4fc fix(brain_edit): snapshot, rollback and lock file go through the brain root (#192)
CI / Lint / Test / Vet (pull_request) Successful in 1m7s
CI / Mirror to GitHub (pull_request) Skipped
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:24:41 +02:00
mathiasandClaude Opus 5.5 bd989f887f fix(linkcheck): keep the write when the link index cannot be built (#178) (#191)
CI / Lint / Test / Vet (push) Successful in 1m4s
CI / Mirror to GitHub (push) Successful in 4s
StripUnresolvedLinks logs a warning and returns the content unchanged when the link index cannot be built (e.g. an unreadable subdirectory), instead of failing every brain_write/brain_update and losing the learning. Nothing is unwrapped on a partial index.

Fixes #178.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Mathias <mthbqv@gmail.com>
2026-10-05 21:16:04 +00:00
mathiasandClaude Opus 5.5 4770f97ffd fix(symlinkguard): report and remove FIFOs, sockets and devices too (#179) (#190)
CI / Lint / Test / Vet (push) Successful in 1m5s
CI / Mirror to GitHub (push) Successful in 4s
Scan reports special files (not regular, directory or symlink) in Result.Special, so the startup check refuses with a message naming them; Watch removes them through an os.Root after re-checking the type, and reports any it cannot remove (never as removed).

Fixes #179. Adversarial review: BLOCKED, BLOCKED, PASS. Flake on main tracked in #193.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Mathias <mthbqv@gmail.com>
2026-10-05 21:15:57 +00:00
mathiasandClaude Opus 5.5 ddd4bdc2ac fix(brainfs): refuse a file behind a symlinked directory for reads and writes (#173) (#187)
CI / Lint / Test / Vet (push) Successful in 1m2s
CI / Mirror to GitHub (push) Successful in 3s
ReadRegular, ReadRegularMax and WriteRegular require a clean, local path and a real directory at every level above the file, so an in-brain directory symlink (or a ".." after one) can no longer redirect brain_update, brain_tunnel, brain_read/get or the other readers elsewhere in the brain. session.Append opens through an os.Root on the brain dir, refuses a non-directory sessions dir, and writes only after confirming the log path is the regular file it opened.

Fixes #173. Adversarial review: BLOCKED, PASS. Follow-up: #188 (brain_write create path).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Mathias <mthbqv@gmail.com>
2026-10-05 20:17:37 +00:00
mathiasandClaude Opus 5.5 1fff54769d fix(tunnel): DetectTunnels walks and reads through brainfs (#174, #157 slice 5b) (#186)
CI / Lint / Test / Vet (push) Successful in 1m2s
CI / Mirror to GitHub (push) Successful in 4s
DetectTunnels (inside every brain_write/brain_update via AutoTunnel) walks with brainfs.WalkRegular and reads titles with ReadRegular: a FIFO under wiki/ no longer hangs the write, and a symlinked note's title no longer becomes a tunnel candidate. A wiki/ that is not a real directory yields no candidates; other walk errors still abort.

Fixes #174. Refs #157 (last reader slice). Adversarial review: BLOCKED, PASS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Mathias <mthbqv@gmail.com>
2026-10-05 20:12:44 +00:00
mathias 0c3e82ca0a test(brainfs): per-caller in-brain symlink targets for slices 1-3 (#171) (#172)
CI / Lint / Test / Vet (push) Successful in 1m2s
CI / Mirror to GitHub (push) Successful in 4s
Co-authored-by: Mathias <mthbqv@gmail.com>
2026-10-05 19:36:31 +00:00
mathiasandClaude Opus 5.5 eedb110be5 fix(pipeline,classification): read the brain through brainfs (#157, slice 6) (#176)
CI / Lint / Test / Vet (push) Successful in 1m1s
CI / Mirror to GitHub (push) Successful in 4s
Ingest pipeline readers (readSourcePage and loadSchema feed the LLM prompt; reviseOrCreate; injectSourceRefs; BackfillRefs list and reads) and classification.Load read through brainfs: symlinked or special files no longer reach the prompt, written pages or the I1 sovereignty config, and a FIFO no longer hangs ingest or startup. A symlinked/special classification.yaml is a hard error; an absent one still yields defaults.

Refs #157. Adversarial review: BLOCKED, BLOCKED, PASS. Follow-ups: #158 (write paths), #179 (FIFO tripwire).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Mathias <mthbqv@gmail.com>
2026-10-05 18:54:42 +00:00
31 changed files with 1197 additions and 83 deletions
@@ -4,6 +4,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"syscall"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -63,3 +64,13 @@ func TestStartupSymlinkCheckRefusesAnUnlistableBrainRoot(t *testing.T) {
t.Cleanup(func() { _ = os.Chmod(brain, 0o755) }) t.Cleanup(func() { _ = os.Chmod(brain, 0o755) })
assert.Error(t, startupSymlinkCheck(brain)) assert.Error(t, startupSymlinkCheck(brain))
} }
// hyperguild#179: a FIFO in the brain is refused at startup with a clear
// message, instead of hanging or exiting later in a reader.
func TestStartupSymlinkCheckRefusesABrainWithAFIFO(t *testing.T) {
brain := t.TempDir()
require.NoError(t, syscall.Mkfifo(filepath.Join(brain, "classification.yaml"), 0o644))
err := startupSymlinkCheck(brain)
require.Error(t, err)
assert.Contains(t, err.Error(), "classification.yaml")
}
+1 -1
View File
@@ -68,7 +68,7 @@ require (
github.com/segmentio/asm v1.2.0 // indirect github.com/segmentio/asm v1.2.0 // indirect
golang.org/x/crypto v0.51.0 // indirect golang.org/x/crypto v0.51.0 // indirect
golang.org/x/sync v0.21.0 // indirect golang.org/x/sync v0.21.0 // indirect
golang.org/x/sys v0.45.0 // indirect golang.org/x/sys v0.45.0
golang.org/x/text v0.39.0 // indirect golang.org/x/text v0.39.0 // indirect
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
@@ -23,6 +23,13 @@ func TestReadNoteRefusesSymlinksAndSurvivesSwapRaces(t *testing.T) {
_, body, _, err := api.ReadNote(brain, "wiki/w/facts/leak.md") _, body, _, err := api.ReadNote(brain, "wiki/w/facts/leak.md")
assert.Error(t, err) assert.Error(t, err)
assert.NotContains(t, body, brainfstest.Sentinel) assert.NotContains(t, body, brainfstest.Sentinel)
// A link to a file elsewhere in the brain: os.Root allows it, only the
// regular-file check refuses it (hyperguild#171).
brainfstest.WriteSentinelAt(t, filepath.Join(brain, "raw", "s.md"))
require.NoError(t, os.Symlink(filepath.Join("..", "..", "..", "raw", "s.md"), filepath.Join(facts, "inlink.md")))
_, body, _, err = api.ReadNote(brain, "wiki/w/facts/inlink.md")
assert.Error(t, err)
assert.NotContains(t, body, brainfstest.Sentinel)
p := filepath.Join(facts, "r.md") p := filepath.Join(facts, "r.md")
require.NoError(t, os.WriteFile(p, []byte("---\nx: y\n---\ninside"), 0o644)) require.NoError(t, os.WriteFile(p, []byte("---\nx: y\n---\ninside"), 0o644))
@@ -39,5 +46,7 @@ func TestReadNoteRefusesSymlinksAndSurvivesSwapRaces(t *testing.T) {
return s return s
} }
brainfstest.AssertNoLeak(t, p, "---\nx: y\n---\ninside", 1500*time.Millisecond, read) brainfstest.AssertNoLeak(t, p, "---\nx: y\n---\ninside", 1500*time.Millisecond, read)
inBrain := brainfstest.WriteSentinelAt(t, filepath.Join(brain, "raw", "s.md"))
brainfstest.AssertNoLeakFrom(t, p, "---\nx: y\n---\ninside", inBrain, 1500*time.Millisecond, read)
brainfstest.AssertNoHang(t, p, "---\nx: y\n---\ninside", 1500*time.Millisecond, 6*time.Second, func() { _ = read() }) brainfstest.AssertNoHang(t, p, "---\nx: y\n---\ninside", 1500*time.Millisecond, 6*time.Second, func() { _ = read() })
} }
@@ -0,0 +1,36 @@
package api_test
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
)
// hyperguild#173: a directory symlink inside wiki/ that points elsewhere in
// the brain redirected brain_update's write (os.Root follows in-root
// intermediate symlinks). Every directory above the note must be real.
func TestUpdateNoteRefusesANoteBehindASymlinkedDirectory(t *testing.T) {
brain := t.TempDir()
victim := filepath.Join(brain, "raw", "facts", "v.md")
require.NoError(t, os.MkdirAll(filepath.Dir(victim), 0o755))
require.NoError(t, os.WriteFile(victim, []byte("---\ntitle: v\n---\nuntouched\n"), 0o644))
require.NoError(t, os.MkdirAll(filepath.Join(brain, "wiki", "w"), 0o755))
require.NoError(t, os.Symlink(filepath.Join("..", "..", "raw", "facts"), filepath.Join(brain, "wiki", "w", "alias")))
_, _, _, err := api.UpdateNote(brain, api.UpdateNoteOptions{Path: "wiki/w/alias/v.md", Content: "replaced"})
assert.ErrorIs(t, err, brainfs.ErrNotRegular)
got, _ := os.ReadFile(victim)
assert.Contains(t, string(got), "untouched")
real := filepath.Join(brain, "wiki", "w", "facts", "r.md")
require.NoError(t, os.MkdirAll(filepath.Dir(real), 0o755))
require.NoError(t, os.WriteFile(real, []byte("---\ntitle: r\n---\nold\n"), 0o644))
_, _, _, err = api.UpdateNote(brain, api.UpdateNoteOptions{Path: "wiki/w/facts/r.md", Content: "new"})
require.NoError(t, err, "positive control: a note in a real directory is updated")
}
@@ -0,0 +1,34 @@
package api_test
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
)
// hyperguild#178: an unreadable directory under wiki/ made the link check
// fail and brain_write refuse the note, losing the learning. The note is now
// written with its links as given.
func TestWriteNoteKeepsTheLearningWhenTheLinkCheckCannotIndex(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("root ignores directory permissions")
}
brain := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(brain, "wiki", "w", "facts"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(brain, "wiki", "w", "facts", "real.md"), []byte("x"), 0o644))
locked := filepath.Join(brain, "wiki", "other", "locked")
require.NoError(t, os.MkdirAll(locked, 0o755))
require.NoError(t, os.Chmod(locked, 0o000))
t.Cleanup(func() { _ = os.Chmod(locked, 0o755) })
rel, err := api.WriteNote(brain, api.WriteNoteOptions{Wing: "w", Hall: "facts", Filename: "n", Content: "learning [[real]] and [[gone]]"})
require.NoError(t, err)
got, err := os.ReadFile(filepath.Join(brain, filepath.FromSlash(rel)))
require.NoError(t, err)
assert.Contains(t, string(got), "[[gone]]", "links kept as given when the index is partial")
}
@@ -0,0 +1,125 @@
package brain_test
import (
"fmt"
"os"
"path/filepath"
"syscall"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs/brainfstest"
)
// hyperguild#174 / #157 slice 5b: DetectTunnels walked wiki/ and opened
// every note with a blocking os.Open, so a FIFO named *.md anywhere under
// wiki/ hung every brain_write and brain_update (wikiUpkeep -> AutoTunnel),
// and a symlinked note's title became a tunnel candidate.
func TestDetectTunnelsReturnsPromptlyWithAFIFOUnderWikiWithAndWithoutAWriter(t *testing.T) {
for _, withWriter := range []bool{false, true} {
t.Run(fmt.Sprintf("writer=%v", withWriter), func(t *testing.T) {
b := t.TempDir()
facts := filepath.Join(b, "wiki", "w", "facts")
require.NoError(t, os.MkdirAll(facts, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(facts, "ok.md"), []byte("---\ntitle: Zqinside Phrase\n---\n"), 0o644))
fifo := filepath.Join(facts, "f.md")
require.NoError(t, syscall.Mkfifo(fifo, 0o644))
if withWriter {
// A writer held open: a reader that opens the FIFO gets no
// EOF and parks, unless the FIFO is never opened.
w, err := os.OpenFile(fifo, os.O_RDWR, 0)
require.NoError(t, err)
t.Cleanup(func() { _ = w.Close() })
}
done := make(chan []brain.TunnelCandidate, 1)
go func() {
cs, _ := brain.DetectTunnels(b, "text mentioning zqinside phrase here")
done <- cs
}()
select {
case cs := <-done:
assert.Contains(t, fmt.Sprintf("%+v", cs), "Zqinside Phrase", "positive control")
case <-time.After(3 * time.Second):
if r, err := os.OpenFile(fifo, os.O_WRONLY|syscall.O_NONBLOCK, 0); err == nil {
_ = r.Close()
}
t.Fatal("DetectTunnels blocked on a FIFO")
}
})
}
}
func TestDetectTunnelsRefusesSymlinkedNotesAndSurvivesSwapRaces(t *testing.T) {
b := t.TempDir()
facts := filepath.Join(b, "wiki", "w", "facts")
require.NoError(t, os.MkdirAll(facts, 0o755))
// The static note's title differs from the raced note's, so the
// harness's "inside was read" floor can only be met by reading r.md.
require.NoError(t, os.WriteFile(filepath.Join(facts, "ok.md"), []byte("---\ntitle: Zqstatic Words\n---\n"), 0o644))
require.NoError(t, os.Symlink(brainfstest.Outside(t), filepath.Join(facts, "out.md")))
inBrain := brainfstest.WriteSentinelAt(t, filepath.Join(b, "raw", "s.md"))
require.NoError(t, os.Symlink(filepath.Join("..", "..", "..", "raw", "s.md"), filepath.Join(facts, "in.md")))
content := "zqstatic words, zqinside phrase and " + brainfstest.Sentinel
cs, err := brain.DetectTunnels(b, content)
require.NoError(t, err)
got := fmt.Sprintf("%+v", cs)
assert.Contains(t, got, "Zqstatic Words", "positive control")
assert.NotContains(t, got, brainfstest.Sentinel)
p := filepath.Join(facts, "r.md")
read := func() string {
cs, _ := brain.DetectTunnels(b, content)
return fmt.Sprintf("%+v", cs)
}
brainfstest.AssertNoLeak(t, p, "---\ntitle: Zqinside Phrase\n---\n", 1500*time.Millisecond, read)
brainfstest.AssertNoLeakFrom(t, p, "---\ntitle: Zqinside Phrase\n---\n", inBrain, 1500*time.Millisecond, read)
}
// A wiki/ that is not a real directory yields no candidates and no error:
// AutoTunnel runs inside brain_write.
func TestDetectTunnelsSkipsAWikiThatIsNotARealDirectory(t *testing.T) {
b := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(b, "raw", "w", "facts"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(b, "raw", "w", "facts", "r.md"), []byte("---\ntitle: Zqrawonly Phrase\n---\n"), 0o644))
require.NoError(t, os.Symlink("raw", filepath.Join(b, "wiki")))
cs, err := brain.DetectTunnels(b, "zqrawonly phrase")
require.NoError(t, err)
assert.Empty(t, cs)
}
// An unreadable directory under wiki/ is a walk error and DetectTunnels
// reports it, as filepath.WalkDir did; it is not silently dropped.
func TestDetectTunnelsReportsAnUnreadableDirectoryUnderWiki(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("root ignores directory permissions")
}
b := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(b, "wiki", "w", "facts"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(b, "wiki", "w", "facts", "ok.md"), []byte("---\ntitle: Zqinside Phrase\n---\n"), 0o644))
locked := filepath.Join(b, "wiki", "other", "locked")
require.NoError(t, os.MkdirAll(locked, 0o755))
require.NoError(t, os.Chmod(locked, 0o000))
t.Cleanup(func() { _ = os.Chmod(locked, 0o755) })
_, err := brain.DetectTunnels(b, "zqinside phrase")
assert.ErrorIs(t, err, os.ErrPermission)
}
// _index.md files are generated, not notes: their title never becomes a
// candidate.
func TestDetectTunnelsSkipsIndexFiles(t *testing.T) {
b := t.TempDir()
w := filepath.Join(b, "wiki", "w")
require.NoError(t, os.MkdirAll(filepath.Join(w, "facts"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(w, "_index.md"), []byte("---\ntitle: Zqindex Words\n---\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(w, "facts", "ok.md"), []byte("---\ntitle: Zqinside Phrase\n---\n"), 0o644))
cs, err := brain.DetectTunnels(b, "zqindex words and zqinside phrase")
require.NoError(t, err)
got := fmt.Sprintf("%+v", cs)
assert.Contains(t, got, "Zqinside Phrase", "positive control")
assert.NotContains(t, got, "Zqindex")
}
-17
View File
@@ -116,23 +116,6 @@ func collectWingEntries(bfs *brainfs.Brain, wingRel string) ([]noteEntry, error)
return out, nil return out, nil
} }
// readTitleAndCreated reads YAML frontmatter for title + created_at; falls
// back to slug and file mtime when absent.
func readTitleAndCreated(path, slug string) (string, string) {
f, err := os.Open(path)
if err != nil {
return slug, ""
}
defer func() { _ = f.Close() }()
title, created := parseTitleAndCreated(f)
if created == "" {
if info, err := os.Stat(path); err == nil {
created = info.ModTime().UTC().Format("2006-01-02")
}
}
return titleOrSlug(title, slug), created
}
// readTitleAndCreatedIn is readTitleAndCreated through the brain root, // readTitleAndCreatedIn is readTitleAndCreated through the brain root,
// regular files only (hyperguild#157). // regular files only (hyperguild#157).
func readTitleAndCreatedIn(bfs *brainfs.Brain, rel, slug string) (string, string) { func readTitleAndCreatedIn(bfs *brainfs.Brain, rel, slug string) (string, string) {
+6 -1
View File
@@ -47,7 +47,12 @@ var (
func StripUnresolvedLinks(brainDir, content string) (string, []string, error) { func StripUnresolvedLinks(brainDir, content string) (string, []string, error) {
index, err := entryIndex(brainDir) index, err := entryIndex(brainDir)
if err != nil { if err != nil {
return "", nil, err // A partial index cannot tell an invented link from one into the
// part it could not read, so unwrap nothing, and do not refuse the
// write either: this runs before every brain_write, and a refused
// write is a lost learning (hyperguild#178).
slog.Warn("link check skipped: cannot index the brain", "err", err)
return content, nil, nil
} }
if len(index) == 0 { if len(index) == 0 {
return content, nil, nil return content, nil, nil
@@ -0,0 +1,41 @@
package brain_test
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
)
// hyperguild#178: StripUnresolvedLinks runs before every brain_write and
// brain_update. An unreadable directory under knowledge/ or wiki/ made it
// error, the write was refused and the learning lost. When the link index
// cannot be built, the content is now returned unchanged (no link is
// unwrapped, none is invented) and no error stops the write.
func TestStripUnresolvedLinksKeepsContentWhenTheIndexCannotBeBuilt(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("root ignores directory permissions")
}
b := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(b, "wiki", "w", "facts"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(b, "wiki", "w", "facts", "real.md"), []byte("x"), 0o644))
locked := filepath.Join(b, "wiki", "other", "locked")
require.NoError(t, os.MkdirAll(locked, 0o755))
require.NoError(t, os.Chmod(locked, 0o000))
t.Cleanup(func() { _ = os.Chmod(locked, 0o755) })
in := "learning [[real]] and [[gone]]"
out, unlinked, err := brain.StripUnresolvedLinks(b, in)
require.NoError(t, err)
assert.Equal(t, in, out, "content unchanged: no link unwrapped on a partial index")
assert.Empty(t, unlinked)
require.NoError(t, os.Chmod(locked, 0o755))
out, _, err = brain.StripUnresolvedLinks(b, in)
require.NoError(t, err)
assert.NotContains(t, out, "[[gone]]", "positive control: with a readable tree the strip runs")
}
+30 -15
View File
@@ -2,7 +2,9 @@ package brain
import ( import (
"bufio" "bufio"
"errors"
"fmt" "fmt"
"io/fs"
"os" "os"
"path" "path"
"path/filepath" "path/filepath"
@@ -39,24 +41,29 @@ type TunnelCandidate struct {
// A note's title is read from YAML frontmatter `title:`; failing that, // A note's title is read from YAML frontmatter `title:`; failing that,
// the filename slug (sans `.md`, hyphens → spaces) is used. // the filename slug (sans `.md`, hyphens → spaces) is used.
func DetectTunnels(brainDir, content string) ([]TunnelCandidate, error) { func DetectTunnels(brainDir, content string) ([]TunnelCandidate, error) {
wikiDir := filepath.Join(brainDir, "wiki") // Walked and read through the brain root, regular files only
if _, err := os.Stat(wikiDir); os.IsNotExist(err) { // (hyperguild#174, #157): this runs inside every brain_write, so a FIFO
// must not block it and a symlinked note's title must not become a
// candidate.
bfs, err := brainfs.Open(brainDir)
if err != nil {
return nil, fmt.Errorf("open brain: %w", err)
}
defer func() { _ = bfs.Close() }()
if _, err := bfs.Lstat("wiki"); errors.Is(err, fs.ErrNotExist) {
return nil, nil return nil, nil
} else if err != nil {
return nil, fmt.Errorf("stat wiki: %w", err)
} }
lowerContent := strings.ToLower(content) lowerContent := strings.ToLower(content)
var out []TunnelCandidate var out []TunnelCandidate
err := filepath.WalkDir(wikiDir, func(path string, d os.DirEntry, err error) error { var walkErr error
if err != nil { err = bfs.WalkRegular("wiki", func(rel string) error {
return err name := path.Base(rel)
} if !strings.HasSuffix(name, ".md") || name == "_index.md" {
if d.IsDir() || !strings.HasSuffix(path, ".md") || d.Name() == "_index.md" {
return nil return nil
} }
title, _ := readTitleAndCreated(path, strings.TrimSuffix(d.Name(), ".md")) title, _ := readTitleAndCreatedIn(bfs, rel, strings.TrimSuffix(name, ".md"))
title = strings.TrimSpace(title) title = strings.TrimSpace(title)
if title == "" { if title == "" {
return nil return nil
@@ -78,17 +85,25 @@ func DetectTunnels(brainDir, content string) ([]TunnelCandidate, error) {
if idx == -1 { if idx == -1 {
return nil return nil
} }
rel, err := filepath.Rel(brainDir, path)
if err != nil {
return err
}
out = append(out, TunnelCandidate{ out = append(out, TunnelCandidate{
TargetPath: filepath.ToSlash(rel), TargetPath: rel,
MatchedTerm: title, MatchedTerm: title,
Exact: isWholeWord(haystack, idx, len(needle)), Exact: isWholeWord(haystack, idx, len(needle)),
}) })
return nil return nil
}, func(rel string, err error) {
// A wiki/ that is not a real directory yields no candidates, as
// filepath.WalkDir did; other walk errors still abort.
if rel == "wiki" && errors.Is(err, brainfs.ErrNotRegular) {
return
}
if walkErr == nil {
walkErr = fmt.Errorf("%s: %w", rel, err)
}
}) })
if err == nil {
err = walkErr
}
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -0,0 +1,38 @@
package brain_test
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
)
// hyperguild#173: brain_tunnel's write followed a directory symlink inside
// wiki/ that points elsewhere in the brain.
func TestWriteTunnelRefusesATargetBehindASymlinkedDirectory(t *testing.T) {
b := t.TempDir()
src := filepath.Join(b, "wiki", "a", "facts", "s.md")
require.NoError(t, os.MkdirAll(filepath.Dir(src), 0o755))
require.NoError(t, os.WriteFile(src, []byte("x\n"), 0o644))
victim := filepath.Join(b, "raw", "facts", "v.md")
require.NoError(t, os.MkdirAll(filepath.Dir(victim), 0o755))
require.NoError(t, os.WriteFile(victim, []byte("untouched\n"), 0o644))
require.NoError(t, os.MkdirAll(filepath.Join(b, "wiki", "b"), 0o755))
require.NoError(t, os.Symlink(filepath.Join("..", "..", "raw", "facts"), filepath.Join(b, "wiki", "b", "facts")))
assert.ErrorIs(t, brain.WriteTunnel(b, "wiki/a/facts/s.md", "wiki/b/facts/v.md"), brainfs.ErrNotRegular)
got, _ := os.ReadFile(victim)
assert.Equal(t, "untouched\n", string(got))
got, _ = os.ReadFile(src)
assert.Equal(t, "x\n", string(got), "the source is not rewritten either")
real := filepath.Join(b, "wiki", "c", "facts", "t.md")
require.NoError(t, os.MkdirAll(filepath.Dir(real), 0o755))
require.NoError(t, os.WriteFile(real, []byte("t\n"), 0o644))
require.NoError(t, brain.WriteTunnel(b, "wiki/a/facts/s.md", "wiki/c/facts/t.md"), "positive control: a tunnel between real directories is written")
}
+79 -3
View File
@@ -104,6 +104,17 @@ func (b *Brain) ReadDirRegular(relDir string) ([]string, error) {
return names, nil return names, nil
} }
// cleanRel refuses a path that is not clean and local. realDir checks the
// cleaned parent, so the I/O must use that same path: os.Root resolves ".."
// after following a symlink, and a ".." after a symlinked directory would
// otherwise pass the check and redirect the read or write.
func cleanRel(rel string) error {
if rel == "" || path.Clean(rel) != rel || !filepath.IsLocal(rel) || strings.Contains(rel, "\\") {
return fmt.Errorf("%q: not a clean brain-relative path: %w", rel, ErrNotRegular)
}
return nil
}
// ReadDirDirs lists the real subdirectories directly in a brain-relative // ReadDirDirs lists the real subdirectories directly in a brain-relative
// directory, by name. Every component of relDir must be a real directory // directory, by name. Every component of relDir must be a real directory
// (ErrNotRegular otherwise); symlinks to directories are left out. // (ErrNotRegular otherwise); symlinks to directories are left out.
@@ -125,9 +136,58 @@ func (b *Brain) ReadDirDirs(relDir string) ([]string, error) {
return names, nil return names, nil
} }
// OpenLock opens (creating if absent) a regular lock file at a clean
// brain-relative path for flock, through the root (hyperguild#192): every
// directory above it must be real, an existing lock must be a regular file,
// and the opened handle must be the file at the path (os.Root follows an
// in-root final symlink even with O_NOFOLLOW). A dangling or swapped-in
// symlink is refused; nothing is created at its target.
func (b *Brain) OpenLock(rel string) (*os.File, error) {
if err := cleanRel(rel); err != nil {
return nil, err
}
if err := b.realDir(path.Dir(rel)); err != nil {
return nil, err
}
if fi, err := b.root.Lstat(rel); err == nil && !fi.Mode().IsRegular() {
return nil, fmt.Errorf("%s: %w", rel, ErrNotRegular)
} else if err != nil && !errors.Is(err, fs.ErrNotExist) {
return nil, err
}
f, err := b.root.OpenFile(rel, os.O_RDWR|os.O_CREATE|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0o644)
if err != nil {
return nil, err
}
hfi, herr := f.Stat()
lfi, lerr := b.root.Lstat(rel)
if herr != nil || lerr != nil || !lfi.Mode().IsRegular() || !os.SameFile(lfi, hfi) {
_ = f.Close()
return nil, fmt.Errorf("%s: changed while opening: %w", rel, ErrNotRegular)
}
return f, nil
}
// RemoveLock removes the lock file at rel through the root (a symlink there
// is removed itself, never its target).
func (b *Brain) RemoveLock(rel string) error {
if err := cleanRel(rel); err != nil {
return err
}
if err := b.realDir(path.Dir(rel)); err != nil {
return err
}
return b.root.Remove(rel)
}
// ReadRegular reads a brain-relative path, refusing anything that is not a // ReadRegular reads a brain-relative path, refusing anything that is not a
// regular file, both before the open and on the opened handle. // regular file, both before the open and on the opened handle.
func (b *Brain) ReadRegular(rel string) ([]byte, error) { func (b *Brain) ReadRegular(rel string) ([]byte, error) {
if err := cleanRel(rel); err != nil {
return nil, err
}
if err := b.realDir(path.Dir(rel)); err != nil {
return nil, err
}
fi, err := b.root.Lstat(rel) fi, err := b.root.Lstat(rel)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -154,6 +214,12 @@ func (b *Brain) readOpened(rel string) ([]byte, error) { return b.readOpenedMax(
// ReadRegularMax is ReadRegular with a size limit, checked on the opened // ReadRegularMax is ReadRegular with a size limit, checked on the opened
// handle before reading (max < 0 means no limit). // handle before reading (max < 0 means no limit).
func (b *Brain) ReadRegularMax(rel string, max int64) ([]byte, error) { func (b *Brain) ReadRegularMax(rel string, max int64) ([]byte, error) {
if err := cleanRel(rel); err != nil {
return nil, err
}
if err := b.realDir(path.Dir(rel)); err != nil {
return nil, err
}
fi, err := b.root.Lstat(rel) fi, err := b.root.Lstat(rel)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -256,10 +322,20 @@ func (b *Brain) WalkRegular(relDir string, fn func(rel string) error, onErr func
// O_NOFOLLOW (verified on go1.27), so the path is Lstat-ed through the root // O_NOFOLLOW (verified on go1.27), so the path is Lstat-ed through the root
// first and the opened handle must be that same file (os.SameFile), which // first and the opened handle must be that same file (os.SameFile), which
// also catches a swap between the two. O_NONBLOCK keeps a FIFO from blocking // also catches a swap between the two. O_NONBLOCK keeps a FIFO from blocking
// the open. Intermediate in-brain symlinks are still followed (os.Root keeps // the open. Every directory above the file must be a real directory
// the write inside the brain; hyperguild#167). // (hyperguild#173); a directory swapped in after that check is the #167
// residual.
func (b *Brain) WriteRegular(rel string, data []byte) error { func (b *Brain) WriteRegular(rel string, data []byte) error {
rel = filepath.ToSlash(rel) if err := cleanRel(rel); err != nil {
return err
}
// Every directory above the file must be real: os.Root follows an
// in-brain intermediate symlink, which would redirect the write
// elsewhere in the brain (hyperguild#173). A swap after this check is
// the #167 residual.
if err := b.realDir(path.Dir(rel)); err != nil {
return err
}
lfi, err := b.root.Lstat(rel) lfi, err := b.root.Lstat(rel)
if err != nil { if err != nil {
return err return err
+51 -1
View File
@@ -218,7 +218,7 @@ func TestReadDirRegularListsOnlyRegularFilesAndRefusesSymlinkedComponents(t *tes
// A final component swapped for a symlink to another regular file in the // A final component swapped for a symlink to another regular file in the
// brain between the Lstat and the open: os.Root follows in-root links, so // brain between the Lstat and the open: os.Root follows in-root links, so
// only comparing the two (os.SameFile) refuses it. // only comparing the two (os.SameFile) refuses it.
func TestReadRegularNeverReturnsAnInBrainFileSwappedInBehindASymlink(t *testing.T) { func TestReadRegularDoesNotReturnAnInBrainFileSwappedInBehindASymlink(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "s.md"), []byte("zqsamefilesentinel"), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(dir, "s.md"), []byte("zqsamefilesentinel"), 0o644))
p := filepath.Join(dir, "r.md") p := filepath.Join(dir, "r.md")
@@ -326,3 +326,53 @@ func TestReadDirDirsListsOnlyRealSubdirsAndRefusesSymlinkedComponents(t *testing
_, err = b.ReadDirDirs("alias") _, err = b.ReadDirDirs("alias")
assert.ErrorIs(t, err, ErrNotRegular) assert.ErrorIs(t, err, ErrNotRegular)
} }
// hyperguild#173: a file reached through a symlinked directory inside the
// brain is refused for reads and writes, so a caller-supplied path cannot be
// redirected to another part of the brain.
func TestReadAndWriteRegularRefuseAFileBehindASymlinkedDirectory(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "raw"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "raw", "v.md"), []byte("untouched"), 0o644))
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki"), 0o755))
require.NoError(t, os.Symlink(filepath.Join("..", "raw"), filepath.Join(dir, "wiki", "alias")))
b, err := Open(dir)
require.NoError(t, err)
defer func() { _ = b.Close() }()
_, err = b.ReadRegular("wiki/alias/v.md")
assert.ErrorIs(t, err, ErrNotRegular)
_, err = b.ReadRegularMax("wiki/alias/v.md", 100)
assert.ErrorIs(t, err, ErrNotRegular)
assert.ErrorIs(t, b.WriteRegular("wiki/alias/v.md", []byte("x")), ErrNotRegular)
got, _ := os.ReadFile(filepath.Join(dir, "raw", "v.md"))
assert.Equal(t, "untouched", string(got))
got, err = b.ReadRegular("raw/v.md")
require.NoError(t, err, "positive control: the real path still reads")
assert.Equal(t, "untouched", string(got))
}
// realDir checks the cleaned parent, so the I/O must use that same clean
// path: os.Root resolves ".." after following a symlink, and a ".." placed
// after a symlinked directory would otherwise pass the check and redirect.
func TestReadAndWriteRegularRefuseAPathThatIsNotClean(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(dir, "raw", "facts"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "raw", "v.md"), []byte("untouched"), 0o644))
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki"), 0o755))
require.NoError(t, os.Symlink(filepath.Join("..", "raw", "facts"), filepath.Join(dir, "wiki", "alias")))
b, err := Open(dir)
require.NoError(t, err)
defer func() { _ = b.Close() }()
for _, rel := range []string{"wiki/alias/../v.md", "./raw/v.md", "raw//v.md", "/raw/v.md"} {
got, err := b.ReadRegular(rel)
assert.ErrorIs(t, err, ErrNotRegular, rel)
assert.NotContains(t, string(got), "untouched", rel)
_, err = b.ReadRegularMax(rel, 100)
assert.ErrorIs(t, err, ErrNotRegular, rel)
assert.ErrorIs(t, b.WriteRegular(rel, []byte("OVERWRITTEN")), ErrNotRegular, rel)
}
got, _ := os.ReadFile(filepath.Join(dir, "raw", "v.md"))
assert.Equal(t, "untouched", string(got))
}
@@ -1,8 +1,10 @@
// Package brainfstest is the shared harness for hyperguild#157's swap-race // Package brainfstest is the shared harness for hyperguild#157's swap-race
// tests: it keeps swapping a regular note, a symlink to an outside file, or a // tests: it keeps swapping a regular note, a symlink to a sentinel target file
// FIFO (with a writer held open) into a path while a reader under test loops. // (outside the brain, or inside it for hyperguild#171), or a FIFO (with a
// It observes two things: no sentinel byte from the outside file reaches the // writer held open) into a path while a reader under test loops.
// reader's output, and the reader returns within a timeout. It does not cover // It observes two things, over a sampled window only: no sentinel byte from
// the target reaches the reader's output, and the reader returns within a
// timeout. It does not cover
// hard links, other paths into the reader, or races it does not generate. // hard links, other paths into the reader, or races it does not generate.
package brainfstest package brainfstest
@@ -18,7 +20,7 @@ import (
"time" "time"
) )
// Sentinel marks the outside file's contents. // Sentinel marks the target file's contents.
const Sentinel = "zqbrainfssentinelonefiftyseven" const Sentinel = "zqbrainfssentinelonefiftyseven"
// outsideContent carries Sentinel in every field a reader might extract: the // outsideContent carries Sentinel in every field a reader might extract: the
@@ -141,7 +143,7 @@ func AssertNoLeakFrom(t testing.TB, path, inside, target string, d time.Duration
} }
} }
if leaks > 0 { if leaks > 0 {
t.Fatalf("outside file leaked %d times in %d iterations", leaks, iters) t.Fatalf("symlink target %s leaked %d times in %d iterations", target, leaks, iters)
} }
if n := swaps.Load(); n == 0 { if n := swaps.Load(); n == 0 {
t.Fatalf("no symlink was swapped into %s in %d iterations: the race did not run", path, iters) t.Fatalf("no symlink was swapped into %s in %d iterations: the race did not run", path, iters)
@@ -16,9 +16,10 @@
package classification package classification
import ( import (
"errors"
"fmt" "fmt"
"os" "github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
"path/filepath" "io/fs"
"strings" "strings"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
@@ -110,9 +111,20 @@ type rawConfig struct {
func Load(brainDir string) (*Config, error) { func Load(brainDir string) (*Config, error) {
cfg := &Config{wings: map[string]Level{}, repos: map[string]Level{}} cfg := &Config{wings: map[string]Level{}, repos: map[string]Level{}}
data, err := os.ReadFile(filepath.Join(brainDir, "classification.yaml")) // Read through the brain root, regular files only (hyperguild#157): a
// symlinked or special file is not trusted as the config, which is a hard
// error like a malformed one.
bfs, err := brainfs.Open(brainDir)
if errors.Is(err, fs.ErrNotExist) {
return cfg, nil
}
if err != nil { if err != nil {
if os.IsNotExist(err) { return nil, fmt.Errorf("open brain: %w", err)
}
defer func() { _ = bfs.Close() }()
data, err := bfs.ReadRegular("classification.yaml")
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return cfg, nil return cfg, nil
} }
return nil, fmt.Errorf("read classification.yaml: %w", err) return nil, fmt.Errorf("read classification.yaml: %w", err)
@@ -0,0 +1,46 @@
package classification
import (
"os"
"path/filepath"
"syscall"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// hyperguild#157 slice 6: Load read classification.yaml with os.ReadFile. It
// sets the I1 sovereignty levels; a symlinked file must not be trusted as the
// config (a hard error, as for a malformed file), and a FIFO must not hang
// startup.
func TestLoadRefusesASymlinkedOrFIFOClassificationFile(t *testing.T) {
ok := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(ok, "classification.yaml"), []byte("wings:\n w: confidential\n"), 0o644))
_, err := Load(ok)
require.NoError(t, err, "positive control")
linked := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(linked, "real.yaml"), []byte("wings:\n w: public\n"), 0o644))
require.NoError(t, os.Symlink("real.yaml", filepath.Join(linked, "classification.yaml")))
_, err = Load(linked)
assert.Error(t, err, "an in-brain symlink is not trusted as the config")
fifo := t.TempDir()
require.NoError(t, syscall.Mkfifo(filepath.Join(fifo, "classification.yaml"), 0o644))
w, err := os.OpenFile(filepath.Join(fifo, "classification.yaml"), os.O_RDWR, 0)
require.NoError(t, err)
defer func() { _ = w.Close() }()
done := make(chan error, 1)
go func() { _, err := Load(fifo); done <- err }()
select {
case err := <-done:
assert.Error(t, err)
case <-time.After(3 * time.Second):
t.Fatal("Load hung on a FIFO")
}
_, err = Load(t.TempDir())
assert.NoError(t, err, "an absent file is still not an error")
}
@@ -0,0 +1,61 @@
package mcp_test
import (
"os"
"path/filepath"
"syscall"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mathiasbq/hyperguild/ingestion/internal/mcp"
)
// hyperguild#192: brain_edit snapshotted the note with os.ReadFile by path,
// which blocks on a FIFO held open by a writer, and hung the call.
func TestBrainEditReturnsPromptlyOnAFIFONoteWithAWriter(t *testing.T) {
dir := newBrainRepo(t)
fifo := filepath.Join(dir, "wiki", "homelab", "facts", "f.md")
require.NoError(t, syscall.Mkfifo(fifo, 0o644))
w, err := os.OpenFile(fifo, os.O_RDWR, 0)
require.NoError(t, err)
t.Cleanup(func() { _ = w.Close() })
srv := mcp.NewServer(dir, nil, nil, nil)
done := make(chan map[string]any, 1)
go func() {
done <- call(t, srv, "brain_edit", map[string]any{"path": "wiki/homelab/facts/f.md", "old_str": "a", "new_str": "b"})
}()
select {
case resp := <-done:
assert.Contains(t, resp, "error", "a FIFO note is refused")
case <-time.After(3 * time.Second):
t.Fatal("brain_edit hung on a FIFO note")
}
resp := call(t, srv, "brain_edit", map[string]any{"path": "wiki/homelab/facts/n.md", "old_str": "beta", "new_str": "BETA"})
require.NotContains(t, resp, "error", "positive control: a regular note is edited: %v", resp["error"])
}
// hyperguild#192: the edit lock "<note>.lock" was opened by path with
// O_CREATE, so a planted dangling .lock symlink made a successful edit create
// a file at the link's target, outside the brain or elsewhere in it.
func TestBrainEditDoesNotCreateAFileThroughADanglingLockSymlink(t *testing.T) {
for name, target := range map[string]func(t *testing.T, dir string) string{
"outside": func(t *testing.T, _ string) string { return filepath.Join(t.TempDir(), "created") },
"in-brain": func(t *testing.T, dir string) string { return filepath.Join(dir, "raw", "created") },
} {
t.Run(name, func(t *testing.T) {
dir := newBrainRepo(t)
require.NoError(t, os.MkdirAll(filepath.Join(dir, "raw"), 0o755))
tgt := target(t, dir)
require.NoError(t, os.Symlink(tgt, filepath.Join(dir, "wiki", "homelab", "facts", "n.md.lock")))
srv := mcp.NewServer(dir, nil, nil, nil)
_ = call(t, srv, "brain_edit", map[string]any{"path": "wiki/homelab/facts/n.md", "old_str": "beta", "new_str": "BETA"})
_, err := os.Lstat(tgt)
assert.True(t, os.IsNotExist(err), "a file was created at the lock symlink's target")
})
}
}
@@ -21,6 +21,11 @@ func TestReadExcerptRefusesSymlinksAndSurvivesSwapRaces(t *testing.T) {
require.NoError(t, os.MkdirAll(facts, 0o755)) require.NoError(t, os.MkdirAll(facts, 0o755))
require.NoError(t, os.Symlink(brainfstest.Outside(t), filepath.Join(facts, "leak.md"))) require.NoError(t, os.Symlink(brainfstest.Outside(t), filepath.Join(facts, "leak.md")))
assert.NotContains(t, readExcerpt(brain, "wiki/w/facts/leak.md", 500), brainfstest.Sentinel) assert.NotContains(t, readExcerpt(brain, "wiki/w/facts/leak.md", 500), brainfstest.Sentinel)
// A link to a file elsewhere in the brain: os.Root allows it, only the
// regular-file check refuses it (hyperguild#171).
inBrain := brainfstest.WriteSentinelAt(t, filepath.Join(brain, "raw", "s.md"))
require.NoError(t, os.Symlink(filepath.Join("..", "..", "..", "raw", "s.md"), filepath.Join(facts, "inlink.md")))
assert.NotContains(t, readExcerpt(brain, "wiki/w/facts/inlink.md", 500), brainfstest.Sentinel)
p := filepath.Join(facts, "r.md") p := filepath.Join(facts, "r.md")
require.NoError(t, os.WriteFile(p, []byte("---\nx: y\n---\ninside"), 0o644)) require.NoError(t, os.WriteFile(p, []byte("---\nx: y\n---\ninside"), 0o644))
@@ -28,6 +33,9 @@ func TestReadExcerptRefusesSymlinksAndSurvivesSwapRaces(t *testing.T) {
brainfstest.AssertNoLeak(t, p, "---\nx: y\n---\ninside", 1500*time.Millisecond, func() string { brainfstest.AssertNoLeak(t, p, "---\nx: y\n---\ninside", 1500*time.Millisecond, func() string {
return readExcerpt(brain, "wiki/w/facts/r.md", 500) return readExcerpt(brain, "wiki/w/facts/r.md", 500)
}) })
brainfstest.AssertNoLeakFrom(t, p, "---\nx: y\n---\ninside", inBrain, 1500*time.Millisecond, func() string {
return readExcerpt(brain, "wiki/w/facts/r.md", 500)
})
brainfstest.AssertNoHang(t, p, "---\nx: y\n---\ninside", 1500*time.Millisecond, 6*time.Second, func() { brainfstest.AssertNoHang(t, p, "---\nx: y\n---\ninside", 1500*time.Millisecond, 6*time.Second, func() {
_ = readExcerpt(brain, "wiki/w/facts/r.md", 500) _ = readExcerpt(brain, "wiki/w/facts/r.md", 500)
}) })
+10 -3
View File
@@ -6,8 +6,8 @@ import (
"errors" "errors"
"fmt" "fmt"
"github.com/mathiasbq/hyperguild/ingestion/internal/api" "github.com/mathiasbq/hyperguild/ingestion/internal/api"
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
"log/slog" "log/slog"
"os"
"strings" "strings"
"time" "time"
@@ -118,7 +118,14 @@ func (s *Server) brainEdit(ctx context.Context, args json.RawMessage) (json.RawM
return nil, err return nil, err
} }
// No snapshot, no edit: without one a failed stamp could not be undone. // No snapshot, no edit: without one a failed stamp could not be undone.
prior, err := os.ReadFile(target.Abs) // Read through the brain root, regular files only (hyperguild#192): a
// FIFO held open by a writer hung os.ReadFile.
bfs, err := brainfs.Open(s.brainDir)
if err != nil {
return nil, fmt.Errorf("snapshot before edit: %w", err)
}
defer func() { _ = bfs.Close() }()
prior, err := bfs.ReadRegular(target.Rel)
if err != nil { if err != nil {
return nil, fmt.Errorf("snapshot before edit: %w", err) return nil, fmt.Errorf("snapshot before edit: %w", err)
} }
@@ -131,7 +138,7 @@ func (s *Server) brainEdit(ctx context.Context, args json.RawMessage) (json.RawM
// to the editor after every edit (PR #147 review, escape E1). // to the editor after every edit (PR #147 review, escape E1).
origin, writtenBy := provenanceFrom(ctx) origin, writtenBy := provenanceFrom(ctx)
if serr := api.StampProvenanceFile(s.brainDir, res.Path, origin, writtenBy); serr != nil { if serr := api.StampProvenanceFile(s.brainDir, res.Path, origin, writtenBy); serr != nil {
if werr := os.WriteFile(target.Abs, prior, 0o644); werr != nil { if werr := bfs.WriteRegular(target.Rel, prior); werr != nil {
// Say exactly what state the note is in: edited, unverified. // Say exactly what state the note is in: edited, unverified.
return nil, fmt.Errorf("stamp provenance failed (%v) AND the edit could NOT be rolled back: %w; "+ return nil, fmt.Errorf("stamp provenance failed (%v) AND the edit could NOT be rolled back: %w; "+
"%s carries the edit under its previous attribution", serr, werr, res.Path) "%s carries the edit under its previous attribution", serr, werr, res.Path)
+19 -7
View File
@@ -127,7 +127,7 @@ func Edit(brainDir, path, oldStr, newStr string, scope Scope) (Result, error) {
mu.Lock() mu.Lock()
defer mu.Unlock() defer mu.Unlock()
unlock, err := flockFile(t.Abs) unlock, err := flockFile(t)
if err != nil { if err != nil {
return Result{}, err return Result{}, err
} }
@@ -220,20 +220,32 @@ func lockFor(abs string) *sync.Mutex {
} }
// flockFile takes an advisory lock covering other processes sharing the // flockFile takes an advisory lock covering other processes sharing the
// hostPath — during a RollingUpdate two ingestion pods do. // hostPath — during a RollingUpdate two ingestion pods do. The lock file is
func flockFile(abs string) (func(), error) { // opened and removed through the brain root, never through a symlink
f, err := os.OpenFile(abs+".lock", os.O_CREATE|os.O_RDWR, 0o644) // (hyperguild#192): a planted dangling "<note>.lock" link made a
// successful edit create a file at its target.
func flockFile(t Target) (func(), error) {
lockRel := t.Rel + ".lock"
bfs, err := brainfs.Open(t.Brain)
if err != nil { if err != nil {
return nil, fmt.Errorf("notes: lock %s: %w", filepath.Base(abs), err) return nil, fmt.Errorf("notes: lock %s: %w", lockRel, err)
}
f, err := bfs.OpenLock(lockRel)
_ = bfs.Close()
if err != nil {
return nil, fmt.Errorf("notes: lock %s: %w", lockRel, err)
} }
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil { if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil {
_ = f.Close() _ = f.Close()
return nil, fmt.Errorf("notes: flock %s: %w", filepath.Base(abs), err) return nil, fmt.Errorf("notes: flock %s: %w", lockRel, err)
} }
return func() { return func() {
_ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN) _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
_ = f.Close() _ = f.Close()
_ = os.Remove(abs + ".lock") if b, err := brainfs.Open(t.Brain); err == nil {
_ = b.RemoveLock(lockRel)
_ = b.Close()
}
}, nil }, nil
} }
@@ -38,3 +38,31 @@ func TestReadScopedRefusesANoteOverMaxNoteBytes(t *testing.T) {
_, err := ReadScoped(brain, "wiki/w/facts/big.md", ScopeNotes) _, err := ReadScoped(brain, "wiki/w/facts/big.md", ScopeNotes)
assert.ErrorIs(t, err, ErrTooLarge) assert.ErrorIs(t, err, ErrTooLarge)
} }
// hyperguild#171 (review of #172, finding 1): os.Root alone refuses links that
// leave the brain, so the outside-target race above stays green for a reader
// that skips brainfs's Lstat and SameFile checks. An in-brain target (raw/,
// outside ScopeNotes) is what tells them apart.
func TestReadScopedDoesNotReturnAnInBrainSymlinkTargetStaticallyOrDuringASampledSwapRace(t *testing.T) {
brain := t.TempDir()
facts := filepath.Join(brain, "wiki", "w", "facts")
require.NoError(t, os.MkdirAll(facts, 0o755))
target := brainfstest.WriteSentinelAt(t, filepath.Join(brain, "raw", "s.md"))
require.NoError(t, os.Symlink(filepath.Join("..", "..", "..", "raw", "s.md"), filepath.Join(facts, "inlink.md")))
b, err := ReadScoped(brain, "wiki/w/facts/inlink.md", ScopeNotes)
assert.Error(t, err, "static in-brain link")
assert.NotContains(t, string(b), brainfstest.Sentinel, "static in-brain link")
p := filepath.Join(facts, "r.md")
require.NoError(t, os.WriteFile(p, []byte("inside"), 0o644))
b, err = ReadScoped(brain, "wiki/w/facts/r.md", ScopeNotes)
require.NoError(t, err)
assert.Equal(t, "inside", string(b), "positive control: a regular note reads")
read := func() string {
b, _ := ReadScoped(brain, "wiki/w/facts/r.md", ScopeNotes)
return string(b)
}
brainfstest.AssertNoLeakFrom(t, p, "inside", target, 1500*time.Millisecond, read)
}
+17 -8
View File
@@ -3,7 +3,10 @@ package pipeline
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
"io/fs"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -23,10 +26,16 @@ func BackfillRefs(ctx context.Context, brainDir string) (int, error) {
return 0, fmt.Errorf("load inventory: %w", err) return 0, fmt.Errorf("load inventory: %w", err)
} }
sourcesDir := filepath.Join(brainDir, "wiki", "sources") // Listed and read through the brain root, regular files only
entries, err := os.ReadDir(sourcesDir) // (hyperguild#157).
bfs, err := brainfs.Open(brainDir)
if err != nil { if err != nil {
if os.IsNotExist(err) { return 0, fmt.Errorf("open brain: %w", err)
}
defer func() { _ = bfs.Close() }()
names, err := bfs.ReadDirRegular("wiki/sources")
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return 0, nil return 0, nil
} }
return 0, fmt.Errorf("read sources dir: %w", err) return 0, fmt.Errorf("read sources dir: %w", err)
@@ -37,20 +46,20 @@ func BackfillRefs(ctx context.Context, brainDir string) (int, error) {
// get both refs merged before a single write. // get both refs merged before a single write.
pending := make(map[string]wiki.Page) pending := make(map[string]wiki.Page)
for _, e := range entries { for _, name := range names {
if ctx.Err() != nil { if ctx.Err() != nil {
return 0, ctx.Err() return 0, ctx.Err()
} }
if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") { if !strings.HasSuffix(name, ".md") {
continue continue
} }
b, err := os.ReadFile(filepath.Join(sourcesDir, e.Name())) b, err := bfs.ReadRegular("wiki/sources/" + name)
if err != nil { if err != nil {
continue continue
} }
sourceContent := string(b) sourceContent := string(b)
sourceSlug := strings.TrimSuffix(e.Name(), ".md") sourceSlug := strings.TrimSuffix(name, ".md")
sourceTitle := extractTitle(sourceContent) sourceTitle := extractTitle(sourceContent)
if sourceTitle == "" { if sourceTitle == "" {
sourceTitle = sourceSlug sourceTitle = sourceSlug
@@ -70,7 +79,7 @@ func BackfillRefs(ctx context.Context, brainDir string) (int, error) {
// Start from already-accumulated version if we've seen this page. // Start from already-accumulated version if we've seen this page.
page, seen := pending[relPath] page, seen := pending[relPath]
if !seen { if !seen {
raw, err := os.ReadFile(filepath.Join(brainDir, filepath.FromSlash(relPath))) raw, err := bfs.ReadRegular(relPath)
if err != nil { if err != nil {
continue continue
} }
+22 -6
View File
@@ -5,7 +5,10 @@ import (
"context" "context"
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"errors"
"fmt" "fmt"
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
"io/fs"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -129,7 +132,7 @@ func buildAndWrite(ctx context.Context, rawPages []RawPage, sourceSlug, date, br
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return Result{}, fmt.Errorf("mkdir for %s: %w", page.Path, err) return Result{}, fmt.Errorf("mkdir for %s: %w", page.Path, err)
} }
content, conflicts, err := reviseOrCreate(dest, page) content, conflicts, err := reviseOrCreate(brainDir, page)
if err != nil { if err != nil {
return Result{}, err return Result{}, err
} }
@@ -188,14 +191,27 @@ func hashBody(content string) string {
// readSourcePage returns the full on-disk content of the source page at // readSourcePage returns the full on-disk content of the source page at
// wiki/sources/<sourceSlug>.md, or "" when it doesn't exist yet. // wiki/sources/<sourceSlug>.md, or "" when it doesn't exist yet.
// It reads through the brain root, regular files only (hyperguild#157): the
// content goes into the LLM prompt.
func readSourcePage(brainDir, sourceSlug string) string { func readSourcePage(brainDir, sourceSlug string) string {
raw, err := os.ReadFile(filepath.Join(brainDir, "wiki", "sources", sourceSlug+".md")) raw, err := readBrainFile(brainDir, "wiki/sources/"+sourceSlug+".md")
if err != nil { if err != nil {
return "" return ""
} }
return string(raw) return string(raw)
} }
// readBrainFile reads one brain-relative file through the brain root,
// regular files only (hyperguild#157).
func readBrainFile(brainDir, rel string) ([]byte, error) {
bfs, err := brainfs.Open(brainDir)
if err != nil {
return nil, err
}
defer func() { _ = bfs.Close() }()
return bfs.ReadRegular(rel)
}
// storedSourceHash reads source_hash from the source page a previous ingest // storedSourceHash reads source_hash from the source page a previous ingest
// wrote, or "" when there is no such page or no such field. // wrote, or "" when there is no such page or no such field.
// //
@@ -230,10 +246,10 @@ func storedSourceHash(brainDir, sourceSlug string) string {
// its first-ingest date on a revised page. Left deliberately — the alternative // its first-ingest date on a revised page. Left deliberately — the alternative
// is this function editing frontmatter it does not own, and no lint reads that // is this function editing frontmatter it does not own, and no lint reads that
// field today. // field today.
func reviseOrCreate(dest string, page wiki.Page) (string, []string, error) { func reviseOrCreate(brainDir string, page wiki.Page) (string, []string, error) {
existing, err := os.ReadFile(dest) existing, err := readBrainFile(brainDir, page.Path)
switch { switch {
case os.IsNotExist(err): case errors.Is(err, fs.ErrNotExist):
return page.Content, nil, nil return page.Content, nil, nil
case err != nil: case err != nil:
return "", nil, fmt.Errorf("read existing %s: %w", page.Path, err) return "", nil, fmt.Errorf("read existing %s: %w", page.Path, err)
@@ -267,7 +283,7 @@ See brain/schema.md for the full schema.
` `
func loadSchema(brainDir string) string { func loadSchema(brainDir string) string {
b, err := os.ReadFile(filepath.Join(brainDir, "schema.md")) b, err := readBrainFile(brainDir, "schema.md")
if err != nil { if err != nil {
return defaultSchema return defaultSchema
} }
+1 -3
View File
@@ -2,7 +2,6 @@
package pipeline package pipeline
import ( import (
"os"
"path/filepath" "path/filepath"
"regexp" "regexp"
"strings" "strings"
@@ -53,8 +52,7 @@ func injectSourceRefs(pages []wiki.Page, inventory map[wiki.PageType][]wiki.Entr
if !ok { if !ok {
continue continue
} }
diskPath := filepath.Join(brainDir, "wiki", string(pt), slug+".md") b, err := readBrainFile(brainDir, "wiki/"+string(pt)+"/"+slug+".md")
b, err := os.ReadFile(diskPath)
if err != nil { if err != nil {
continue continue
} }
+167
View File
@@ -0,0 +1,167 @@
package pipeline
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"syscall"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs/brainfstest"
"github.com/mathiasbq/hyperguild/ingestion/internal/wiki"
)
// hyperguild#157 slice 6: the pipeline read the source page, schema.md, the
// pages it revises and the pages it adds source refs to with os.ReadFile, so
// a symlinked file's content reached the LLM prompt and the written pages,
// and a FIFO hung the ingest.
func mkdirs(t *testing.T, brain string, dirs ...string) {
t.Helper()
for _, d := range dirs {
require.NoError(t, os.MkdirAll(filepath.Join(brain, filepath.FromSlash(d)), 0o755))
}
}
func TestReadSourcePageAndSchemaRefuseSymlinksAndSurviveSwapRaces(t *testing.T) {
brain := t.TempDir()
mkdirs(t, brain, "wiki/sources")
inBrain := brainfstest.WriteSentinelAt(t, filepath.Join(brain, "raw", "s.md"))
require.NoError(t, os.WriteFile(filepath.Join(brain, "wiki", "sources", "ok.md"), []byte("zqinside source"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(brain, "schema.md"), []byte("zqinside schema"), 0o644))
assert.Equal(t, "zqinside source", readSourcePage(brain, "ok"), "positive control")
assert.Equal(t, "zqinside schema", loadSchema(brain), "positive control")
require.NoError(t, os.Symlink(brainfstest.Outside(t), filepath.Join(brain, "wiki", "sources", "out.md")))
require.NoError(t, os.Symlink(filepath.Join("..", "..", "raw", "s.md"), filepath.Join(brain, "wiki", "sources", "in.md")))
assert.NotContains(t, readSourcePage(brain, "out"), brainfstest.Sentinel)
assert.NotContains(t, readSourcePage(brain, "in"), brainfstest.Sentinel)
assert.NotContains(t, storedSourceHash(brain, "in"), brainfstest.Sentinel)
src := filepath.Join(brain, "wiki", "sources", "r.md")
read := func() string { return readSourcePage(brain, "r") }
brainfstest.AssertNoLeak(t, src, "zqinside", 1500*time.Millisecond, read)
brainfstest.AssertNoLeakFrom(t, src, "zqinside", inBrain, 1500*time.Millisecond, read)
brainfstest.AssertNoHang(t, src, "zqinside", 1500*time.Millisecond, 6*time.Second, func() { _ = read() })
schema := filepath.Join(brain, "schema.md")
rs := func() string { return loadSchema(brain) }
brainfstest.AssertNoLeakFrom(t, schema, "zqinside schema", inBrain, 1500*time.Millisecond, rs)
brainfstest.AssertNoHang(t, schema, "zqinside schema", 1500*time.Millisecond, 6*time.Second, func() { _ = rs() })
}
func TestReviseOrCreateRefusesASymlinkedExistingPage(t *testing.T) {
brain := t.TempDir()
mkdirs(t, brain, "wiki/concepts")
brainfstest.WriteSentinelAt(t, filepath.Join(brain, "raw", "s.md"))
require.NoError(t, os.Symlink(filepath.Join("..", "..", "raw", "s.md"), filepath.Join(brain, "wiki", "concepts", "c.md")))
page := wiki.Page{Path: "wiki/concepts/c.md", Content: "---\ntitle: C\n---\nnew\n"}
out, _, err := reviseOrCreate(brain, page)
assert.Error(t, err)
assert.NotContains(t, out, brainfstest.Sentinel)
require.NoError(t, os.WriteFile(filepath.Join(brain, "wiki", "concepts", "d.md"), []byte("---\ntitle: D\n---\nzqold\n"), 0o644))
out, _, err = reviseOrCreate(brain, wiki.Page{Path: "wiki/concepts/d.md", Content: "---\ntitle: D\n---\nnew\n"})
require.NoError(t, err)
assert.Contains(t, out, "zqold", "positive control: the existing page is read")
out, _, err = reviseOrCreate(brain, wiki.Page{Path: "wiki/concepts/new.md", Content: "fresh"})
require.NoError(t, err)
assert.Equal(t, "fresh", out, "positive control: a missing page is created")
}
func TestInjectSourceRefsDoesNotReadASymlinkedPage(t *testing.T) {
brain := t.TempDir()
mkdirs(t, brain, "wiki/concepts")
brainfstest.WriteSentinelAt(t, filepath.Join(brain, "raw", "s.md"))
require.NoError(t, os.Symlink(filepath.Join("..", "..", "raw", "s.md"), filepath.Join(brain, "wiki", "concepts", "c.md")))
require.NoError(t, os.WriteFile(filepath.Join(brain, "wiki", "concepts", "d.md"), []byte("zqinside d"), 0o644))
inv := map[wiki.PageType][]wiki.Entry{wiki.PageTypeConcept: {
{Slug: "c", Type: wiki.PageTypeConcept}, {Slug: "d", Type: wiki.PageTypeConcept},
}}
src := wiki.Page{Path: "wiki/sources/src.md", Content: "---\ntitle: Src\n---\nsee [[c|C]] and [[d|D]]\n"}
out := injectSourceRefs([]wiki.Page{src}, inv, brain)
got := fmt.Sprintf("%+v", out)
assert.Contains(t, got, "zqinside d", "positive control")
assert.NotContains(t, got, brainfstest.Sentinel)
}
func TestBackfillRefsSkipsSymlinkedSourcesAndPages(t *testing.T) {
brain := t.TempDir()
mkdirs(t, brain, "wiki/sources", "wiki/concepts", "raw")
require.NoError(t, os.WriteFile(filepath.Join(brain, "wiki", "concepts", "real.md"), []byte("---\ntitle: R\n---\nbody\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(brain, "wiki", "sources", "ok.md"), []byte("---\ntitle: zqinside\n---\n[[real|R]]\n"), 0o644))
// A source elsewhere in the brain whose title is the sentinel and which
// links the real concept with the [[slug|title]] form the pipeline follows.
require.NoError(t, os.WriteFile(filepath.Join(brain, "raw", "src.md"),
[]byte("---\ntitle: "+brainfstest.Sentinel+"\n---\n[[real|R]]\n"), 0o644))
require.NoError(t, os.Symlink(filepath.Join("..", "..", "raw", "src.md"), filepath.Join(brain, "wiki", "sources", "in.md")))
// A concept page linked by the regular source that is itself a symlink.
require.NoError(t, os.WriteFile(filepath.Join(brain, "wiki", "sources", "ok2.md"), []byte("---\ntitle: Two\n---\n[[lnk|L]]\n"), 0o644))
brainfstest.WriteSentinelAt(t, filepath.Join(brain, "raw", "page.md"))
require.NoError(t, os.Symlink(filepath.Join("..", "..", "raw", "page.md"), filepath.Join(brain, "wiki", "concepts", "lnk.md")))
_, err := BackfillRefs(context.Background(), brain)
require.NoError(t, err)
b, _ := os.ReadFile(filepath.Join(brain, "wiki", "concepts", "real.md"))
assert.Contains(t, string(b), "zqinside", "positive control: the regular source's ref is added")
assert.NotContains(t, string(b), brainfstest.Sentinel, "a symlinked source's title was written as a ref")
raw, _ := os.ReadFile(filepath.Join(brain, "raw", "page.md"))
assert.NotContains(t, string(raw), "[[ok2", "the symlinked concept page's target was rewritten")
}
// A source swapped between a regular file and a symlink to an in-brain source
// whose title is the sentinel: the read refuses the swapped-in link, so the
// sentinel never lands in the concept page as a ref.
func TestBackfillRefsDoesNotWriteARefFromASourceSwappedInBehindASymlink(t *testing.T) {
brain := t.TempDir()
mkdirs(t, brain, "wiki/sources", "wiki/concepts", "raw")
realPage := filepath.Join(brain, "wiki", "concepts", "real.md")
target := filepath.Join(brain, "raw", "src.md")
require.NoError(t, os.WriteFile(target, []byte("---\ntitle: "+brainfstest.Sentinel+"\n---\n[[real|R]]\n"), 0o644))
src := filepath.Join(brain, "wiki", "sources", "r.md")
inside := "---\ntitle: zqinside\n---\n[[real|R]]\n"
read := func() string {
_ = os.WriteFile(realPage, []byte("---\ntitle: R\n---\nbody\n"), 0o644)
_, _ = BackfillRefs(context.Background(), brain)
b, _ := os.ReadFile(realPage)
return string(b)
}
require.NoError(t, os.WriteFile(src, []byte(inside), 0o644))
require.Contains(t, read(), "zqinside", "positive control")
brainfstest.AssertNoLeakFrom(t, src, inside, target, 1500*time.Millisecond, read)
}
// A concept page that is regular when the inventory lists it, then swapped
// for a symlink to an in-brain file before BackfillRefs reads it: only the
// page read's own check refuses it (a static symlink never gets this far,
// because LoadInventory does not list it).
func TestBackfillRefsDoesNotCopyAPageSwappedInBehindASymlink(t *testing.T) {
brain := t.TempDir()
mkdirs(t, brain, "wiki/sources", "wiki/concepts", "raw")
require.NoError(t, os.WriteFile(filepath.Join(brain, "wiki", "sources", "s.md"), []byte("---\ntitle: S\n---\n[[c|C]]\n"), 0o644))
page := filepath.Join(brain, "wiki", "concepts", "c.md")
target := filepath.Join(brain, "raw", "secret.md")
inside := "---\ntitle: C\n---\nzqinside\n"
read := func() string {
// The write path still follows a swapped-in link (#158) and can
// overwrite the target, so restore it each round.
_ = os.WriteFile(target, []byte("---\ntitle: X\n---\n"+brainfstest.Sentinel+"\n"), 0o644)
_, _ = BackfillRefs(context.Background(), brain)
f, err := os.OpenFile(page, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0)
if err != nil {
return ""
}
defer func() { _ = f.Close() }()
b, _ := io.ReadAll(f)
return string(b)
}
require.NoError(t, os.WriteFile(page, []byte(inside), 0o644))
require.Contains(t, read(), "[[s|S]]", "positive control: the regular page gets the source ref")
brainfstest.AssertNoLeakFrom(t, page, inside, target, 3*time.Second, read)
}
@@ -12,6 +12,8 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs/brainfstest"
) )
// hyperguild#157 option 1: search walked and read the brain by path, so a // hyperguild#157 option 1: search walked and read the brain by path, so a
@@ -189,3 +191,49 @@ func TestHydrateRefusesASymlink(t *testing.T) {
assert.Error(t, err) assert.Error(t, err)
assert.NotContains(t, r.Excerpt, sentinel157) assert.NotContains(t, r.Excerpt, sentinel157)
} }
// hyperguild#171: os.Root refuses links that leave the brain on its own, so
// the outside-target cases above would stay green for a caller that read
// without brainfs's Lstat and SameFile checks. These targets are inside the
// brain (raw/, which is not a search root).
func inBrainTarget(t *testing.T, brain string) string {
t.Helper()
return brainfstest.WriteSentinelAt(t, filepath.Join(brain, "raw", "s.md"))
}
func TestQueryDoesNotReturnAnInBrainSymlinkTargetDuringASampledSwapRace(t *testing.T) {
brain, facts, _ := searchBrain(t)
target := inBrainTarget(t, brain)
read := func() string {
// Both terms, so the regular note is a possible hit: a reader that
// never returns the inside note would pass vacuously (#177 floor).
rs, _ := Query(brain, QueryOptions{Query: brainfstest.Sentinel + " zqinside", Limit: 10})
s := ""
for _, r := range rs {
s += r.Title + r.Excerpt
}
return s
}
brainfstest.AssertNoLeakFrom(t, filepath.Join(facts, "r.md"), "# r\n\nzqinside", target, 1500*time.Millisecond, read)
require.NoError(t, os.Symlink(filepath.Join("..", "..", "..", "raw", "s.md"), filepath.Join(facts, "inlink.md")))
assert.NotContains(t, read(), brainfstest.Sentinel, "static in-brain link")
}
func TestHydrateRefusesAnInBrainSymlinkAndDoesNotReturnItsTargetDuringASampledSwapRace(t *testing.T) {
brain, facts, _ := searchBrain(t)
target := inBrainTarget(t, brain)
require.NoError(t, os.Symlink(filepath.Join("..", "..", "..", "raw", "s.md"), filepath.Join(facts, "inlink.md")))
r, err := hydrate(brain, "wiki/w/facts/inlink.md")
assert.Error(t, err)
assert.NotContains(t, r.Excerpt+r.Title, brainfstest.Sentinel)
p := filepath.Join(facts, "r.md")
require.NoError(t, os.WriteFile(p, []byte("# r\n\nzqinside"), 0o644))
r, err = hydrate(brain, "wiki/w/facts/r.md")
require.NoError(t, err)
assert.Contains(t, r.Excerpt, "zqinside", "positive control: a regular note hydrates")
brainfstest.AssertNoLeakFrom(t, p, "# r\n\nzqinside", target, 1500*time.Millisecond, func() string {
r, _ := hydrate(brain, "wiki/w/facts/r.md")
return r.Title + r.Excerpt
})
}
+85
View File
@@ -2,14 +2,17 @@ package session
import ( import (
"errors" "errors"
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"sync/atomic"
"syscall" "syscall"
"testing" "testing"
"time" "time"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"golang.org/x/sys/unix"
) )
// session_log passed the caller's session_id straight into // session_log passed the caller's session_id straight into
@@ -90,3 +93,85 @@ func TestAppendReturnsPromptlyOnAFIFOLogWithNoReader(t *testing.T) {
t.Fatal("Append blocked on a FIFO log with no reader") t.Fatal("Append blocked on a FIFO log with no reader")
} }
} }
// hyperguild#173: session_log followed a symlinked sessions dir.
func TestAppendRefusesASymlinkedSessionsDir(t *testing.T) {
root := t.TempDir()
elsewhere := filepath.Join(root, "elsewhere")
require.NoError(t, os.MkdirAll(elsewhere, 0o755))
dir := filepath.Join(root, "sessions")
require.NoError(t, os.Symlink("elsewhere", dir))
assert.Error(t, Append(dir, "s", Entry{SessionID: "s"}))
ents, _ := os.ReadDir(elsewhere)
assert.Empty(t, ents, "a log was created through the symlinked dir")
}
// The sessions dir is swapped (RENAME_EXCHANGE) with a symlink to a
// directory outside the brain while Append runs. Append opens through an
// os.Root on the sessions dir's parent, so a swap can redirect it only
// inside that parent (the #167 residual), never outside it.
func TestAppendNeverCreatesALogOutsideTheParentUnderADirSwapRace(t *testing.T) {
brain := t.TempDir()
outside := t.TempDir()
dir := filepath.Join(brain, "sessions")
other := filepath.Join(brain, "sessions.link")
require.NoError(t, os.MkdirAll(dir, 0o755))
require.NoError(t, os.Symlink(outside, other))
var stop atomic.Bool
done := make(chan struct{})
go func() {
defer close(done)
for !stop.Load() {
_ = unix.Renameat2(unix.AT_FDCWD, dir, unix.AT_FDCWD, other, unix.RENAME_EXCHANGE)
}
}()
ok := 0
for i := 0; i < 20000; i++ {
if Append(dir, fmt.Sprintf("s%d", i), Entry{SessionID: "s"}) == nil {
ok++
}
}
stop.Store(true)
<-done
ents, _ := os.ReadDir(outside)
assert.Empty(t, ents, "logs were created outside the brain")
assert.Positive(t, ok, "positive control: appends to the real dir succeed")
}
// The log file is swapped between a regular file and a symlink to another
// file in the brain while Append runs: Append writes only after confirming
// the path is the regular file it opened, so the other file never grows.
func TestAppendNeverWritesThroughALogSwappedForASymlink(t *testing.T) {
brain := t.TempDir()
dir := filepath.Join(brain, "sessions")
require.NoError(t, os.MkdirAll(dir, 0o755))
victim := filepath.Join(brain, "victim.md")
require.NoError(t, os.WriteFile(victim, []byte("untouched\n"), 0o644))
log := filepath.Join(dir, "s.jsonl")
var stop atomic.Bool
done := make(chan struct{})
go func() {
defer close(done)
tmp := log + ".swap"
for i := 0; !stop.Load(); i++ {
_ = os.Remove(tmp)
if i%2 == 0 {
_ = os.WriteFile(tmp, nil, 0o644)
} else {
_ = os.Symlink(filepath.Join("..", "victim.md"), tmp)
}
_ = os.Rename(tmp, log)
}
}()
ok := 0
for i := 0; i < 20000; i++ {
if Append(dir, "s", Entry{SessionID: "s"}) == nil {
ok++
}
}
stop.Store(true)
<-done
got, _ := os.ReadFile(victim)
assert.Equal(t, "untouched\n", string(got), "a log line was written through the swapped-in symlink")
assert.Positive(t, ok, "positive control: appends to the regular log succeed")
}
+31 -3
View File
@@ -65,14 +65,42 @@ func Append(sessionsDir, sessionID string, entry Entry) error {
if err := checkID(sessionID); err != nil { if err := checkID(sessionID); err != nil {
return err return err
} }
if err := os.MkdirAll(sessionsDir, 0o755); err != nil { // Opened through an os.Root on the sessions dir's parent (the brain dir),
// so a sessions dir swapped for a symlink after the check below can
// redirect the log only inside that parent, never outside it (#173; the
// in-brain swap is the #167 residual).
parentDir, name := filepath.Dir(sessionsDir), filepath.Base(sessionsDir)
if err := os.MkdirAll(parentDir, 0o755); err != nil {
return fmt.Errorf("create sessions parent: %w", err)
}
root, err := os.OpenRoot(parentDir)
if err != nil {
return fmt.Errorf("open sessions parent: %w", err)
}
defer func() { _ = root.Close() }()
if err := root.Mkdir(name, 0o755); err != nil && !errors.Is(err, fs.ErrExist) {
return fmt.Errorf("create sessions dir: %w", err) return fmt.Errorf("create sessions dir: %w", err)
} }
path := filepath.Join(sessionsDir, sessionID+".jsonl") // The sessions dir itself must be a real directory, not a symlink that
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0o644) // redirects the log elsewhere (hyperguild#173).
if fi, err := root.Lstat(name); err != nil {
return fmt.Errorf("stat sessions dir: %w", err)
} else if !fi.IsDir() {
return fmt.Errorf("sessions dir %s is not a real directory", sessionsDir)
}
logRel := name + "/" + sessionID + ".jsonl"
f, err := root.OpenFile(logRel, os.O_CREATE|os.O_APPEND|os.O_WRONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0o644)
if err != nil { if err != nil {
return fmt.Errorf("open session log: %w", err) return fmt.Errorf("open session log: %w", err)
} }
// os.Root follows an in-root final symlink even with O_NOFOLLOW, so
// confirm, before writing, that the path is the regular file we opened.
hfi, herr := f.Stat()
lfi, lerr := root.Lstat(logRel)
if herr != nil || lerr != nil || !lfi.Mode().IsRegular() || !os.SameFile(lfi, hfi) {
_ = f.Close()
return fmt.Errorf("session log %s is not a regular file", logRel)
}
line, err := json.Marshal(entry) line, err := json.Marshal(entry)
if err != nil { if err != nil {
@@ -0,0 +1,104 @@
package symlinkguard
import (
"context"
"net"
"os"
"path/filepath"
"syscall"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// hyperguild#179: the tripwire looked only for symlinks, so a FIFO (or
// socket) in the brain passed it. A FIFO at classification.yaml then made
// startup exit; at a note path it could hang readers that skip the handle
// check.
func TestScanReportsFIFOsAndSocketsAsSpecialFiles(t *testing.T) {
brain := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(brain, "wiki", "w"), 0o755))
require.NoError(t, syscall.Mkfifo(filepath.Join(brain, "classification.yaml"), 0o644))
require.NoError(t, syscall.Mkfifo(filepath.Join(brain, "wiki", "w", "f.md"), 0o644))
l, err := net.Listen("unix", filepath.Join(brain, "s.sock"))
require.NoError(t, err)
defer func() { _ = l.Close() }()
require.NoError(t, os.WriteFile(filepath.Join(brain, "wiki", "w", "n.md"), []byte("n"), 0o644))
r, err := Scan(brain)
require.NoError(t, err)
assert.Equal(t, []string{"classification.yaml", "s.sock", "wiki/w/f.md"}, r.Special)
assert.False(t, r.Clean())
assert.Contains(t, r.String(), "special file")
}
// Watch removes a FIFO as it removes symlinks, and leaves a regular file
// (which Scan never reports) alone.
func TestWatchRemovesAFIFOAndLeavesAnUnreportedRegularFile(t *testing.T) {
brain := t.TempDir()
fifo := filepath.Join(brain, "f.md")
require.NoError(t, syscall.Mkfifo(fifo, 0o644))
regular := filepath.Join(brain, "n.md")
require.NoError(t, os.WriteFile(regular, []byte("n"), 0o644))
n := &recNotifier{}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go Watch(ctx, brain, 10*time.Millisecond, n)
require.Eventually(t, func() bool {
_, err := os.Lstat(fifo)
return os.IsNotExist(err)
}, 2*time.Second, 10*time.Millisecond, "the FIFO is removed")
require.Eventually(t, func() bool { return n.count() >= 1 }, 2*time.Second, 10*time.Millisecond)
b, err := os.ReadFile(regular)
require.NoError(t, err)
assert.Equal(t, "n", string(b))
n.mu.Lock()
defer n.mu.Unlock()
assert.Contains(t, n.msgs[0], "f.md")
}
// removeSpecial re-checks the type: a path that is a regular file or a
// directory at the re-check is not removed. (A swap between the re-check and
// the unlink can still remove one; Linux has no conditional unlink. The same
// window exists in removeSymlinks.)
func TestRemoveSpecialRefusesRegularFilesAndDirectories(t *testing.T) {
brain := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(brain, "n.md"), []byte("n"), 0o644))
require.NoError(t, os.MkdirAll(filepath.Join(brain, "d"), 0o755))
require.NoError(t, syscall.Mkfifo(filepath.Join(brain, "f.md"), 0o644))
removed, failed := removeSpecial(brain, []string{"n.md", "d", "f.md"})
assert.Equal(t, []string{"f.md"}, removed)
assert.Equal(t, []string{"n.md", "d"}, failed)
_, err := os.Stat(filepath.Join(brain, "n.md"))
assert.NoError(t, err)
}
// A special file Watch cannot remove (its directory is read-only) stays
// reported: the persistent-finding alert names it.
func TestWatchReportsASpecialFileItCannotRemove(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("root ignores directory permissions")
}
brain := t.TempDir()
ro := filepath.Join(brain, "ro")
require.NoError(t, os.MkdirAll(ro, 0o755))
require.NoError(t, syscall.Mkfifo(filepath.Join(ro, "f.md"), 0o644))
require.NoError(t, os.Chmod(ro, 0o555))
t.Cleanup(func() { _ = os.Chmod(ro, 0o755) })
n := &recNotifier{}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go Watch(ctx, brain, 10*time.Millisecond, n)
require.Eventually(t, func() bool { return n.count() >= 1 }, 2*time.Second, 10*time.Millisecond)
_, err := os.Lstat(filepath.Join(ro, "f.md"))
require.NoError(t, err, "the FIFO could not be removed")
n.mu.Lock()
defer n.mu.Unlock()
assert.Contains(t, n.msgs[0], "ro/f.md")
assert.Contains(t, n.msgs[0], "special file")
for _, m := range n.msgs {
assert.NotContains(t, m, "removed", "a file it could not remove was reported as removed")
}
}
@@ -46,10 +46,16 @@ type Notifier interface {
type Result struct { type Result struct {
Symlinks []string Symlinks []string
Unreadable []string Unreadable []string
// Special holds FIFOs, sockets and devices (hyperguild#179): a reader
// that opens one by path can hang, and a FIFO at classification.yaml
// stops startup.
Special []string
} }
// Clean reports whether the scan found nothing to act on. // Clean reports whether the scan found nothing to act on.
func (r Result) Clean() bool { return len(r.Symlinks) == 0 && len(r.Unreadable) == 0 } func (r Result) Clean() bool {
return len(r.Symlinks) == 0 && len(r.Unreadable) == 0 && len(r.Special) == 0
}
// String describes the findings for a log line or alert. Paths are quoted, so // String describes the findings for a log line or alert. Paths are quoted, so
// a name containing a newline cannot blur two findings together. // a name containing a newline cannot blur two findings together.
@@ -59,6 +65,9 @@ func (r Result) String() string {
if len(r.Symlinks) > 0 { if len(r.Symlinks) > 0 {
parts = append(parts, fmt.Sprintf("%d symlink(s): %s", len(r.Symlinks), q(r.Symlinks))) parts = append(parts, fmt.Sprintf("%d symlink(s): %s", len(r.Symlinks), q(r.Symlinks)))
} }
if len(r.Special) > 0 {
parts = append(parts, fmt.Sprintf("%d special file(s) (FIFO, socket or device; delete them): %s", len(r.Special), q(r.Special)))
}
if len(r.Unreadable) > 0 { if len(r.Unreadable) > 0 {
parts = append(parts, fmt.Sprintf("%d unreadable dir(s): %s", len(r.Unreadable), q(r.Unreadable))) parts = append(parts, fmt.Sprintf("%d unreadable dir(s): %s", len(r.Unreadable), q(r.Unreadable)))
} }
@@ -113,12 +122,16 @@ func Scan(brainDir string) (Result, error) {
if d.IsDir() && p == rootGit { if d.IsDir() && p == rootGit {
return filepath.SkipDir return filepath.SkipDir
} }
if d.Type()&os.ModeSymlink != 0 { switch {
case d.Type()&os.ModeSymlink != 0:
r.Symlinks = append(r.Symlinks, rel) r.Symlinks = append(r.Symlinks, rel)
case !d.IsDir() && !d.Type().IsRegular():
r.Special = append(r.Special, rel)
} }
return nil return nil
}) })
sort.Strings(r.Symlinks) sort.Strings(r.Symlinks)
sort.Strings(r.Special)
sort.Strings(r.Unreadable) sort.Strings(r.Unreadable)
if walkErr != nil { if walkErr != nil {
// Only the root's own error reaches here (deeper errors are recorded // Only the root's own error reaches here (deeper errors are recorded
@@ -182,14 +195,20 @@ func Watch(ctx context.Context, brainDir string, interval time.Duration, n Notif
q.push(fmt.Sprintf("brain symlink tripwire (hyperguild#157): removed %d symlink(s) from the brain dir: %s", q.push(fmt.Sprintf("brain symlink tripwire (hyperguild#157): removed %d symlink(s) from the brain dir: %s",
len(removed), Result{}.quoted(removed))) len(removed), Result{}.quoted(removed)))
} }
key = Result{Symlinks: failed, Unreadable: r.Unreadable}.String() sRemoved, sFailed := removeSpecial(brainDir, r.Special)
if len(sRemoved) > 0 {
slog.Error("brain symlink tripwire removed special files", "count", len(sRemoved))
q.push(fmt.Sprintf("brain symlink tripwire (hyperguild#179): removed %d special file(s) (FIFO, socket or device) from the brain dir: %s",
len(sRemoved), Result{}.quoted(sRemoved)))
}
key = Result{Symlinks: failed, Unreadable: r.Unreadable, Special: sFailed}.String()
} }
if key != reported { if key != reported {
reported = key reported = key
if key != "" { if key != "" {
slog.Error("brain symlink tripwire", "finding", key) slog.Error("brain symlink tripwire", "finding", key)
q.push("brain symlink tripwire (hyperguild#157): " + key + q.push("brain symlink tripwire (hyperguild#157, #179): " + key +
". Readers may follow symlinks out of the brain; fix them.") ". Symlinks can lead readers out of the brain and special files can hang them; remove them.")
} }
} }
} }
@@ -322,6 +341,40 @@ func permanent(err error) bool {
return errors.As(err, &p) && p.Permanent() return errors.As(err, &p) && p.Permanent()
} }
// removeSpecial unlinks each brain-relative FIFO, socket or device through
// an os.Root on the brain dir, after re-checking that it is still one: a
// path that is a regular file, a directory or a symlink at the re-check is
// left alone and reported as failed. The re-check narrows, but cannot close,
// the window before the unlink (Linux has no conditional unlink); the same
// window exists in removeSymlinks, and only a local writer can use it.
func removeSpecial(brainDir string, rels []string) (removed, failed []string) {
if len(rels) == 0 {
return nil, nil
}
resolved, err := filepath.EvalSymlinks(brainDir)
if err != nil {
return nil, rels
}
root, err := os.OpenRoot(resolved)
if err != nil {
return nil, rels
}
defer func() { _ = root.Close() }()
for _, rel := range rels {
fi, err := root.Lstat(rel)
if err != nil || fi.IsDir() || fi.Mode().IsRegular() || fi.Mode()&os.ModeSymlink != 0 {
failed = append(failed, rel)
continue
}
if err := root.Remove(rel); err != nil {
failed = append(failed, rel)
continue
}
removed = append(removed, rel)
}
return removed, failed
}
// removeSymlinks unlinks each brain-relative symlink through an os.Root on // removeSymlinks unlinks each brain-relative symlink through an os.Root on
// the brain dir, after re-checking it is still a symlink. Removing a symlink // the brain dir, after re-checking it is still a symlink. Removing a symlink
// never touches its target. It returns what was removed and what was not. // never touches its target. It returns what was removed and what was not.
@@ -42,6 +42,11 @@ func TestSyncRefusesSymlinkedNotesAndSurvivesSwapRaces(t *testing.T) {
brain := t.TempDir() brain := t.TempDir()
writeNote(t, brain, "wiki/w/facts/ok.md", "inside note") writeNote(t, brain, "wiki/w/facts/ok.md", "inside note")
require.NoError(t, os.Symlink(brainfstest.Outside(t), filepath.Join(brain, "wiki", "w", "facts", "leak.md"))) require.NoError(t, os.Symlink(brainfstest.Outside(t), filepath.Join(brain, "wiki", "w", "facts", "leak.md")))
// A link to a file elsewhere in the brain: os.Root allows it, only the
// regular-file checks refuse it (hyperguild#171). raw/ is not a scan root,
// so its sentinel can only arrive through the link.
inBrain := brainfstest.WriteSentinelAt(t, filepath.Join(brain, "raw", "s.md"))
require.NoError(t, os.Symlink(filepath.Join("..", "..", "..", "raw", "s.md"), filepath.Join(brain, "wiki", "w", "facts", "inlink.md")))
emb := &recordingEmbedder{} emb := &recordingEmbedder{}
store := &stubStore{} store := &stubStore{}
@@ -51,6 +56,7 @@ func TestSyncRefusesSymlinkedNotesAndSurvivesSwapRaces(t *testing.T) {
assert.NotContains(t, emb.all(), brainfstest.Sentinel) assert.NotContains(t, emb.all(), brainfstest.Sentinel)
for p := range store.upserts { for p := range store.upserts {
assert.NotContains(t, p, "leak.md") assert.NotContains(t, p, "leak.md")
assert.NotContains(t, p, "inlink.md")
} }
p := filepath.Join(brain, "knowledge", "r.md") p := filepath.Join(brain, "knowledge", "r.md")
@@ -61,6 +67,7 @@ func TestSyncRefusesSymlinkedNotesAndSurvivesSwapRaces(t *testing.T) {
return e.all() return e.all()
} }
brainfstest.AssertNoLeak(t, p, "inside", 1500*time.Millisecond, sync) brainfstest.AssertNoLeak(t, p, "inside", 1500*time.Millisecond, sync)
brainfstest.AssertNoLeakFrom(t, p, "inside", inBrain, 1500*time.Millisecond, sync)
brainfstest.AssertNoHang(t, p, "inside", 1500*time.Millisecond, 6*time.Second, func() { _ = sync() }) brainfstest.AssertNoHang(t, p, "inside", 1500*time.Millisecond, 6*time.Second, func() { _ = sync() })
} }