diff --git a/ingestion/cmd/server/symlink_check_test.go b/ingestion/cmd/server/symlink_check_test.go index 0efcb5e..0f6a357 100644 --- a/ingestion/cmd/server/symlink_check_test.go +++ b/ingestion/cmd/server/symlink_check_test.go @@ -53,3 +53,13 @@ func TestAlertingWarningsNamesTheSymlinkTripwire(t *testing.T) { w := strings.Join(alertingWarnings(func(string) string { return "" }), " ") assert.Contains(t, w, "symlink tripwire") } + +func TestStartupSymlinkCheckRefusesAnUnlistableBrainRoot(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root lists mode-0100 directories") + } + brain := t.TempDir() + require.NoError(t, os.Chmod(brain, 0o100)) + t.Cleanup(func() { _ = os.Chmod(brain, 0o755) }) + assert.Error(t, startupSymlinkCheck(brain)) +} diff --git a/ingestion/internal/symlinkguard/symlinkguard.go b/ingestion/internal/symlinkguard/symlinkguard.go index e3fa3b2..2dc11f0 100644 --- a/ingestion/internal/symlinkguard/symlinkguard.go +++ b/ingestion/internal/symlinkguard/symlinkguard.go @@ -62,8 +62,9 @@ func (r Result) String() string { // directory it could not read, continuing past both. Only the ROOT .git is // skipped (it is brain-sync's, and no reader walks it); a nested .git is // scanned like any other directory. brainDir itself may be a symlink (a -// volume mount); it is resolved first. The error is non-nil only when the -// root cannot be resolved, wrapping os.ErrNotExist when it is missing. +// volume mount); it is resolved first. The error is non-nil when the root +// cannot be resolved (wrapping os.ErrNotExist when it is missing) or cannot be +// listed. func Scan(brainDir string) (Result, error) { root, err := filepath.EvalSymlinks(brainDir) if err != nil { @@ -71,7 +72,7 @@ func Scan(brainDir string) (Result, error) { } var r Result rootGit := filepath.Join(root, ".git") - _ = filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { + walkErr := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { rel, _ := filepath.Rel(root, p) rel = filepath.ToSlash(rel) if err != nil { @@ -94,6 +95,13 @@ func Scan(brainDir string) (Result, error) { }) sort.Strings(r.Symlinks) sort.Strings(r.Unreadable) + if walkErr != nil { + // Only the root's own error reaches here (deeper errors are recorded + // as Unreadable). A root that cannot be listed cannot be shown clean + // (PR #159 round 2: discarding this reported clean while search + // served a planted symlink). + return r, fmt.Errorf("scan brain dir: %w", walkErr) + } return r, nil } diff --git a/ingestion/internal/symlinkguard/symlinkguard_test.go b/ingestion/internal/symlinkguard/symlinkguard_test.go index 4dbbf67..6b46112 100644 --- a/ingestion/internal/symlinkguard/symlinkguard_test.go +++ b/ingestion/internal/symlinkguard/symlinkguard_test.go @@ -153,3 +153,37 @@ func TestWatchToleratesANonPositiveInterval(t *testing.T) { t.Fatal("Watch did not return on cancel") } } + +// PR #159 round 2: a brain root that can be entered but not listed (mode +// 0100) made WalkDir fail at the root; the error was discarded and Scan +// reported clean, while search (which goes straight into wiki/) still served +// a planted symlink. A root that cannot be listed is now an error. +func TestScanOfAnUnlistableRootIsAnError(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root lists mode-0100 directories") + } + brain := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(brain, "wiki"), 0o755)) + require.NoError(t, os.Symlink(t.TempDir(), filepath.Join(brain, "wiki", "leak.md"))) + require.NoError(t, os.Chmod(brain, 0o100)) + t.Cleanup(func() { _ = os.Chmod(brain, 0o755) }) + _, err := Scan(brain) + require.Error(t, err) + assert.False(t, errors.Is(err, os.ErrNotExist), "an unlistable root is not a missing root") +} + +// The same set that disappears and comes back is reported again. +func TestWatchReportsASetAgainAfterItWasCleared(t *testing.T) { + brain := t.TempDir() + link := filepath.Join(brain, "planted") + n := &recNotifier{} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go Watch(ctx, brain, 10*time.Millisecond, n) + require.NoError(t, os.Symlink(t.TempDir(), link)) + require.Eventually(t, func() bool { return n.count() == 1 }, 2*time.Second, 10*time.Millisecond) + require.NoError(t, os.Remove(link)) + time.Sleep(60 * time.Millisecond) + require.NoError(t, os.Symlink(t.TempDir(), link)) + require.Eventually(t, func() bool { return n.count() == 2 }, 2*time.Second, 10*time.Millisecond) +}