fix(brain): wing indexes and the link check read through brainfs (#157, slice 5a) #175
@@ -2,12 +2,18 @@ package brain
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
|
||||||
)
|
)
|
||||||
|
|
||||||
// noteEntry is one row in a Wing _index.md.
|
// noteEntry is one row in a Wing _index.md.
|
||||||
@@ -27,13 +33,18 @@ func BuildWingIndex(brainDir, wing string) error {
|
|||||||
return fmt.Errorf("invalid wing %q", wing)
|
return fmt.Errorf("invalid wing %q", wing)
|
||||||
}
|
}
|
||||||
wingDir := filepath.Join(brainDir, "wiki", w)
|
wingDir := filepath.Join(brainDir, "wiki", w)
|
||||||
if _, err := os.Stat(wingDir); os.IsNotExist(err) {
|
// 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)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open brain: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = bfs.Close() }()
|
||||||
|
if _, err := bfs.Lstat("wiki/" + w); errors.Is(err, fs.ErrNotExist) {
|
||||||
return nil
|
return nil
|
||||||
} else if err != nil {
|
|
||||||
return fmt.Errorf("stat wing: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
entries, err := collectWingEntries(wingDir)
|
entries, err := collectWingEntries(bfs, "wiki/"+w)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -58,51 +69,47 @@ func BuildWingIndex(brainDir, wing string) error {
|
|||||||
|
|
||||||
// BuildAllWingIndexes regenerates _index.md for every wing under brain/wiki/.
|
// BuildAllWingIndexes regenerates _index.md for every wing under brain/wiki/.
|
||||||
func BuildAllWingIndexes(brainDir string) error {
|
func BuildAllWingIndexes(brainDir string) error {
|
||||||
wikiDir := filepath.Join(brainDir, "wiki")
|
bfs, err := brainfs.Open(brainDir)
|
||||||
ents, err := os.ReadDir(wikiDir)
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
return fmt.Errorf("open brain: %w", err)
|
||||||
|
}
|
||||||
|
wings, err := bfs.ReadDirDirs("wiki")
|
||||||
|
_ = bfs.Close()
|
||||||
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("read wiki: %w", err)
|
return fmt.Errorf("read wiki: %w", err)
|
||||||
}
|
}
|
||||||
for _, e := range ents {
|
for _, w := range wings {
|
||||||
if !e.IsDir() {
|
if err := BuildWingIndex(brainDir, w); err != nil {
|
||||||
continue
|
return fmt.Errorf("index %s: %w", w, err)
|
||||||
}
|
|
||||||
if err := BuildWingIndex(brainDir, e.Name()); err != nil {
|
|
||||||
return fmt.Errorf("index %s: %w", e.Name(), err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func collectWingEntries(wingDir string) ([]noteEntry, error) {
|
func collectWingEntries(bfs *brainfs.Brain, wingRel string) ([]noteEntry, error) {
|
||||||
var out []noteEntry
|
var out []noteEntry
|
||||||
ents, err := os.ReadDir(wingDir)
|
halls, err := bfs.ReadDirDirs(wingRel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("read wing: %w", err)
|
return nil, fmt.Errorf("read wing: %w", err)
|
||||||
}
|
}
|
||||||
for _, hallEnt := range ents {
|
for _, hall := range halls {
|
||||||
if !hallEnt.IsDir() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
hall := hallEnt.Name()
|
|
||||||
if !IsValidHall(hall) {
|
if !IsValidHall(hall) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
hallDir := filepath.Join(wingDir, hall)
|
hallRel := wingRel + "/" + hall
|
||||||
notes, err := os.ReadDir(hallDir)
|
names, err := bfs.ReadDirRegular(hallRel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("read hall %s: %w", hall, err)
|
return nil, fmt.Errorf("read hall %s: %w", hall, err)
|
||||||
}
|
}
|
||||||
for _, n := range notes {
|
for _, name := range names {
|
||||||
if n.IsDir() || !strings.HasSuffix(n.Name(), ".md") || n.Name() == "_index.md" {
|
if !strings.HasSuffix(name, ".md") || name == "_index.md" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
slug := strings.TrimSuffix(n.Name(), ".md")
|
slug := strings.TrimSuffix(name, ".md")
|
||||||
full := filepath.Join(hallDir, n.Name())
|
title, created := readTitleAndCreatedIn(bfs, hallRel+"/"+name, slug)
|
||||||
title, created := readTitleAndCreated(full, slug)
|
|
||||||
out = append(out, noteEntry{Hall: hall, Slug: slug, Title: title, Created: created})
|
out = append(out, noteEntry{Hall: hall, Slug: slug, Title: title, Created: created})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -117,9 +124,40 @@ func readTitleAndCreated(path, slug string) (string, string) {
|
|||||||
return slug, ""
|
return slug, ""
|
||||||
}
|
}
|
||||||
defer func() { _ = f.Close() }()
|
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
|
||||||
|
}
|
||||||
|
|
||||||
title, created := "", ""
|
// readTitleAndCreatedIn is readTitleAndCreated through the brain root,
|
||||||
scanner := bufio.NewScanner(f)
|
// regular files only (hyperguild#157).
|
||||||
|
func readTitleAndCreatedIn(bfs *brainfs.Brain, rel, slug string) (string, string) {
|
||||||
|
content, err := bfs.ReadRegular(rel)
|
||||||
|
if err != nil {
|
||||||
|
return slug, ""
|
||||||
|
}
|
||||||
|
title, created := parseTitleAndCreated(bytes.NewReader(content))
|
||||||
|
if created == "" {
|
||||||
|
if info, err := bfs.Lstat(rel); err == nil {
|
||||||
|
created = info.ModTime().UTC().Format("2006-01-02")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return titleOrSlug(title, slug), created
|
||||||
|
}
|
||||||
|
|
||||||
|
func titleOrSlug(title, slug string) string {
|
||||||
|
if title == "" {
|
||||||
|
return strings.ReplaceAll(slug, "-", " ")
|
||||||
|
}
|
||||||
|
return title
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseTitleAndCreated(r io.Reader) (title, created string) {
|
||||||
|
scanner := bufio.NewScanner(r)
|
||||||
inFrontmatter := false
|
inFrontmatter := false
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
line := scanner.Text()
|
line := scanner.Text()
|
||||||
@@ -149,13 +187,5 @@ func readTitleAndCreated(path, slug string) (string, string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if title == "" {
|
|
||||||
title = strings.ReplaceAll(slug, "-", " ")
|
|
||||||
}
|
|
||||||
if created == "" {
|
|
||||||
if info, err := os.Stat(path); err == nil {
|
|
||||||
created = info.ModTime().UTC().Format("2006-01-02")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return title, created
|
return title, created
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package brain_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
func readIndex(t *testing.T, brainDir, wing string) string {
|
||||||
|
t.Helper()
|
||||||
|
b, _ := os.ReadFile(filepath.Join(brainDir, "wiki", wing, "_index.md"))
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// hyperguild#157 slice 5: BuildWingIndex listed wiki/<wing>/<hall>/ with
|
||||||
|
// os.ReadDir and opened each note by path, so a symlinked note put the
|
||||||
|
// target's title into _index.md, and a FIFO hung the index build.
|
||||||
|
func TestBuildWingIndexRefusesSymlinkedNotesAndSurvivesSwapRaces(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\n---\n"), 0o644))
|
||||||
|
require.NoError(t, os.Symlink(brainfstest.Outside(t), filepath.Join(facts, "leak.md")))
|
||||||
|
inBrain := brainfstest.WriteSentinelAt(t, filepath.Join(b, "raw", "s.md"))
|
||||||
|
require.NoError(t, os.Symlink(filepath.Join("..", "..", "..", "raw", "s.md"), filepath.Join(facts, "inlink.md")))
|
||||||
|
|
||||||
|
require.NoError(t, brain.BuildWingIndex(b, "w"))
|
||||||
|
idx := readIndex(t, b, "w")
|
||||||
|
assert.Contains(t, idx, "zqinside", "positive control")
|
||||||
|
assert.NotContains(t, idx, brainfstest.Sentinel)
|
||||||
|
assert.NotContains(t, idx, "leak")
|
||||||
|
assert.NotContains(t, idx, "inlink")
|
||||||
|
|
||||||
|
p := filepath.Join(facts, "r.md")
|
||||||
|
read := func() string {
|
||||||
|
_ = brain.BuildWingIndex(b, "w")
|
||||||
|
return readIndex(t, b, "w")
|
||||||
|
}
|
||||||
|
brainfstest.AssertNoLeak(t, p, "---\ntitle: zqinside\n---\n", 1500*time.Millisecond, read)
|
||||||
|
brainfstest.AssertNoLeakFrom(t, p, "---\ntitle: zqinside\n---\n", inBrain, 1500*time.Millisecond, read)
|
||||||
|
brainfstest.AssertNoHang(t, p, "---\ntitle: zqinside\n---\n", 1500*time.Millisecond, 6*time.Second, func() { _ = read() })
|
||||||
|
}
|
||||||
|
|
||||||
|
// A symlinked hall is skipped while a real hall next to it is indexed; a
|
||||||
|
// symlinked wing is refused and nothing is written into its target.
|
||||||
|
func TestBuildWingIndexSkipsASymlinkedHallAndRefusesASymlinkedWing(t *testing.T) {
|
||||||
|
b := t.TempDir()
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(b, "raw", "facts"), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(b, "raw", "facts", "r.md"), []byte("---\ntitle: zqrawonly\n---\n"), 0o644))
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(b, "wiki", "w", "decisions"), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(b, "wiki", "w", "decisions", "d.md"), []byte("---\ntitle: zqrealhall\n---\n"), 0o644))
|
||||||
|
require.NoError(t, os.Symlink(filepath.Join("..", "..", "raw", "facts"), filepath.Join(b, "wiki", "w", "facts")))
|
||||||
|
require.NoError(t, os.Symlink(filepath.Join("..", "raw"), filepath.Join(b, "wiki", "alias")))
|
||||||
|
|
||||||
|
require.NoError(t, brain.BuildWingIndex(b, "w"))
|
||||||
|
idx, err := os.ReadFile(filepath.Join(b, "wiki", "w", "_index.md"))
|
||||||
|
require.NoError(t, err, "the wing index is written")
|
||||||
|
assert.Contains(t, string(idx), "zqrealhall", "the real hall is indexed")
|
||||||
|
assert.NotContains(t, string(idx), "zqrawonly", "the symlinked hall is not")
|
||||||
|
|
||||||
|
assert.Error(t, brain.BuildWingIndex(b, "alias"))
|
||||||
|
_, err = os.Stat(filepath.Join(b, "raw", "_index.md"))
|
||||||
|
assert.True(t, os.IsNotExist(err), "an index was written into the symlinked wing's target")
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildAllWingIndexes indexes the real wings and skips a symlinked one.
|
||||||
|
func TestBuildAllWingIndexesSkipsASymlinkedWing(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: zqrealwing\n---\n"), 0o644))
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(b, "raw", "facts"), 0o755))
|
||||||
|
require.NoError(t, os.Symlink(filepath.Join("..", "raw"), filepath.Join(b, "wiki", "alias")))
|
||||||
|
|
||||||
|
require.NoError(t, brain.BuildAllWingIndexes(b))
|
||||||
|
idx, err := os.ReadFile(filepath.Join(b, "wiki", "w", "_index.md"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(idx), "zqrealwing")
|
||||||
|
_, err = os.Stat(filepath.Join(b, "raw", "_index.md"))
|
||||||
|
assert.True(t, os.IsNotExist(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
// A link to a symlinked note does not resolve: only regular files count as
|
||||||
|
// entries, so StripUnresolvedLinks unwraps it.
|
||||||
|
func TestStripUnresolvedLinksDoesNotCountASymlinkedNoteAsAnEntry(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, "real.md"), []byte("x"), 0o644))
|
||||||
|
brainfstest.WriteSentinelAt(t, filepath.Join(b, "raw", "s.md"))
|
||||||
|
require.NoError(t, os.Symlink(filepath.Join("..", "..", "..", "raw", "s.md"), filepath.Join(facts, "ghost.md")))
|
||||||
|
out, unlinked, err := brain.StripUnresolvedLinks(b, "see [[real]] and [[ghost]]")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, out, "[[real]]", "positive control")
|
||||||
|
assert.NotContains(t, out, "[[ghost]]")
|
||||||
|
assert.Contains(t, unlinked, "ghost")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A linked tree (knowledge/ or wiki/) that is not a real directory is
|
||||||
|
// skipped, not an error: StripUnresolvedLinks runs before every brain_write,
|
||||||
|
// and an error there loses the learning.
|
||||||
|
func TestStripUnresolvedLinksSkipsALinkedTreeThatIsNotARealDirectory(t *testing.T) {
|
||||||
|
for name, mk := range map[string]func(t *testing.T, b string){
|
||||||
|
"symlink to an in-brain dir": func(t *testing.T, b string) {
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(b, "raw", "k"), 0o755))
|
||||||
|
require.NoError(t, os.Symlink(filepath.Join("raw", "k"), filepath.Join(b, "knowledge")))
|
||||||
|
},
|
||||||
|
"dangling symlink": func(t *testing.T, b string) {
|
||||||
|
require.NoError(t, os.Symlink("nowhere", filepath.Join(b, "knowledge")))
|
||||||
|
},
|
||||||
|
"regular file": func(t *testing.T, b string) {
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(b, "knowledge"), []byte("x"), 0o644))
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(name, func(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", "real.md"), []byte("x"), 0o644))
|
||||||
|
mk(t, b)
|
||||||
|
out, _, err := brain.StripUnresolvedLinks(b, "learning [[real]] and [[gone]]")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, out, "[[real]]", "links into wiki/ still resolve")
|
||||||
|
assert.NotContains(t, out, "[[gone]]")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
package brain
|
package brain
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"io/fs"
|
||||||
"path/filepath"
|
"log/slog"
|
||||||
|
"path"
|
||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
|
||||||
)
|
)
|
||||||
|
|
||||||
// wikilinkRE matches [[target]] and [[target|Display Text]].
|
// wikilinkRE matches [[target]] and [[target|Display Text]].
|
||||||
@@ -98,29 +102,40 @@ var linkedTrees = []string{"knowledge", "wiki"}
|
|||||||
// one the audit will also resolve.
|
// one the audit will also resolve.
|
||||||
func entryIndex(brainDir string) (map[string]struct{}, error) {
|
func entryIndex(brainDir string) (map[string]struct{}, error) {
|
||||||
index := map[string]struct{}{}
|
index := map[string]struct{}{}
|
||||||
|
// Walked through the brain root (hyperguild#157): only regular files
|
||||||
|
// count as entries, so a symlinked .md name cannot make a link resolve.
|
||||||
|
bfs, err := brainfs.Open(brainDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open brain: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = bfs.Close() }()
|
||||||
for _, dir := range linkedTrees {
|
for _, dir := range linkedTrees {
|
||||||
root := filepath.Join(brainDir, dir)
|
if _, err := bfs.Lstat(dir); errors.Is(err, fs.ErrNotExist) {
|
||||||
if _, err := os.Stat(root); os.IsNotExist(err) {
|
|
||||||
continue
|
continue
|
||||||
} else if err != nil {
|
|
||||||
return nil, fmt.Errorf("stat %s: %w", dir, err)
|
|
||||||
}
|
}
|
||||||
err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
var walkErr error
|
||||||
if err != nil {
|
err := bfs.WalkRegular(dir, func(rel string) error {
|
||||||
return err
|
if !strings.HasSuffix(rel, ".md") {
|
||||||
}
|
|
||||||
if d.IsDir() || !strings.HasSuffix(path, ".md") {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
rel, err := filepath.Rel(brainDir, path)
|
index[strings.TrimSuffix(path.Base(rel), ".md")] = struct{}{}
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
rel = filepath.ToSlash(rel)
|
|
||||||
index[strings.TrimSuffix(filepath.Base(rel), ".md")] = struct{}{}
|
|
||||||
index[strings.TrimPrefix(strings.TrimSuffix(rel, ".md"), "wiki/")] = struct{}{}
|
index[strings.TrimPrefix(strings.TrimSuffix(rel, ".md"), "wiki/")] = struct{}{}
|
||||||
return nil
|
return nil
|
||||||
|
}, func(rel string, err error) {
|
||||||
|
// A linked tree that is not a real directory (a symlink, a
|
||||||
|
// file) is skipped, as filepath.WalkDir did: this runs before
|
||||||
|
// every brain_write, and an error here loses the learning.
|
||||||
|
if rel == dir && errors.Is(err, brainfs.ErrNotRegular) {
|
||||||
|
slog.Warn("link check: skipping a linked tree that is not a real directory", "dir", dir)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if walkErr == nil {
|
||||||
|
walkErr = fmt.Errorf("%s: %w", rel, err)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
if err == nil {
|
||||||
|
err = walkErr
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("walk %s: %w", dir, err)
|
return nil, fmt.Errorf("walk %s: %w", dir, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,6 +104,27 @@ func (b *Brain) ReadDirRegular(relDir string) ([]string, error) {
|
|||||||
return names, nil
|
return names, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReadDirDirs lists the real subdirectories directly in a brain-relative
|
||||||
|
// directory, by name. Every component of relDir must be a real directory
|
||||||
|
// (ErrNotRegular otherwise); symlinks to directories are left out.
|
||||||
|
func (b *Brain) ReadDirDirs(relDir string) ([]string, error) {
|
||||||
|
relDir = filepath.ToSlash(relDir)
|
||||||
|
if err := b.realDir(relDir); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ents, err := fs.ReadDir(b.root.FS(), relDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var names []string
|
||||||
|
for _, e := range ents {
|
||||||
|
if e.Type().IsDir() {
|
||||||
|
names = append(names, e.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return names, nil
|
||||||
|
}
|
||||||
|
|
||||||
// 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) {
|
||||||
|
|||||||
@@ -262,3 +262,21 @@ func TestReadRegularNeverReturnsAnInBrainFileSwappedInBehindASymlink(t *testing.
|
|||||||
assert.Zero(t, leaks)
|
assert.Zero(t, leaks)
|
||||||
assert.Positive(t, ok, "positive control: the regular file is read")
|
assert.Positive(t, ok, "positive control: the regular file is read")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestReadDirDirsListsOnlyRealSubdirsAndRefusesSymlinkedComponents(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "a", "real"), 0o755))
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "other"), 0o755))
|
||||||
|
require.NoError(t, os.Symlink(filepath.Join("..", "other"), filepath.Join(dir, "a", "linked")))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "a", "f.md"), []byte("f"), 0o644))
|
||||||
|
require.NoError(t, os.Symlink("a", filepath.Join(dir, "alias")))
|
||||||
|
b, err := Open(dir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = b.Close() }()
|
||||||
|
|
||||||
|
names, err := b.ReadDirDirs("a")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []string{"real"}, names)
|
||||||
|
_, err = b.ReadDirDirs("alias")
|
||||||
|
assert.ErrorIs(t, err, ErrNotRegular)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user