diff --git a/ingestion/internal/api/update_redirect_test.go b/ingestion/internal/api/update_redirect_test.go new file mode 100644 index 0000000..65241ba --- /dev/null +++ b/ingestion/internal/api/update_redirect_test.go @@ -0,0 +1,29 @@ +package api_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mathiasbq/hyperguild/ingestion/internal/api" +) + +// hyperguild#173: a directory symlink inside wiki/ that points elsewhere in +// the brain redirected brain_update's write (os.Root follows in-root +// intermediate symlinks). Every directory above the note must be real. +func TestUpdateNoteRefusesANoteBehindASymlinkedDirectory(t *testing.T) { + brain := t.TempDir() + victim := filepath.Join(brain, "raw", "facts", "v.md") + require.NoError(t, os.MkdirAll(filepath.Dir(victim), 0o755)) + require.NoError(t, os.WriteFile(victim, []byte("---\ntitle: v\n---\nuntouched\n"), 0o644)) + require.NoError(t, os.MkdirAll(filepath.Join(brain, "wiki", "w"), 0o755)) + require.NoError(t, os.Symlink(filepath.Join("..", "..", "raw", "facts"), filepath.Join(brain, "wiki", "w", "alias"))) + + _, _, _, err := api.UpdateNote(brain, api.UpdateNoteOptions{Path: "wiki/w/alias/v.md", Content: "replaced"}) + assert.Error(t, err) + got, _ := os.ReadFile(victim) + assert.Contains(t, string(got), "untouched") +} diff --git a/ingestion/internal/brain/tunnel_redirect_test.go b/ingestion/internal/brain/tunnel_redirect_test.go new file mode 100644 index 0000000..fe57d5b --- /dev/null +++ b/ingestion/internal/brain/tunnel_redirect_test.go @@ -0,0 +1,32 @@ +package brain_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mathiasbq/hyperguild/ingestion/internal/brain" +) + +// hyperguild#173: brain_tunnel's write followed a directory symlink inside +// wiki/ that points elsewhere in the brain. +func TestWriteTunnelRefusesATargetBehindASymlinkedDirectory(t *testing.T) { + b := t.TempDir() + src := filepath.Join(b, "wiki", "a", "facts", "s.md") + require.NoError(t, os.MkdirAll(filepath.Dir(src), 0o755)) + require.NoError(t, os.WriteFile(src, []byte("x\n"), 0o644)) + victim := filepath.Join(b, "raw", "facts", "v.md") + require.NoError(t, os.MkdirAll(filepath.Dir(victim), 0o755)) + require.NoError(t, os.WriteFile(victim, []byte("untouched\n"), 0o644)) + require.NoError(t, os.MkdirAll(filepath.Join(b, "wiki", "b"), 0o755)) + require.NoError(t, os.Symlink(filepath.Join("..", "..", "raw", "facts"), filepath.Join(b, "wiki", "b", "facts"))) + + assert.Error(t, brain.WriteTunnel(b, "wiki/a/facts/s.md", "wiki/b/facts/v.md")) + got, _ := os.ReadFile(victim) + assert.Equal(t, "untouched\n", string(got)) + got, _ = os.ReadFile(src) + assert.Equal(t, "x\n", string(got), "the source is not rewritten either") +} diff --git a/ingestion/internal/brainfs/brainfs.go b/ingestion/internal/brainfs/brainfs.go index 7496a74..92ea8e3 100644 --- a/ingestion/internal/brainfs/brainfs.go +++ b/ingestion/internal/brainfs/brainfs.go @@ -128,6 +128,9 @@ func (b *Brain) ReadDirDirs(relDir string) ([]string, error) { // 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) { + if err := b.realDir(path.Dir(filepath.ToSlash(rel))); err != nil { + return nil, err + } fi, err := b.root.Lstat(rel) if err != nil { return nil, err @@ -154,6 +157,9 @@ func (b *Brain) readOpened(rel string) ([]byte, error) { return b.readOpenedMax( // ReadRegularMax is ReadRegular with a size limit, checked on the opened // handle before reading (max < 0 means no limit). func (b *Brain) ReadRegularMax(rel string, max int64) ([]byte, error) { + if err := b.realDir(path.Dir(filepath.ToSlash(rel))); err != nil { + return nil, err + } fi, err := b.root.Lstat(rel) if err != nil { return nil, err @@ -256,10 +262,18 @@ func (b *Brain) WalkRegular(relDir string, fn func(rel string) error, onErr func // O_NOFOLLOW (verified on go1.27), so the path is Lstat-ed through the root // first and the opened handle must be that same file (os.SameFile), which // also catches a swap between the two. O_NONBLOCK keeps a FIFO from blocking -// the open. Intermediate in-brain symlinks are still followed (os.Root keeps -// the write inside the brain; hyperguild#167). +// the open. Every directory above the file must be a real directory +// (hyperguild#173); a directory swapped in after that check is the #167 +// residual. func (b *Brain) WriteRegular(rel string, data []byte) error { rel = filepath.ToSlash(rel) + // Every directory above the file must be real: os.Root follows an + // in-brain intermediate symlink, which would redirect the write + // elsewhere in the brain (hyperguild#173). A swap after this check is + // the #167 residual. + if err := b.realDir(path.Dir(rel)); err != nil { + return err + } lfi, err := b.root.Lstat(rel) if err != nil { return err diff --git a/ingestion/internal/brainfs/brainfs_test.go b/ingestion/internal/brainfs/brainfs_test.go index d964f25..b8565b7 100644 --- a/ingestion/internal/brainfs/brainfs_test.go +++ b/ingestion/internal/brainfs/brainfs_test.go @@ -326,3 +326,28 @@ func TestReadDirDirsListsOnlyRealSubdirsAndRefusesSymlinkedComponents(t *testing _, err = b.ReadDirDirs("alias") assert.ErrorIs(t, err, ErrNotRegular) } + +// hyperguild#173: a file reached through a symlinked directory inside the +// brain is refused for reads and writes, so a caller-supplied path cannot be +// redirected to another part of the brain. +func TestReadAndWriteRegularRefuseAFileBehindASymlinkedDirectory(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "raw"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "raw", "v.md"), []byte("untouched"), 0o644)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki"), 0o755)) + require.NoError(t, os.Symlink(filepath.Join("..", "raw"), filepath.Join(dir, "wiki", "alias"))) + b, err := Open(dir) + require.NoError(t, err) + defer func() { _ = b.Close() }() + + _, err = b.ReadRegular("wiki/alias/v.md") + assert.ErrorIs(t, err, ErrNotRegular) + _, err = b.ReadRegularMax("wiki/alias/v.md", 100) + assert.ErrorIs(t, err, ErrNotRegular) + assert.ErrorIs(t, b.WriteRegular("wiki/alias/v.md", []byte("x")), ErrNotRegular) + got, _ := os.ReadFile(filepath.Join(dir, "raw", "v.md")) + assert.Equal(t, "untouched", string(got)) + got, err = b.ReadRegular("raw/v.md") + require.NoError(t, err, "positive control: the real path still reads") + assert.Equal(t, "untouched", string(got)) +} diff --git a/ingestion/internal/session/id_test.go b/ingestion/internal/session/id_test.go index fe06f21..ee86db3 100644 --- a/ingestion/internal/session/id_test.go +++ b/ingestion/internal/session/id_test.go @@ -90,3 +90,15 @@ func TestAppendReturnsPromptlyOnAFIFOLogWithNoReader(t *testing.T) { t.Fatal("Append blocked on a FIFO log with no reader") } } + +// hyperguild#173: session_log followed a symlinked sessions dir. +func TestAppendRefusesASymlinkedSessionsDir(t *testing.T) { + root := t.TempDir() + elsewhere := filepath.Join(root, "elsewhere") + require.NoError(t, os.MkdirAll(elsewhere, 0o755)) + dir := filepath.Join(root, "sessions") + require.NoError(t, os.Symlink("elsewhere", dir)) + assert.Error(t, Append(dir, "s", Entry{SessionID: "s"})) + ents, _ := os.ReadDir(elsewhere) + assert.Empty(t, ents, "a log was created through the symlinked dir") +} diff --git a/ingestion/internal/session/session.go b/ingestion/internal/session/session.go index 878d425..656da92 100644 --- a/ingestion/internal/session/session.go +++ b/ingestion/internal/session/session.go @@ -68,6 +68,13 @@ func Append(sessionsDir, sessionID string, entry Entry) error { if err := os.MkdirAll(sessionsDir, 0o755); err != nil { return fmt.Errorf("create sessions dir: %w", err) } + // The sessions dir itself must be a real directory, not a symlink that + // redirects the log elsewhere (hyperguild#173). + if fi, err := os.Lstat(sessionsDir); err != nil { + return fmt.Errorf("stat sessions dir: %w", err) + } else if !fi.IsDir() { + return fmt.Errorf("sessions dir %s is not a real directory", sessionsDir) + } path := filepath.Join(sessionsDir, sessionID+".jsonl") f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0o644) if err != nil {