Writers that created or rewrote brain files by path followed symlinks: - promote/reject: os.MkdirAll + os.WriteFile of the destination, then os.Remove of the raw source. Now PutRegular + brainfs.Remove; the source is kept when the write is refused. - StampProvenanceFile (brain_edit's re-stamp): provenance.ApplyFile read and rewrote by path. Now ReadRegular + WriteRegular; ApplyFile, now unused, is removed. - BuildWingIndex (_index.md) and wiki.RebuildIndex (wiki/index.md): now PutRegular. - wiki.AppendLog (log.md) and AutoTunnel's fuzzy-candidate log (raw/tunnel-candidates-*.md): now AppendRegular; tunnel's fileExists, now unused, is removed. - New brainfs.Remove: clean path, real parent, removes a final symlink itself, never its target. brain_edit's own atomic rename was already refused behind a symlinked directory by #187's read check; a guard test pins it. Pipeline writes, BackfillRefs, the watcher's raw/ markers and the session archive in cmd/server follow in a separate PR (#158). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -127,18 +127,16 @@ func PromoteNote(brainDir string, opts PromoteOptions) (string, error) {
|
|||||||
}
|
}
|
||||||
fm.set("promoted_at", now)
|
fm.set("promoted_at", now)
|
||||||
|
|
||||||
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
// Write-then-delete: the source survives any write failure. Both go
|
||||||
return "", fmt.Errorf("create wing dir: %w", err)
|
// through the brain root (hyperguild#188).
|
||||||
}
|
|
||||||
// Write-then-delete: the source survives any write failure.
|
|
||||||
promoted, err := StampProvenance(fm.render()+body, opts.Origin, opts.WrittenBy)
|
promoted, err := StampProvenance(fm.render()+body, opts.Origin, opts.WrittenBy)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
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)
|
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)
|
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)
|
fm.set("reject_reason", opts.Reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
// Write-then-delete: the source survives any write failure. Both go
|
||||||
return "", fmt.Errorf("create rejected dir: %w", err)
|
// through the brain root (hyperguild#188).
|
||||||
}
|
if _, err := putBrainFile(brainDir, dest, []byte(fm.render()+body)); err != nil {
|
||||||
// Write-then-delete: the source survives any write failure.
|
|
||||||
if err := os.WriteFile(dest, []byte(fm.render()+body), 0o644); err != nil {
|
|
||||||
return "", fmt.Errorf("write rejected note: %w", err)
|
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)
|
return "", fmt.Errorf("rejected note written but source removal failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/provenance"
|
"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})
|
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 {
|
func StampProvenanceFile(brainDir, relPath, origin, writtenBy string) error {
|
||||||
_, abs, err := resolveWithin(brainDir, relPath)
|
rel, _, err := resolveWithin(brainDir, relPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"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)
|
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")
|
// Written through the brain root, never through a symlinked _index.md
|
||||||
return os.WriteFile(dest, []byte(b.String()), 0o644)
|
// (hyperguild#158).
|
||||||
|
_ = wingDir
|
||||||
|
return bfs.PutRegular("wiki/"+w+"/_index.md", []byte(b.String()))
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildAllWingIndexes regenerates _index.md for every wing under brain/wiki/.
|
// BuildAllWingIndexes regenerates _index.md for every wing under brain/wiki/.
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -176,35 +175,23 @@ func logFuzzyCandidates(brainDir, sourcePath string, cs []TunnelCandidate) error
|
|||||||
if len(cs) == 0 {
|
if len(cs) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
rawDir := filepath.Join(brainDir, "raw")
|
// Appended through the brain root, never through a symlinked raw/ or
|
||||||
if err := os.MkdirAll(rawDir, 0o755); err != nil {
|
// log file (hyperguild#158).
|
||||||
return err
|
bfs, err := brainfs.Open(brainDir)
|
||||||
}
|
|
||||||
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)
|
|
||||||
if err != nil {
|
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 {
|
for _, c := range cs {
|
||||||
line := fmt.Sprintf("- `%s` ↔ `%s` (term: %q)\n", sourcePath, c.TargetPath, c.MatchedTerm)
|
fmt.Fprintf(&buf, "- `%s` ↔ `%s` (term: %q)\n", sourcePath, c.TargetPath, c.MatchedTerm)
|
||||||
if _, err := f.WriteString(line); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
}
|
return bfs.AppendRegular(rel, []byte(buf.String()))
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func fileExists(p string) bool {
|
|
||||||
_, err := os.Stat(p)
|
|
||||||
return err == nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// WriteTunnel appends a bidirectional wikilink between sourcePath and
|
// WriteTunnel appends a bidirectional wikilink between sourcePath and
|
||||||
|
|||||||
@@ -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/<wing>/_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/")
|
||||||
|
}
|
||||||
@@ -204,6 +204,16 @@ func (b *Brain) AppendRegular(rel string, data []byte) error {
|
|||||||
return f.Close()
|
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
|
// 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
|
// 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.
|
// rename) but must not be redirected by a symlinked directory.
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -28,7 +28,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -102,24 +101,6 @@ func Apply(content string, s Stamp) (string, error) {
|
|||||||
return out, nil
|
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.
|
// plainGroups keeps the provably plain key groups of a frontmatter block.
|
||||||
func plainGroups(block string) string {
|
func plainGroups(block string) string {
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ package wiki
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
|
||||||
@@ -50,8 +48,8 @@ func RebuildIndex(brainDir, date string) error {
|
|||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
dest := filepath.Join(brainDir, "wiki", "index.md")
|
// Written through the brain root, never through a symlink (#158).
|
||||||
return os.WriteFile(dest, []byte(sb.String()), 0o644)
|
return bfs.PutRegular("wiki/index.md", []byte(sb.String()))
|
||||||
}
|
}
|
||||||
|
|
||||||
// pageFirstSentence reads the page through the brain root, regular files
|
// pageFirstSentence reads the page through the brain root, regular files
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ package wiki
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -27,14 +26,14 @@ func AppendLog(brainDir, source string, pages, warnings []string, date string) e
|
|||||||
}
|
}
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
|
|
||||||
logPath := filepath.Join(brainDir, "log.md")
|
// Appended through the brain root, never through a symlink (#158).
|
||||||
f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
bfs, err := brainfs.Open(brainDir)
|
||||||
if err != nil {
|
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 {
|
defer func() { _ = bfs.Close() }()
|
||||||
f.Close() //nolint:errcheck
|
if err := bfs.AppendRegular("log.md", []byte(sb.String())); err != nil {
|
||||||
return fmt.Errorf("write log: %w", err)
|
return fmt.Errorf("write log: %w", err)
|
||||||
}
|
}
|
||||||
return f.Close()
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user