Merge origin/main into fix/tunnel-traversal (conflict-only)
Conflict in ingestion/internal/brainfs/brainfs_test.go only: both sides appended a test after TestReadRegularNeverReturnsAnInBrainFileSwappedInBehindASymlink (this branch: TestWriteRegularRefusesFinalSymlinksFIFOsEscapesAndMissingFilesAndRewritesRegularFiles; main: TestReadDirDirsListsOnlyRealSubdirsAndRefusesSymlinkedComponents). Kept both, unchanged. No production code touched by the resolution. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -104,6 +104,27 @@ func (b *Brain) ReadDirRegular(relDir string) ([]string, error) {
|
||||
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
|
||||
// regular file, both before the open and on the opened handle.
|
||||
func (b *Brain) ReadRegular(rel string) ([]byte, error) {
|
||||
|
||||
@@ -308,3 +308,21 @@ func TestWriteRegularRefusesFinalSymlinksFIFOsEscapesAndMissingFilesAndRewritesR
|
||||
got, _ = os.ReadFile(filepath.Join(dir, "n.md"))
|
||||
assert.Equal(t, "new", string(got), "truncated and rewritten")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ const outsideContent = "---\ntitle: " + Sentinel + "\naliases:\n - " + Sentinel
|
||||
"\n\n" + Sentinel + " [[" + Sentinel + "]]\n"
|
||||
|
||||
// Outside writes a file outside any brain dir containing Sentinel.
|
||||
func Outside(t *testing.T) string {
|
||||
func Outside(t testing.TB) string {
|
||||
t.Helper()
|
||||
p := filepath.Join(t.TempDir(), "secret.md")
|
||||
if err := os.WriteFile(p, []byte(outsideContent), 0o644); err != nil {
|
||||
@@ -42,9 +42,12 @@ var swapSeq atomic.Int64
|
||||
// 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
|
||||
// handle check parks forever instead of reading EOF. The swapper is stopped
|
||||
// and awaited before t.TempDir cleanup.
|
||||
func StartSwap(t *testing.T, path, inside, outside string, fifo bool) {
|
||||
// and awaited before t.TempDir cleanup. It returns the number of symlinks
|
||||
// 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()
|
||||
var swaps atomic.Int64
|
||||
var stop atomic.Bool
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
@@ -67,22 +70,26 @@ func StartSwap(t *testing.T, path, inside, outside string, fifo bool) {
|
||||
}()
|
||||
for i := 0; !stop.Load(); i++ {
|
||||
_ = os.Remove(tmp)
|
||||
var made error
|
||||
switch {
|
||||
case i%2 == 0:
|
||||
_ = os.WriteFile(tmp, []byte(inside), 0o644)
|
||||
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 {
|
||||
writers = append(writers, w)
|
||||
}
|
||||
}
|
||||
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() })
|
||||
return &swaps
|
||||
}
|
||||
|
||||
// WriteSentinelAt writes a file carrying Sentinel at p (creating its
|
||||
@@ -107,18 +114,41 @@ func AssertNoLeak(t *testing.T, path, inside string, d time.Duration, read func(
|
||||
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.
|
||||
// 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()
|
||||
StartSwap(t, path, inside, target, false)
|
||||
leaks, iters := 0, 0
|
||||
if !strings.Contains(inside, InsideMarker) {
|
||||
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++ {
|
||||
leaks += strings.Count(read(), Sentinel)
|
||||
out := read()
|
||||
leaks += strings.Count(out, Sentinel)
|
||||
if strings.Contains(out, InsideMarker) {
|
||||
insideReads++
|
||||
}
|
||||
}
|
||||
if leaks > 0 {
|
||||
t.Fatalf("outside file leaked %d times in %d iterations", 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)
|
||||
}
|
||||
}
|
||||
|
||||
// AssertNoHang loops read for d while a FIFO swapper (held writers) runs on
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user