diff --git a/ingestion/internal/api/promote.go b/ingestion/internal/api/promote.go index c6ec563..4f21bb0 100644 --- a/ingestion/internal/api/promote.go +++ b/ingestion/internal/api/promote.go @@ -127,18 +127,16 @@ func PromoteNote(brainDir string, opts PromoteOptions) (string, error) { } fm.set("promoted_at", now) - if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { - return "", fmt.Errorf("create wing dir: %w", err) - } - // Write-then-delete: the source survives any write failure. + // Write-then-delete: the source survives any write failure. Both go + // through the brain root (hyperguild#188). promoted, err := StampProvenance(fm.render()+body, opts.Origin, opts.WrittenBy) if err != nil { return "", err } - if err := os.WriteFile(dest, []byte(promoted), 0o644); err != nil { + if _, err := putBrainFile(brainDir, dest, []byte(promoted)); err != nil { return "", fmt.Errorf("write promoted note: %w", err) } - if err := os.Remove(src); err != nil { + if err := removeBrainFile(brainDir, src); err != nil { return "", fmt.Errorf("promoted note written but source removal failed: %w", err) } @@ -196,14 +194,12 @@ func RejectNote(brainDir string, opts RejectOptions) (string, error) { fm.set("reject_reason", opts.Reason) } - if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { - return "", fmt.Errorf("create rejected dir: %w", err) - } - // Write-then-delete: the source survives any write failure. - if err := os.WriteFile(dest, []byte(fm.render()+body), 0o644); err != nil { + // Write-then-delete: the source survives any write failure. Both go + // through the brain root (hyperguild#188). + if _, err := putBrainFile(brainDir, dest, []byte(fm.render()+body)); err != nil { return "", fmt.Errorf("write rejected note: %w", err) } - if err := os.Remove(src); err != nil { + if err := removeBrainFile(brainDir, src); err != nil { return "", fmt.Errorf("rejected note written but source removal failed: %w", err) } diff --git a/ingestion/internal/api/promote_redirect_test.go b/ingestion/internal/api/promote_redirect_test.go new file mode 100644 index 0000000..49a21f2 --- /dev/null +++ b/ingestion/internal/api/promote_redirect_test.go @@ -0,0 +1,60 @@ +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#188: promote and reject wrote the destination with +// os.MkdirAll + os.WriteFile by path, so a symlinked wing (promote) or a +// symlinked raw/rejected (reject) redirected the note; the raw source was +// then removed. +func TestPromoteAndRejectRefuseASymlinkedDestinationDirectory(t *testing.T) { + brain := t.TempDir() + outside := t.TempDir() + raw := filepath.Join(brain, "raw") + require.NoError(t, os.MkdirAll(raw, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(raw, "2026-10-05-n.md"), []byte("---\ntitle: n\n---\nbody\n"), 0o644)) + require.NoError(t, os.MkdirAll(filepath.Join(brain, "wiki"), 0o755)) + require.NoError(t, os.Symlink(outside, filepath.Join(brain, "wiki", "lw"))) + require.NoError(t, os.Symlink(outside, filepath.Join(raw, "rejected"))) + + _, err := api.PromoteNote(brain, api.PromoteOptions{Filename: "2026-10-05-n.md", Wing: "lw", Hall: "facts"}) + assert.Error(t, err) + _, err = api.RejectNote(brain, api.RejectOptions{Filename: "2026-10-05-n.md"}) + assert.Error(t, err) + ents, _ := os.ReadDir(outside) + assert.Empty(t, ents, "a note was written outside the brain") + _, err = os.Stat(filepath.Join(raw, "2026-10-05-n.md")) + assert.NoError(t, err, "the raw source is kept when the write is refused") + + require.NoError(t, os.Remove(filepath.Join(raw, "rejected"))) + _, err = api.PromoteNote(brain, api.PromoteOptions{Filename: "2026-10-05-n.md", Wing: "w", Hall: "facts"}) + require.NoError(t, err, "positive control: promote into a real wing") +} + +// StampProvenanceFile read and rewrote the note by path (provenance.ApplyFile). +func TestStampProvenanceFileRefusesANoteBehindASymlinkedDirectory(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"))) + assert.Error(t, api.StampProvenanceFile(brain, "wiki/w/alias/v.md", "static", "x")) + got, _ := os.ReadFile(victim) + assert.Equal(t, "---\ntitle: v\n---\nuntouched\n", string(got)) + + 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---\nbody\n"), 0o644)) + require.NoError(t, api.StampProvenanceFile(brain, "wiki/w/facts/r.md", "static", "x"), "positive control") + got, _ = os.ReadFile(real) + assert.Contains(t, string(got), "written_by: x") +} diff --git a/ingestion/internal/api/provenance.go b/ingestion/internal/api/provenance.go index 7a6a776..e2cafb5 100644 --- a/ingestion/internal/api/provenance.go +++ b/ingestion/internal/api/provenance.go @@ -1,6 +1,10 @@ package api import ( + "fmt" + "path/filepath" + + "github.com/mathiasbq/hyperguild/ingestion/internal/brainfs" "github.com/mathiasbq/hyperguild/ingestion/internal/provenance" ) @@ -11,11 +15,62 @@ func StampProvenance(content, origin, writtenBy string) (string, error) { return provenance.Apply(content, provenance.Stamp{Origin: origin, WrittenBy: writtenBy}) } -// StampProvenanceFile applies StampProvenance to a brain-relative note path. +// StampProvenanceFile applies StampProvenance to a brain-relative note path, +// read and rewritten through the brain root (hyperguild#158). func StampProvenanceFile(brainDir, relPath, origin, writtenBy string) error { - _, abs, err := resolveWithin(brainDir, relPath) + rel, _, err := resolveWithin(brainDir, relPath) if err != nil { return err } - return provenance.ApplyFile(abs, provenance.Stamp{Origin: origin, WrittenBy: writtenBy}) + bfs, err := brainfs.Open(brainDir) + if err != nil { + return fmt.Errorf("open brain: %w", err) + } + defer func() { _ = bfs.Close() }() + b, err := bfs.ReadRegular(rel) + if err != nil { + return fmt.Errorf("read for provenance: %w", err) + } + out, err := provenance.Apply(string(b), provenance.Stamp{Origin: origin, WrittenBy: writtenBy}) + if err != nil { + return err + } + if out == string(b) { + return nil + } + if err := bfs.WriteRegular(rel, []byte(out)); err != nil { + return fmt.Errorf("write provenance: %w", err) + } + return nil +} + +// putBrainFile creates or replaces the file at abs (inside brainDir) through +// the brain root, never through a symlinked directory or file (#188). +func putBrainFile(brainDir, abs string, data []byte) (string, error) { + rel, err := filepath.Rel(brainDir, abs) + if err != nil { + return "", err + } + rel = filepath.ToSlash(rel) + bfs, err := brainfs.Open(brainDir) + if err != nil { + return "", fmt.Errorf("open brain: %w", err) + } + defer func() { _ = bfs.Close() }() + return rel, bfs.PutRegular(rel, data) +} + +// removeBrainFile removes the file at abs (inside brainDir) through the +// brain root. +func removeBrainFile(brainDir, abs string) error { + rel, err := filepath.Rel(brainDir, abs) + if err != nil { + return err + } + bfs, err := brainfs.Open(brainDir) + if err != nil { + return fmt.Errorf("open brain: %w", err) + } + defer func() { _ = bfs.Close() }() + return bfs.Remove(filepath.ToSlash(rel)) } diff --git a/ingestion/internal/brain/index.go b/ingestion/internal/brain/index.go index d700b74..51373c0 100644 --- a/ingestion/internal/brain/index.go +++ b/ingestion/internal/brain/index.go @@ -7,7 +7,6 @@ import ( "fmt" "io" "io/fs" - "os" "path/filepath" "sort" "strings" @@ -63,8 +62,10 @@ func BuildWingIndex(brainDir, wing string) error { fmt.Fprintf(&b, "| %s | [%s](%s/%s.md) | %s |\n", e.Hall, e.Title, e.Hall, e.Slug, e.Created) } - dest := filepath.Join(wingDir, "_index.md") - return os.WriteFile(dest, []byte(b.String()), 0o644) + // Written through the brain root, never through a symlinked _index.md + // (hyperguild#158). + _ = wingDir + return bfs.PutRegular("wiki/"+w+"/_index.md", []byte(b.String())) } // BuildAllWingIndexes regenerates _index.md for every wing under brain/wiki/. diff --git a/ingestion/internal/brain/tunnel.go b/ingestion/internal/brain/tunnel.go index db3be1b..ffc635d 100644 --- a/ingestion/internal/brain/tunnel.go +++ b/ingestion/internal/brain/tunnel.go @@ -5,7 +5,6 @@ import ( "errors" "fmt" "io/fs" - "os" "path" "path/filepath" "strings" @@ -176,35 +175,23 @@ func logFuzzyCandidates(brainDir, sourcePath string, cs []TunnelCandidate) error if len(cs) == 0 { return nil } - rawDir := filepath.Join(brainDir, "raw") - if err := os.MkdirAll(rawDir, 0o755); err != nil { - return err - } - stamp := time.Now().UTC().Format("2006-01-02") - path := filepath.Join(rawDir, "tunnel-candidates-"+stamp+".md") - existed := fileExists(path) - f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + // Appended through the brain root, never through a symlinked raw/ or + // log file (hyperguild#158). + bfs, err := brainfs.Open(brainDir) if err != nil { - return err + return fmt.Errorf("open brain: %w", err) } - defer func() { _ = f.Close() }() - if !existed { - if _, err := f.WriteString("# Tunnel candidates " + stamp + "\n\nFuzzy cross-wing matches surfaced by AutoTunnel. Review and promote to a tunnel via `brain_tunnel` if relevant.\n\n"); err != nil { - return err - } + defer func() { _ = bfs.Close() }() + stamp := time.Now().UTC().Format("2006-01-02") + rel := "raw/tunnel-candidates-" + stamp + ".md" + var buf strings.Builder + if _, err := bfs.Lstat(rel); errors.Is(err, fs.ErrNotExist) { + buf.WriteString("# Tunnel candidates " + stamp + "\n\nFuzzy cross-wing matches surfaced by AutoTunnel. Review and promote to a tunnel via `brain_tunnel` if relevant.\n\n") } for _, c := range cs { - line := fmt.Sprintf("- `%s` ↔ `%s` (term: %q)\n", sourcePath, c.TargetPath, c.MatchedTerm) - if _, err := f.WriteString(line); err != nil { - return err - } + fmt.Fprintf(&buf, "- `%s` ↔ `%s` (term: %q)\n", sourcePath, c.TargetPath, c.MatchedTerm) } - return nil -} - -func fileExists(p string) bool { - _, err := os.Stat(p) - return err == nil + return bfs.AppendRegular(rel, []byte(buf.String())) } // WriteTunnel appends a bidirectional wikilink between sourcePath and diff --git a/ingestion/internal/brain/writers_redirect_test.go b/ingestion/internal/brain/writers_redirect_test.go new file mode 100644 index 0000000..66f7d2b --- /dev/null +++ b/ingestion/internal/brain/writers_redirect_test.go @@ -0,0 +1,52 @@ +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#158: BuildWingIndex wrote wiki//_index.md with +// os.WriteFile, so a symlinked _index.md was written through. +func TestBuildWingIndexDoesNotWriteThroughASymlinkedIndex(t *testing.T) { + 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", "n.md"), []byte("---\ntitle: zqn\n---\n"), 0o644)) + victim := filepath.Join(b, "raw", "v.md") + require.NoError(t, os.MkdirAll(filepath.Dir(victim), 0o755)) + require.NoError(t, os.WriteFile(victim, []byte("untouched"), 0o644)) + require.NoError(t, os.Symlink(filepath.Join("..", "..", "raw", "v.md"), filepath.Join(b, "wiki", "w", "_index.md"))) + assert.Error(t, brain.BuildWingIndex(b, "w")) + got, _ := os.ReadFile(victim) + assert.Equal(t, "untouched", string(got)) + + require.NoError(t, os.Remove(filepath.Join(b, "wiki", "w", "_index.md"))) + require.NoError(t, brain.BuildWingIndex(b, "w"), "positive control") + idx, _ := os.ReadFile(filepath.Join(b, "wiki", "w", "_index.md")) + assert.Contains(t, string(idx), "zqn") +} + +// AutoTunnel's fuzzy-candidate log appended to raw/tunnel-candidates-*.md +// with os.OpenFile, so a symlinked raw/ (or log file) was written through. +func TestAutoTunnelDoesNotAppendTheCandidateLogThroughASymlinkedRaw(t *testing.T) { + b := t.TempDir() + outside := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(b, "wiki", "a", "facts"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(b, "wiki", "b", "facts"), 0o755)) + // "zq widget" matches as a substring of "zq widgets", a fuzzy candidate. + require.NoError(t, os.WriteFile(filepath.Join(b, "wiki", "b", "facts", "t.md"), []byte("---\ntitle: Zq Widget\n---\n"), 0o644)) + require.NoError(t, os.Symlink(outside, filepath.Join(b, "raw"))) + _ = brain.AutoTunnel(b, "wiki/a/facts/s.md", "about zq widgets") + ents, _ := os.ReadDir(outside) + assert.Empty(t, ents, "the candidate log was written outside the brain") + + require.NoError(t, os.Remove(filepath.Join(b, "raw"))) + require.NoError(t, brain.AutoTunnel(b, "wiki/a/facts/s.md", "about zq widgets")) + logs, _ := filepath.Glob(filepath.Join(b, "raw", "tunnel-candidates-*.md")) + assert.Len(t, logs, 1, "positive control: the fuzzy candidate is logged in the real raw/") +} diff --git a/ingestion/internal/brainfs/brainfs.go b/ingestion/internal/brainfs/brainfs.go index eec5f8e..414d9a2 100644 --- a/ingestion/internal/brainfs/brainfs.go +++ b/ingestion/internal/brainfs/brainfs.go @@ -204,6 +204,16 @@ func (b *Brain) AppendRegular(rel string, data []byte) error { return f.Close() } +// Remove removes the file at a clean brain-relative path through the root, +// refusing a path behind a symlinked directory. A final symlink is removed +// itself, never its target. +func (b *Brain) Remove(rel string) error { + if err := b.RealParent(rel); err != nil { + return err + } + return b.root.Remove(rel) +} + // RealParent returns ErrNotRegular unless rel is clean and every directory // above it is a real directory, for writers that do their own I/O (an atomic // rename) but must not be redirected by a symlinked directory. diff --git a/ingestion/internal/notes/edit_redirect_test.go b/ingestion/internal/notes/edit_redirect_test.go new file mode 100644 index 0000000..7a75399 --- /dev/null +++ b/ingestion/internal/notes/edit_redirect_test.go @@ -0,0 +1,25 @@ +package notes + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// hyperguild#188/#173: brain_edit's atomic rename must not be redirected by +// a symlinked directory inside the brain. +func TestEditRefusesANoteBehindASymlinkedDirectory(t *testing.T) { + b := t.TempDir() + victim := filepath.Join(b, "raw", "facts", "v.md") + require.NoError(t, os.MkdirAll(filepath.Dir(victim), 0o755)) + require.NoError(t, os.WriteFile(victim, []byte("old text\n"), 0o644)) + require.NoError(t, os.MkdirAll(filepath.Join(b, "wiki", "w"), 0o755)) + require.NoError(t, os.Symlink(filepath.Join("..", "..", "raw", "facts"), filepath.Join(b, "wiki", "w", "alias"))) + _, err := Edit(b, "wiki/w/alias/v.md", "old text", "new text", ScopeNotes) + assert.Error(t, err) + got, _ := os.ReadFile(victim) + assert.Equal(t, "old text\n", string(got)) +} diff --git a/ingestion/internal/provenance/provenance.go b/ingestion/internal/provenance/provenance.go index 1343e0d..d48807f 100644 --- a/ingestion/internal/provenance/provenance.go +++ b/ingestion/internal/provenance/provenance.go @@ -28,7 +28,6 @@ import ( "context" "errors" "fmt" - "os" "regexp" "strings" @@ -102,24 +101,6 @@ func Apply(content string, s Stamp) (string, error) { return out, nil } -// ApplyFile applies the stamp to the note at absPath in place. -func ApplyFile(absPath string, s Stamp) error { - b, err := os.ReadFile(absPath) - if err != nil { - return fmt.Errorf("read for provenance: %w", err) - } - out, err := Apply(string(b), s) - if err != nil { - return err - } - if out == string(b) { - return nil - } - if err := os.WriteFile(absPath, []byte(out), 0o644); err != nil { - return fmt.Errorf("write provenance: %w", err) - } - return nil -} // plainGroups keeps the provably plain key groups of a frontmatter block. func plainGroups(block string) string { diff --git a/ingestion/internal/wiki/index.go b/ingestion/internal/wiki/index.go index 0bab797..aa1b5d5 100644 --- a/ingestion/internal/wiki/index.go +++ b/ingestion/internal/wiki/index.go @@ -3,8 +3,6 @@ package wiki import ( "fmt" - "os" - "path/filepath" "strings" "github.com/mathiasbq/hyperguild/ingestion/internal/brainfs" @@ -50,8 +48,8 @@ func RebuildIndex(brainDir, date string) error { sb.WriteString("\n") } - dest := filepath.Join(brainDir, "wiki", "index.md") - return os.WriteFile(dest, []byte(sb.String()), 0o644) + // Written through the brain root, never through a symlink (#158). + return bfs.PutRegular("wiki/index.md", []byte(sb.String())) } // pageFirstSentence reads the page through the brain root, regular files diff --git a/ingestion/internal/wiki/log.go b/ingestion/internal/wiki/log.go index 1b19ae8..a904da5 100644 --- a/ingestion/internal/wiki/log.go +++ b/ingestion/internal/wiki/log.go @@ -3,8 +3,7 @@ package wiki import ( "fmt" - "os" - "path/filepath" + "github.com/mathiasbq/hyperguild/ingestion/internal/brainfs" "strings" ) @@ -27,14 +26,14 @@ func AppendLog(brainDir, source string, pages, warnings []string, date string) e } sb.WriteString("\n") - logPath := filepath.Join(brainDir, "log.md") - f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + // Appended through the brain root, never through a symlink (#158). + bfs, err := brainfs.Open(brainDir) if err != nil { - return fmt.Errorf("open log: %w", err) + return fmt.Errorf("open brain: %w", err) } - if _, err = f.WriteString(sb.String()); err != nil { - f.Close() //nolint:errcheck + defer func() { _ = bfs.Close() }() + if err := bfs.AppendRegular("log.md", []byte(sb.String())); err != nil { return fmt.Errorf("write log: %w", err) } - return f.Close() + return nil } diff --git a/ingestion/internal/wiki/writers_redirect_test.go b/ingestion/internal/wiki/writers_redirect_test.go new file mode 100644 index 0000000..f47267e --- /dev/null +++ b/ingestion/internal/wiki/writers_redirect_test.go @@ -0,0 +1,35 @@ +package wiki + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// hyperguild#158: RebuildIndex wrote wiki/index.md and AppendLog appended +// log.md with os.WriteFile / os.OpenFile, so a symlinked file was written +// through. +func TestRebuildIndexAndAppendLogDoNotWriteThroughSymlinks(t *testing.T) { + b := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(b, "wiki", "concepts"), 0o755)) + victim := filepath.Join(b, "raw", "v.md") + require.NoError(t, os.MkdirAll(filepath.Dir(victim), 0o755)) + require.NoError(t, os.WriteFile(victim, []byte("untouched"), 0o644)) + require.NoError(t, os.Symlink(filepath.Join("..", "raw", "v.md"), filepath.Join(b, "wiki", "index.md"))) + require.NoError(t, os.Symlink(filepath.Join("raw", "v.md"), filepath.Join(b, "log.md"))) + + assert.Error(t, RebuildIndex(b, "2026-10-05")) + assert.Error(t, AppendLog(b, "src", []string{"p"}, nil, "2026-10-05")) + got, _ := os.ReadFile(victim) + assert.Equal(t, "untouched", string(got)) + + require.NoError(t, os.Remove(filepath.Join(b, "wiki", "index.md"))) + require.NoError(t, os.Remove(filepath.Join(b, "log.md"))) + require.NoError(t, RebuildIndex(b, "2026-10-05"), "positive control") + require.NoError(t, AppendLog(b, "src", []string{"p"}, nil, "2026-10-05"), "positive control") + l, _ := os.ReadFile(filepath.Join(b, "log.md")) + assert.Contains(t, string(l), "src") +}