diff --git a/ingestion/internal/api/promote.go b/ingestion/internal/api/promote.go index 4f21bb0..cfeae31 100644 --- a/ingestion/internal/api/promote.go +++ b/ingestion/internal/api/promote.go @@ -133,7 +133,9 @@ func PromoteNote(brainDir string, opts PromoteOptions) (string, error) { if err != nil { return "", err } - if _, err := putBrainFile(brainDir, dest, []byte(promoted)); err != nil { + // Created with O_EXCL: a note that appeared after the collision check + // above is never overwritten. + if err := createBrainFile(brainDir, dest, []byte(promoted)); err != nil { return "", fmt.Errorf("write promoted note: %w", err) } if err := removeBrainFile(brainDir, src); err != nil { @@ -196,7 +198,7 @@ func RejectNote(brainDir string, opts RejectOptions) (string, error) { // 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 { + if err := createBrainFile(brainDir, dest, []byte(fm.render()+body)); err != nil { return "", fmt.Errorf("write rejected note: %w", err) } if err := removeBrainFile(brainDir, src); err != nil { diff --git a/ingestion/internal/api/provenance.go b/ingestion/internal/api/provenance.go index e2cafb5..0cd107c 100644 --- a/ingestion/internal/api/provenance.go +++ b/ingestion/internal/api/provenance.go @@ -44,20 +44,19 @@ func StampProvenanceFile(brainDir, relPath, origin, writtenBy string) error { 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) { +// createBrainFile creates the file at abs (inside brainDir) through the +// brain root and fails if anything is already there. +func createBrainFile(brainDir, abs string, data []byte) error { rel, err := filepath.Rel(brainDir, abs) if err != nil { - return "", err + return err } - rel = filepath.ToSlash(rel) bfs, err := brainfs.Open(brainDir) if err != nil { - return "", fmt.Errorf("open brain: %w", err) + return fmt.Errorf("open brain: %w", err) } defer func() { _ = bfs.Close() }() - return rel, bfs.PutRegular(rel, data) + return bfs.CreateRegular(filepath.ToSlash(rel), data) } // removeBrainFile removes the file at abs (inside brainDir) through the diff --git a/ingestion/internal/brain/index.go b/ingestion/internal/brain/index.go index 51373c0..86dc3e3 100644 --- a/ingestion/internal/brain/index.go +++ b/ingestion/internal/brain/index.go @@ -7,7 +7,6 @@ import ( "fmt" "io" "io/fs" - "path/filepath" "sort" "strings" "time" @@ -31,7 +30,6 @@ func BuildWingIndex(brainDir, wing string) error { if w == "" { return fmt.Errorf("invalid wing %q", wing) } - wingDir := filepath.Join(brainDir, "wiki", w) // Listed and read through the brain root (hyperguild#157): a wing or // hall that is a symlink is not followed, and notes are regular files. bfs, err := brainfs.Open(brainDir) @@ -64,7 +62,6 @@ func BuildWingIndex(brainDir, wing string) error { // Written through the brain root, never through a symlinked _index.md // (hyperguild#158). - _ = wingDir return bfs.PutRegular("wiki/"+w+"/_index.md", []byte(b.String())) } diff --git a/ingestion/internal/brainfs/brainfs.go b/ingestion/internal/brainfs/brainfs.go index 414d9a2..f7f423d 100644 --- a/ingestion/internal/brainfs/brainfs.go +++ b/ingestion/internal/brainfs/brainfs.go @@ -156,6 +156,25 @@ func (b *Brain) PutRegular(rel string, data []byte) error { } else if !errors.Is(err, fs.ErrNotExist) { return err } + return b.createNew(rel, data) +} + +// CreateRegular creates a new regular file at a brain-relative path, with +// PutRegular's parent rules, and fails with fs.ErrExist if anything is +// already there: for writers that must never overwrite (promote, reject). +func (b *Brain) CreateRegular(rel string, data []byte) error { + if err := cleanRel(rel); err != nil { + return err + } + if err := b.mkdirReal(path.Dir(rel)); err != nil { + return err + } + return b.createNew(rel, data) +} + +// createNew creates rel with O_EXCL and writes data only after confirming +// the path is the regular file it created. +func (b *Brain) createNew(rel string, data []byte) error { f, err := b.root.OpenFile(rel, os.O_WRONLY|os.O_CREATE|os.O_EXCL|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0o644) if err != nil { return err diff --git a/ingestion/internal/brainfs/brainfs_test.go b/ingestion/internal/brainfs/brainfs_test.go index eb3b9aa..11b8b7d 100644 --- a/ingestion/internal/brainfs/brainfs_test.go +++ b/ingestion/internal/brainfs/brainfs_test.go @@ -453,3 +453,43 @@ func TestRealParentRefusesASymlinkedDirectoryOrUncleanPath(t *testing.T) { assert.ErrorIs(t, b.RealParent("wiki/../raw/x.md"), ErrNotRegular) assert.NoError(t, b.RealParent("wiki/x.md"), "positive control") } + +// cleanRel must refuse an unclean path on its own: here the ".." resolves, +// after a symlinked directory, to a real regular file, so only the clean +// check stands between the call and that file. +func TestAppendRegularRefusesAnUncleanPathThatResolvesToARealFile(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "raw", "keep"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "raw", "v.md"), []byte("untouched"), 0o644)) + require.NoError(t, os.Symlink(filepath.Join("raw", "keep"), filepath.Join(dir, "indir"))) + b, err := Open(dir) + require.NoError(t, err) + defer func() { _ = b.Close() }() + assert.ErrorIs(t, b.AppendRegular("indir/../v.md", []byte("x")), ErrNotRegular) + assert.ErrorIs(t, b.PutRegular("indir/../v.md", []byte("x")), ErrNotRegular) + got, _ := os.ReadFile(filepath.Join(dir, "raw", "v.md")) + assert.Equal(t, "untouched", string(got)) +} + +func TestRemoveRefusesAPathBehindASymlinkedDirectoryAndRemovesOnlyTheLink(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("keep"), 0o644)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki"), 0o755)) + require.NoError(t, os.Symlink(filepath.Join("..", "raw"), filepath.Join(dir, "wiki", "alias"))) + require.NoError(t, os.Symlink(filepath.Join("..", "raw", "v.md"), filepath.Join(dir, "wiki", "link.md"))) + require.NoError(t, os.WriteFile(filepath.Join(dir, "wiki", "n.md"), []byte("n"), 0o644)) + b, err := Open(dir) + require.NoError(t, err) + defer func() { _ = b.Close() }() + + assert.ErrorIs(t, b.Remove("wiki/alias/v.md"), ErrNotRegular) + _, err = os.Stat(filepath.Join(dir, "raw", "v.md")) + assert.NoError(t, err, "the file behind the symlinked directory is kept") + require.NoError(t, b.Remove("wiki/link.md")) + _, err = os.Stat(filepath.Join(dir, "raw", "v.md")) + assert.NoError(t, err, "removing a final symlink never removes its target") + require.NoError(t, b.Remove("wiki/n.md"), "positive control") + _, err = os.Stat(filepath.Join(dir, "wiki", "n.md")) + assert.True(t, os.IsNotExist(err)) +} diff --git a/ingestion/internal/notes/edit_redirect_test.go b/ingestion/internal/notes/edit_redirect_test.go index 7a75399..dc52575 100644 --- a/ingestion/internal/notes/edit_redirect_test.go +++ b/ingestion/internal/notes/edit_redirect_test.go @@ -13,6 +13,9 @@ import ( // a symlinked directory inside the brain. func TestEditRefusesANoteBehindASymlinkedDirectory(t *testing.T) { b := t.TempDir() + // Edit refuses a tree that is not the sync source before any path + // check, so the test needs a .git to reach the symlink refusal. + require.NoError(t, os.MkdirAll(filepath.Join(b, ".git"), 0o755)) 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)) @@ -22,4 +25,12 @@ func TestEditRefusesANoteBehindASymlinkedDirectory(t *testing.T) { assert.Error(t, err) got, _ := os.ReadFile(victim) assert.Equal(t, "old text\n", string(got)) + + real := filepath.Join(b, "wiki", "w", "facts", "r.md") + require.NoError(t, os.MkdirAll(filepath.Dir(real), 0o755)) + require.NoError(t, os.WriteFile(real, []byte("old text\n"), 0o644)) + _, err = Edit(b, "wiki/w/facts/r.md", "old text", "new text", ScopeNotes) + require.NoError(t, err, "positive control: a note in a real directory is edited") + got, _ = os.ReadFile(real) + assert.Equal(t, "new text\n", string(got)) } diff --git a/ingestion/internal/provenance/provenance.go b/ingestion/internal/provenance/provenance.go index d48807f..040f4fc 100644 --- a/ingestion/internal/provenance/provenance.go +++ b/ingestion/internal/provenance/provenance.go @@ -101,7 +101,6 @@ func Apply(content string, s Stamp) (string, error) { return out, nil } - // plainGroups keeps the provably plain key groups of a frontmatter block. func plainGroups(block string) string { lines := strings.Split(strings.TrimSuffix(block, "\n"), "\n")