diff --git a/ingestion/internal/brain/detect_root_test.go b/ingestion/internal/brain/detect_root_test.go new file mode 100644 index 0000000..c5f451c --- /dev/null +++ b/ingestion/internal/brain/detect_root_test.go @@ -0,0 +1,125 @@ +package brain_test + +import ( + "fmt" + "os" + "path/filepath" + "syscall" + "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" +) + +// hyperguild#174 / #157 slice 5b: DetectTunnels walked wiki/ and opened +// every note with a blocking os.Open, so a FIFO named *.md anywhere under +// wiki/ hung every brain_write and brain_update (wikiUpkeep -> AutoTunnel), +// and a symlinked note's title became a tunnel candidate. +func TestDetectTunnelsReturnsPromptlyWithAFIFOUnderWikiWithAndWithoutAWriter(t *testing.T) { + for _, withWriter := range []bool{false, true} { + t.Run(fmt.Sprintf("writer=%v", withWriter), func(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 Phrase\n---\n"), 0o644)) + fifo := filepath.Join(facts, "f.md") + require.NoError(t, syscall.Mkfifo(fifo, 0o644)) + if withWriter { + // A writer held open: a reader that opens the FIFO gets no + // EOF and parks, unless the FIFO is never opened. + w, err := os.OpenFile(fifo, os.O_RDWR, 0) + require.NoError(t, err) + t.Cleanup(func() { _ = w.Close() }) + } + done := make(chan []brain.TunnelCandidate, 1) + go func() { + cs, _ := brain.DetectTunnels(b, "text mentioning zqinside phrase here") + done <- cs + }() + select { + case cs := <-done: + assert.Contains(t, fmt.Sprintf("%+v", cs), "Zqinside Phrase", "positive control") + case <-time.After(3 * time.Second): + if r, err := os.OpenFile(fifo, os.O_WRONLY|syscall.O_NONBLOCK, 0); err == nil { + _ = r.Close() + } + t.Fatal("DetectTunnels blocked on a FIFO") + } + }) + } +} + +func TestDetectTunnelsRefusesSymlinkedNotesAndSurvivesSwapRaces(t *testing.T) { + b := t.TempDir() + facts := filepath.Join(b, "wiki", "w", "facts") + require.NoError(t, os.MkdirAll(facts, 0o755)) + // The static note's title differs from the raced note's, so the + // harness's "inside was read" floor can only be met by reading r.md. + require.NoError(t, os.WriteFile(filepath.Join(facts, "ok.md"), []byte("---\ntitle: Zqstatic Words\n---\n"), 0o644)) + require.NoError(t, os.Symlink(brainfstest.Outside(t), filepath.Join(facts, "out.md"))) + inBrain := brainfstest.WriteSentinelAt(t, filepath.Join(b, "raw", "s.md")) + require.NoError(t, os.Symlink(filepath.Join("..", "..", "..", "raw", "s.md"), filepath.Join(facts, "in.md"))) + content := "zqstatic words, zqinside phrase and " + brainfstest.Sentinel + + cs, err := brain.DetectTunnels(b, content) + require.NoError(t, err) + got := fmt.Sprintf("%+v", cs) + assert.Contains(t, got, "Zqstatic Words", "positive control") + assert.NotContains(t, got, brainfstest.Sentinel) + + p := filepath.Join(facts, "r.md") + read := func() string { + cs, _ := brain.DetectTunnels(b, content) + return fmt.Sprintf("%+v", cs) + } + brainfstest.AssertNoLeak(t, p, "---\ntitle: Zqinside Phrase\n---\n", 1500*time.Millisecond, read) + brainfstest.AssertNoLeakFrom(t, p, "---\ntitle: Zqinside Phrase\n---\n", inBrain, 1500*time.Millisecond, read) +} + +// A wiki/ that is not a real directory yields no candidates and no error: +// AutoTunnel runs inside brain_write. +func TestDetectTunnelsSkipsAWikiThatIsNotARealDirectory(t *testing.T) { + b := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(b, "raw", "w", "facts"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(b, "raw", "w", "facts", "r.md"), []byte("---\ntitle: Zqrawonly Phrase\n---\n"), 0o644)) + require.NoError(t, os.Symlink("raw", filepath.Join(b, "wiki"))) + cs, err := brain.DetectTunnels(b, "zqrawonly phrase") + require.NoError(t, err) + assert.Empty(t, cs) +} + +// An unreadable directory under wiki/ is a walk error and DetectTunnels +// reports it, as filepath.WalkDir did; it is not silently dropped. +func TestDetectTunnelsReportsAnUnreadableDirectoryUnderWiki(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root ignores directory permissions") + } + 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", "ok.md"), []byte("---\ntitle: Zqinside Phrase\n---\n"), 0o644)) + locked := filepath.Join(b, "wiki", "other", "locked") + require.NoError(t, os.MkdirAll(locked, 0o755)) + require.NoError(t, os.Chmod(locked, 0o000)) + t.Cleanup(func() { _ = os.Chmod(locked, 0o755) }) + _, err := brain.DetectTunnels(b, "zqinside phrase") + assert.ErrorIs(t, err, os.ErrPermission) +} + +// _index.md files are generated, not notes: their title never becomes a +// candidate. +func TestDetectTunnelsSkipsIndexFiles(t *testing.T) { + b := t.TempDir() + w := filepath.Join(b, "wiki", "w") + require.NoError(t, os.MkdirAll(filepath.Join(w, "facts"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(w, "_index.md"), []byte("---\ntitle: Zqindex Words\n---\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(w, "facts", "ok.md"), []byte("---\ntitle: Zqinside Phrase\n---\n"), 0o644)) + cs, err := brain.DetectTunnels(b, "zqindex words and zqinside phrase") + require.NoError(t, err) + got := fmt.Sprintf("%+v", cs) + assert.Contains(t, got, "Zqinside Phrase", "positive control") + assert.NotContains(t, got, "Zqindex") +} diff --git a/ingestion/internal/brain/index.go b/ingestion/internal/brain/index.go index 4c029e1..d700b74 100644 --- a/ingestion/internal/brain/index.go +++ b/ingestion/internal/brain/index.go @@ -116,23 +116,6 @@ func collectWingEntries(bfs *brainfs.Brain, wingRel string) ([]noteEntry, error) return out, nil } -// readTitleAndCreated reads YAML frontmatter for title + created_at; falls -// back to slug and file mtime when absent. -func readTitleAndCreated(path, slug string) (string, string) { - f, err := os.Open(path) - if err != nil { - return slug, "" - } - 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 -} - // readTitleAndCreatedIn is readTitleAndCreated through the brain root, // regular files only (hyperguild#157). func readTitleAndCreatedIn(bfs *brainfs.Brain, rel, slug string) (string, string) { diff --git a/ingestion/internal/brain/tunnel.go b/ingestion/internal/brain/tunnel.go index f445e0f..db3be1b 100644 --- a/ingestion/internal/brain/tunnel.go +++ b/ingestion/internal/brain/tunnel.go @@ -2,7 +2,9 @@ package brain import ( "bufio" + "errors" "fmt" + "io/fs" "os" "path" "path/filepath" @@ -39,24 +41,29 @@ type TunnelCandidate struct { // A note's title is read from YAML frontmatter `title:`; failing that, // the filename slug (sans `.md`, hyphens → spaces) is used. func DetectTunnels(brainDir, content string) ([]TunnelCandidate, error) { - wikiDir := filepath.Join(brainDir, "wiki") - if _, err := os.Stat(wikiDir); os.IsNotExist(err) { + // Walked and read through the brain root, regular files only + // (hyperguild#174, #157): this runs inside every brain_write, so a FIFO + // must not block it and a symlinked note's title must not become a + // candidate. + bfs, err := brainfs.Open(brainDir) + if err != nil { + return nil, fmt.Errorf("open brain: %w", err) + } + defer func() { _ = bfs.Close() }() + if _, err := bfs.Lstat("wiki"); errors.Is(err, fs.ErrNotExist) { return nil, nil - } else if err != nil { - return nil, fmt.Errorf("stat wiki: %w", err) } lowerContent := strings.ToLower(content) var out []TunnelCandidate - err := filepath.WalkDir(wikiDir, func(path string, d os.DirEntry, err error) error { - if err != nil { - return err - } - if d.IsDir() || !strings.HasSuffix(path, ".md") || d.Name() == "_index.md" { + var walkErr error + err = bfs.WalkRegular("wiki", func(rel string) error { + name := path.Base(rel) + if !strings.HasSuffix(name, ".md") || name == "_index.md" { return nil } - title, _ := readTitleAndCreated(path, strings.TrimSuffix(d.Name(), ".md")) + title, _ := readTitleAndCreatedIn(bfs, rel, strings.TrimSuffix(name, ".md")) title = strings.TrimSpace(title) if title == "" { return nil @@ -78,17 +85,25 @@ func DetectTunnels(brainDir, content string) ([]TunnelCandidate, error) { if idx == -1 { return nil } - rel, err := filepath.Rel(brainDir, path) - if err != nil { - return err - } out = append(out, TunnelCandidate{ - TargetPath: filepath.ToSlash(rel), + TargetPath: rel, MatchedTerm: title, Exact: isWholeWord(haystack, idx, len(needle)), }) return nil + }, func(rel string, err error) { + // A wiki/ that is not a real directory yields no candidates, as + // filepath.WalkDir did; other walk errors still abort. + if rel == "wiki" && errors.Is(err, brainfs.ErrNotRegular) { + return + } + if walkErr == nil { + walkErr = fmt.Errorf("%s: %w", rel, err) + } }) + if err == nil { + err = walkErr + } if err != nil { return nil, err }