Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1cf05fdf15 | ||
|
|
51183d64d8 | ||
|
|
0bd0fbf03e | ||
|
|
ef39435252 | ||
|
|
f969d97c16 | ||
|
|
a83f0cb381 | ||
|
|
bf3646c0c7 |
@@ -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() })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -27,7 +29,7 @@ const outsideContent = "---\ntitle: " + Sentinel + "\naliases:\n - " + Sentinel
|
|||||||
"\n\n" + Sentinel + " [[" + Sentinel + "]]\n"
|
"\n\n" + Sentinel + " [[" + Sentinel + "]]\n"
|
||||||
|
|
||||||
// Outside writes a file outside any brain dir containing Sentinel.
|
// Outside writes a file outside any brain dir containing Sentinel.
|
||||||
func Outside(t *testing.T) string {
|
func Outside(t testing.TB) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
p := filepath.Join(t.TempDir(), "secret.md")
|
p := filepath.Join(t.TempDir(), "secret.md")
|
||||||
if err := os.WriteFile(p, []byte(outsideContent), 0o644); err != nil {
|
if err := os.WriteFile(p, []byte(outsideContent), 0o644); err != nil {
|
||||||
@@ -42,9 +44,12 @@ var swapSeq atomic.Int64
|
|||||||
// a symlink to outside or a FIFO into path, until the test ends. FIFOs get a
|
// a symlink to outside or a FIFO into path, until the test ends. FIFOs get a
|
||||||
// writer held open until the swapper exits, so a reader that skips the
|
// writer held open until the swapper exits, so a reader that skips the
|
||||||
// handle check parks forever instead of reading EOF. The swapper is stopped
|
// handle check parks forever instead of reading EOF. The swapper is stopped
|
||||||
// and awaited before t.TempDir cleanup.
|
// and awaited before t.TempDir cleanup. It returns the number of symlinks
|
||||||
func StartSwap(t *testing.T, path, inside, outside string, fifo bool) {
|
// or FIFOs actually renamed into path so far, so a caller can tell a race
|
||||||
|
// that ran from one whose every swap failed (hyperguild#177).
|
||||||
|
func StartSwap(t testing.TB, path, inside, outside string, fifo bool) *atomic.Int64 {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
var swaps atomic.Int64
|
||||||
var stop atomic.Bool
|
var stop atomic.Bool
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
@@ -67,22 +72,26 @@ func StartSwap(t *testing.T, path, inside, outside string, fifo bool) {
|
|||||||
}()
|
}()
|
||||||
for i := 0; !stop.Load(); i++ {
|
for i := 0; !stop.Load(); i++ {
|
||||||
_ = os.Remove(tmp)
|
_ = os.Remove(tmp)
|
||||||
|
var made error
|
||||||
switch {
|
switch {
|
||||||
case i%2 == 0:
|
case i%2 == 0:
|
||||||
_ = os.WriteFile(tmp, []byte(inside), 0o644)
|
_ = os.WriteFile(tmp, []byte(inside), 0o644)
|
||||||
case fifo:
|
case fifo:
|
||||||
if err := syscall.Mkfifo(tmp, 0o644); err == nil {
|
if made = syscall.Mkfifo(tmp, 0o644); made == nil {
|
||||||
if w, err := os.OpenFile(tmp, os.O_RDWR, 0); err == nil {
|
if w, err := os.OpenFile(tmp, os.O_RDWR, 0); err == nil {
|
||||||
writers = append(writers, w)
|
writers = append(writers, w)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
_ = os.Symlink(link, tmp)
|
made = os.Symlink(link, tmp)
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmp, path); err == nil && made == nil && i%2 == 1 {
|
||||||
|
swaps.Add(1)
|
||||||
}
|
}
|
||||||
_ = os.Rename(tmp, path)
|
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
t.Cleanup(func() { stop.Store(true); wg.Wait() })
|
t.Cleanup(func() { stop.Store(true); wg.Wait() })
|
||||||
|
return &swaps
|
||||||
}
|
}
|
||||||
|
|
||||||
// WriteSentinelAt writes a file carrying Sentinel at p (creating its
|
// WriteSentinelAt writes a file carrying Sentinel at p (creating its
|
||||||
@@ -107,17 +116,40 @@ func AssertNoLeak(t *testing.T, path, inside string, d time.Duration, read func(
|
|||||||
AssertNoLeakFrom(t, path, inside, Outside(t), d, read)
|
AssertNoLeakFrom(t, path, inside, Outside(t), d, read)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InsideMarker must appear in the inside note's content and in read's
|
||||||
|
// output whenever read saw that note: the floor below counts such reads.
|
||||||
|
const InsideMarker = "inside"
|
||||||
|
|
||||||
// AssertNoLeakFrom is AssertNoLeak with the symlink pointing at target, e.g.
|
// AssertNoLeakFrom is AssertNoLeak with the symlink pointing at target, e.g.
|
||||||
// a file elsewhere in the brain written with WriteSentinelAt.
|
// a file elsewhere in the brain written with WriteSentinelAt.
|
||||||
func AssertNoLeakFrom(t *testing.T, path, inside, target string, d time.Duration, read func() string) {
|
//
|
||||||
|
// Beyond "no Sentinel in any output" it sets two floors, because without
|
||||||
|
// them the assertion passes when nothing was tested (hyperguild#177): at
|
||||||
|
// least one symlink must have been swapped into path, and at least one read
|
||||||
|
// must have returned InsideMarker (a reader that always errors returns
|
||||||
|
// nothing, and so never leaks).
|
||||||
|
func AssertNoLeakFrom(t testing.TB, path, inside, target string, d time.Duration, read func() string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
StartSwap(t, path, inside, target, false)
|
if !strings.Contains(inside, InsideMarker) {
|
||||||
leaks, iters := 0, 0
|
t.Fatalf("inside content must contain %q", InsideMarker)
|
||||||
|
}
|
||||||
|
swaps := StartSwap(t, path, inside, target, false)
|
||||||
|
leaks, iters, insideReads := 0, 0, 0
|
||||||
for deadline := time.Now().Add(d); time.Now().Before(deadline); iters++ {
|
for deadline := time.Now().Add(d); time.Now().Before(deadline); iters++ {
|
||||||
leaks += strings.Count(read(), Sentinel)
|
out := read()
|
||||||
|
leaks += strings.Count(out, Sentinel)
|
||||||
|
if strings.Contains(out, InsideMarker) {
|
||||||
|
insideReads++
|
||||||
|
}
|
||||||
}
|
}
|
||||||
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 {
|
||||||
|
t.Fatalf("no symlink was swapped into %s in %d iterations: the race did not run", path, iters)
|
||||||
|
}
|
||||||
|
if insideReads == 0 {
|
||||||
|
t.Fatalf("no read in %d iterations returned the inside note (%q): the reader read nothing", iters, InsideMarker)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package brainfstest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"syscall"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// recordingTB stands in for the test under AssertNoLeakFrom so a fatal
|
||||||
|
// there can be observed instead of failing this test. Fatalf ends the
|
||||||
|
// calling goroutine the way testing.T does.
|
||||||
|
type recordingTB struct {
|
||||||
|
testing.TB
|
||||||
|
failed bool
|
||||||
|
msg string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *recordingTB) Fatalf(format string, args ...any) {
|
||||||
|
r.failed, r.msg = true, fmt.Sprintf(format, args...)
|
||||||
|
runtime.Goexit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func runAssert(t *testing.T, path, target string, read func() string) *recordingTB {
|
||||||
|
t.Helper()
|
||||||
|
r := &recordingTB{TB: t}
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
AssertNoLeakFrom(r, path, "inside", target, 200*time.Millisecond, read)
|
||||||
|
}()
|
||||||
|
<-done
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// hyperguild#177: with every swap failing, no leak can occur, so the leak
|
||||||
|
// assertion passed without testing anything.
|
||||||
|
func TestAssertNoLeakFromFailsWhenNoSymlinkSwapSucceeds(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "missing-dir", "r.md")
|
||||||
|
r := runAssert(t, path, Outside(t), func() string { return "inside" })
|
||||||
|
if !r.failed {
|
||||||
|
t.Fatal("AssertNoLeakFrom passed with zero successful symlink swaps")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hyperguild#177: a reader that always errors returns nothing, so it never
|
||||||
|
// leaks either.
|
||||||
|
func TestAssertNoLeakFromFailsWhenNoReadReturnsTheInsideNote(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "r.md")
|
||||||
|
r := runAssert(t, path, Outside(t), func() string { return "" })
|
||||||
|
if !r.failed {
|
||||||
|
t.Fatal("AssertNoLeakFrom passed with zero reads returning the inside note")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Positive control: a reader that refuses symlinks and reads regular files
|
||||||
|
// passes, so the floors do not fail a working reader.
|
||||||
|
func TestAssertNoLeakFromPassesAReaderThatRefusesSymlinks(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "r.md")
|
||||||
|
read := func() string {
|
||||||
|
f, err := os.OpenFile(path, os.O_RDONLY|noFollow, 0)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
defer func() { _ = f.Close() }()
|
||||||
|
b := make([]byte, 64)
|
||||||
|
n, _ := f.Read(b)
|
||||||
|
return string(b[:n])
|
||||||
|
}
|
||||||
|
r := runAssert(t, path, Outside(t), read)
|
||||||
|
if r.failed {
|
||||||
|
t.Fatalf("a symlink-refusing reader failed: %s", r.msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const noFollow = syscall.O_NOFOLLOW
|
||||||
@@ -16,10 +16,9 @@
|
|||||||
package classification
|
package classification
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
|
"os"
|
||||||
"io/fs"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
@@ -111,20 +110,9 @@ 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{}}
|
||||||
|
|
||||||
// Read through the brain root, regular files only (hyperguild#157): a
|
data, err := os.ReadFile(filepath.Join(brainDir, "classification.yaml"))
|
||||||
// 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 {
|
||||||
return nil, fmt.Errorf("open brain: %w", err)
|
if os.IsNotExist(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)
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,10 +3,7 @@ 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"
|
||||||
@@ -26,16 +23,10 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Listed and read through the brain root, regular files only
|
sourcesDir := filepath.Join(brainDir, "wiki", "sources")
|
||||||
// (hyperguild#157).
|
entries, err := os.ReadDir(sourcesDir)
|
||||||
bfs, err := brainfs.Open(brainDir)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("open brain: %w", err)
|
if os.IsNotExist(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)
|
||||||
@@ -46,20 +37,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 _, name := range names {
|
for _, e := range entries {
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return 0, ctx.Err()
|
return 0, ctx.Err()
|
||||||
}
|
}
|
||||||
if !strings.HasSuffix(name, ".md") {
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
b, err := bfs.ReadRegular("wiki/sources/" + name)
|
b, err := os.ReadFile(filepath.Join(sourcesDir, e.Name()))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
sourceContent := string(b)
|
sourceContent := string(b)
|
||||||
sourceSlug := strings.TrimSuffix(name, ".md")
|
sourceSlug := strings.TrimSuffix(e.Name(), ".md")
|
||||||
sourceTitle := extractTitle(sourceContent)
|
sourceTitle := extractTitle(sourceContent)
|
||||||
if sourceTitle == "" {
|
if sourceTitle == "" {
|
||||||
sourceTitle = sourceSlug
|
sourceTitle = sourceSlug
|
||||||
@@ -79,7 +70,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 := bfs.ReadRegular(relPath)
|
raw, err := os.ReadFile(filepath.Join(brainDir, filepath.FromSlash(relPath)))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,7 @@ 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"
|
||||||
@@ -132,7 +129,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(brainDir, page)
|
content, conflicts, err := reviseOrCreate(dest, page)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Result{}, err
|
return Result{}, err
|
||||||
}
|
}
|
||||||
@@ -191,27 +188,14 @@ 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 := readBrainFile(brainDir, "wiki/sources/"+sourceSlug+".md")
|
raw, err := os.ReadFile(filepath.Join(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.
|
||||||
//
|
//
|
||||||
@@ -246,10 +230,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(brainDir string, page wiki.Page) (string, []string, error) {
|
func reviseOrCreate(dest string, page wiki.Page) (string, []string, error) {
|
||||||
existing, err := readBrainFile(brainDir, page.Path)
|
existing, err := os.ReadFile(dest)
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, fs.ErrNotExist):
|
case os.IsNotExist(err):
|
||||||
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)
|
||||||
@@ -283,7 +267,7 @@ See brain/schema.md for the full schema.
|
|||||||
`
|
`
|
||||||
|
|
||||||
func loadSchema(brainDir string) string {
|
func loadSchema(brainDir string) string {
|
||||||
b, err := readBrainFile(brainDir, "schema.md")
|
b, err := os.ReadFile(filepath.Join(brainDir, "schema.md"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return defaultSchema
|
return defaultSchema
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
package pipeline
|
package pipeline
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -52,7 +53,8 @@ func injectSourceRefs(pages []wiki.Page, inventory map[wiki.PageType][]wiki.Entr
|
|||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
b, err := readBrainFile(brainDir, "wiki/"+string(pt)+"/"+slug+".md")
|
diskPath := filepath.Join(brainDir, "wiki", string(pt), slug+".md")
|
||||||
|
b, err := os.ReadFile(diskPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,167 +0,0 @@
|
|||||||
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(), "zqinside", "positive control: the regular page gets the 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
|
||||||
@@ -93,16 +95,20 @@ func TestQueryDoesNotFollowASymlinkedWingUnderAWingAndHallRoot(t *testing.T) {
|
|||||||
// bypass of readOpened hung Query after 5 iterations).
|
// bypass of readOpened hung Query after 5 iterations).
|
||||||
// startSwap runs swapInto until the test ends and waits for it before
|
// startSwap runs swapInto until the test ends and waits for it before
|
||||||
// t.TempDir's cleanup (hyperguild#162).
|
// t.TempDir's cleanup (hyperguild#162).
|
||||||
func startSwap(t *testing.T, path, outside string, fifo bool) {
|
// It returns the count of symlinks or FIFOs actually renamed into path
|
||||||
|
// (hyperguild#177: a swapper whose every swap fails tests nothing).
|
||||||
|
func startSwap(t *testing.T, path, outside string, fifo bool) *atomic.Int64 {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
var stop atomic.Bool
|
var stop atomic.Bool
|
||||||
|
var swaps atomic.Int64
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func() { defer wg.Done(); swapInto(path, outside, fifo, &stop) }()
|
go func() { defer wg.Done(); swapInto(path, outside, fifo, &stop, &swaps) }()
|
||||||
t.Cleanup(func() { stop.Store(true); wg.Wait() })
|
t.Cleanup(func() { stop.Store(true); wg.Wait() })
|
||||||
|
return &swaps
|
||||||
}
|
}
|
||||||
|
|
||||||
func swapInto(path, outside string, fifo bool, stop *atomic.Bool) {
|
func swapInto(path, outside string, fifo bool, stop *atomic.Bool, swaps *atomic.Int64) {
|
||||||
tmp := path + ".swap"
|
tmp := path + ".swap"
|
||||||
// Writers stay open until the swapper exits, so a reader parked on a
|
// Writers stay open until the swapper exits, so a reader parked on a
|
||||||
// FIFO stays parked (closing one would hand the reader EOF and hide the
|
// FIFO stays parked (closing one would hand the reader EOF and hide the
|
||||||
@@ -115,33 +121,46 @@ func swapInto(path, outside string, fifo bool, stop *atomic.Bool) {
|
|||||||
}()
|
}()
|
||||||
for i := 0; !stop.Load(); i++ {
|
for i := 0; !stop.Load(); i++ {
|
||||||
_ = os.Remove(tmp)
|
_ = os.Remove(tmp)
|
||||||
|
var made error
|
||||||
switch {
|
switch {
|
||||||
case i%2 == 0:
|
case i%2 == 0:
|
||||||
_ = os.WriteFile(tmp, []byte("# r\n\nzqinside "+"zqracenote"), 0o644)
|
_ = os.WriteFile(tmp, []byte("# r\n\nzqinside "+"zqracenote"), 0o644)
|
||||||
case fifo:
|
case fifo:
|
||||||
if err := syscall.Mkfifo(tmp, 0o644); err == nil {
|
if made = syscall.Mkfifo(tmp, 0o644); made == nil {
|
||||||
if w, err := os.OpenFile(tmp, os.O_RDWR, 0); err == nil {
|
if w, err := os.OpenFile(tmp, os.O_RDWR, 0); err == nil {
|
||||||
writers = append(writers, w)
|
writers = append(writers, w)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
_ = os.Symlink(outside, tmp)
|
made = os.Symlink(outside, tmp)
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmp, path); err == nil && made == nil && i%2 == 1 {
|
||||||
|
swaps.Add(1)
|
||||||
}
|
}
|
||||||
_ = os.Rename(tmp, path)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestQueryLeaksNothingUnderASymlinkSwapRace(t *testing.T) {
|
func TestQueryLeaksNothingUnderASymlinkSwapRace(t *testing.T) {
|
||||||
brain, facts, outside := searchBrain(t)
|
brain, facts, outside := searchBrain(t)
|
||||||
startSwap(t, filepath.Join(facts, "r.md"), outside, false)
|
swaps := startSwap(t, filepath.Join(facts, "r.md"), outside, false)
|
||||||
leaks, iters := 0, 0
|
leaks, iters, insideReads := 0, 0, 0
|
||||||
for deadline := time.Now().Add(1500 * time.Millisecond); time.Now().Before(deadline); iters++ {
|
for deadline := time.Now().Add(1500 * time.Millisecond); time.Now().Before(deadline); iters++ {
|
||||||
rs, _ := Query(brain, QueryOptions{Query: sentinel157, Limit: 10})
|
rs, err := Query(brain, QueryOptions{Query: sentinel157, Limit: 10})
|
||||||
|
require.NoError(t, err)
|
||||||
leaks += leaked(rs)
|
leaks += leaked(rs)
|
||||||
|
// Positive control (hyperguild#177): a Query that always errors
|
||||||
|
// returns nothing and so never leaks.
|
||||||
|
rs, err = Query(brain, QueryOptions{Query: "zqracenote", Limit: 10})
|
||||||
|
require.NoError(t, err)
|
||||||
|
if len(rs) > 0 {
|
||||||
|
insideReads++
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if leaks > 0 {
|
if leaks > 0 {
|
||||||
t.Fatalf("outside file leaked %d times in %d iterations", leaks, iters)
|
t.Fatalf("outside file leaked %d times in %d iterations", leaks, iters)
|
||||||
}
|
}
|
||||||
|
require.Positive(t, swaps.Load(), "no symlink was swapped in: the race did not run")
|
||||||
|
require.Positive(t, insideReads, "no Query returned the inside note")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestQueryDoesNotHangUnderAFIFOSwapRace(t *testing.T) {
|
func TestQueryDoesNotHangUnderAFIFOSwapRace(t *testing.T) {
|
||||||
@@ -172,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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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() })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user