carlos / platform Public

stopevidence: complete /proc descriptor-holder scan, EACCES is incomplete (claim Plan 3 Task 5)

Co-Authored-By: Codex <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
codex agent, operated by paul@keymail.dev pushed by claude agent, operated by paul@keymail.dev b3c19f9037379e28b6230f91c87d0569284edfa7 parent b4ff0e8
5 files changed, +277 −0
  • internal/stopevidence/fds.go +9 −0
  • internal/stopevidence/fds_linux.go +97 −0
  • internal/stopevidence/fds_linux_test.go +149 −0
  • internal/stopevidence/fds_other.go +8 −0
  • internal/stopevidence/fds_other_test.go +14 −0
diff --git a/internal/stopevidence/fds.go b/internal/stopevidence/fds.go
new file mode 100644
index 000000000..6b6caf288
--- /dev/null
+++ b/internal/stopevidence/fds.go
@@ -0,0 +1,9 @@
+package stopevidence
+
+import "errors"
+
+var (
+ ErrIncomplete = errors.New("stopevidence: incomplete descriptor scan")
+ ErrUnsupported = errors.New("stopevidence: unsupported platform")
+ ErrInvalidInput = errors.New("stopevidence: invalid input")
+)
diff --git a/internal/stopevidence/fds_linux.go b/internal/stopevidence/fds_linux.go
new file mode 100644
index 000000000..3313d73d9
--- /dev/null
+++ b/internal/stopevidence/fds_linux.go
@@ -0,0 +1,97 @@
+//go:build linux
+
+package stopevidence
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strconv"
+ "strings"
+)
+
+type fdOps struct {
+ readDir func(string) ([]os.DirEntry, error)
+ readlink func(string) (string, error)
+ stat func(string) (os.FileInfo, error)
+}
+
+func realFDOps() fdOps { return fdOps{os.ReadDir, os.Readlink, os.Stat} }
+
+func FDHolders(procRoot string, paths []string) ([]int, error) {
+ return fdHolders(context.Background(), procRoot, paths, realFDOps())
+}
+
+func fdHolders(ctx context.Context, procRoot string, paths []string, ops fdOps) ([]int, error) {
+ if ctx == nil || !filepath.IsAbs(procRoot) || len(paths) == 0 {
+ return nil, ErrInvalidInput
+ }
+ wanted := make(map[string]bool, len(paths))
+ for _, path := range paths {
+ if !filepath.IsAbs(path) {
+ return nil, ErrInvalidInput
+ }
+ wanted[filepath.Clean(path)] = true
+ }
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+ entries, err := ops.readDir(procRoot)
+ if err != nil {
+ return nil, fmt.Errorf("%w: %w", ErrIncomplete, err)
+ }
+ var holders []int
+ for _, entry := range entries {
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+ pid, err := strconv.Atoi(entry.Name())
+ if err != nil || pid <= 0 {
+ continue
+ }
+ pidDir := filepath.Join(procRoot, entry.Name())
+ fds, err := ops.readDir(filepath.Join(pidDir, "fd"))
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ if _, gone := ops.stat(pidDir); errors.Is(gone, os.ErrNotExist) {
+ continue
+ }
+ }
+ return nil, fmt.Errorf("%w: pid %d: %w", ErrIncomplete, pid, err)
+ }
+ found := false
+ for _, fd := range fds {
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+ target, err := ops.readlink(filepath.Join(pidDir, "fd", fd.Name()))
+ if errors.Is(err, os.ErrNotExist) {
+ continue
+ }
+ if err != nil {
+ return nil, fmt.Errorf("%w: pid %d fd %s: %w", ErrIncomplete, pid, fd.Name(), err)
+ }
+ if !filepath.IsAbs(target) {
+ continue
+ } // sockets, pipes and anon_inode
+ if wanted[filepath.Clean(target)] || wanted[filepath.Clean(strings.TrimSuffix(target, " (deleted)"))] {
+ found = true
+ }
+ }
+ if found {
+ holders = append(holders, pid)
+ }
+ }
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+ sort.Ints(holders)
+ return holders, nil
+}
+
+func scanFDs(ctx context.Context, root string, paths []string) ([]int, error) {
+ return fdHolders(ctx, root, paths, realFDOps())
+}
diff --git a/internal/stopevidence/fds_linux_test.go b/internal/stopevidence/fds_linux_test.go
new file mode 100644
index 000000000..0d1d8d898
--- /dev/null
+++ b/internal/stopevidence/fds_linux_test.go
@@ -0,0 +1,149 @@
+//go:build linux
+
+package stopevidence
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "reflect"
+ "strconv"
+ "syscall"
+ "testing"
+)
+
+func procFixture(t *testing.T) (string, string) {
+ t.Helper()
+ root, db := t.TempDir(), filepath.Join(t.TempDir(), "data.db")
+ for pid, targets := range map[string][]string{
+ "20": {db, db + "-wal", db + "-shm"},
+ "3": {db + " (deleted)"},
+ "90": {db + ".other"},
+ } {
+ dir := filepath.Join(root, pid, "fd")
+ if err := os.MkdirAll(dir, 0700); err != nil {
+ t.Fatal(err)
+ }
+ for n, target := range targets {
+ if err := os.Symlink(target, filepath.Join(dir, strconv.Itoa(n))); err != nil {
+ t.Fatal(err)
+ }
+ }
+ }
+ return root, db
+}
+
+func TestFDHolders(t *testing.T) {
+ root, db := procFixture(t)
+ got, err := FDHolders(root, []string{filepath.Join(filepath.Dir(db), ".", "data.db"), db + "-wal", db + "-shm"})
+ if err != nil || !reflect.DeepEqual(got, []int{3, 20}) {
+ t.Fatal(got, err)
+ }
+ if _, err := FDHolders(root, []string{"relative.db"}); !errors.Is(err, ErrInvalidInput) {
+ t.Fatal(err)
+ }
+ if _, err := FDHolders(root, nil); !errors.Is(err, ErrInvalidInput) {
+ t.Fatal(err)
+ }
+ if got, err := FDHolders(filepath.Join(root, "missing"), []string{db}); !errors.Is(err, ErrIncomplete) || got != nil {
+ t.Fatal(got, err)
+ }
+}
+
+func TestFDScanRefusesIncompleteReads(t *testing.T) {
+ root, db := procFixture(t)
+ for _, where := range []string{"root", "fd-directory", "readlink"} {
+ t.Run(where, func(t *testing.T) {
+ ops := realFDOps()
+ readDir, readlink := ops.readDir, ops.readlink
+ ops.readDir = func(p string) ([]os.DirEntry, error) {
+ if (where == "root" && p == root) || (where == "fd-directory" && p == filepath.Join(root, "20", "fd")) {
+ return nil, syscall.EACCES
+ }
+ return readDir(p)
+ }
+ ops.readlink = func(p string) (string, error) {
+ if where == "readlink" && p == filepath.Join(root, "20", "fd", "0") {
+ return "", syscall.EACCES
+ }
+ return readlink(p)
+ }
+ got, err := fdHolders(t.Context(), root, []string{db}, ops)
+ if !errors.Is(err, ErrIncomplete) || !errors.Is(err, syscall.EACCES) || got != nil {
+ t.Fatalf("partial success: %v %v", got, err)
+ }
+ })
+ }
+ ops := realFDOps()
+ readDir := ops.readDir
+ ops.readDir = func(p string) ([]os.DirEntry, error) {
+ if p == filepath.Join(root, "20", "fd") {
+ return nil, os.ErrNotExist
+ }
+ return readDir(p)
+ }
+ if _, err := fdHolders(t.Context(), root, []string{db}, ops); !errors.Is(err, ErrIncomplete) {
+ t.Fatal("live PID with missing fd directory", err)
+ }
+ stat := ops.stat
+ ops.stat = func(p string) (os.FileInfo, error) {
+ if p == filepath.Join(root, "20") {
+ return nil, os.ErrNotExist
+ }
+ return stat(p)
+ }
+ got, err := fdHolders(t.Context(), root, []string{db}, ops)
+ if err != nil || !reflect.DeepEqual(got, []int{3}) {
+ t.Fatal("vanished process", got, err)
+ }
+ ops = realFDOps()
+ readlink := ops.readlink
+ ops.readlink = func(p string) (string, error) {
+ if p == filepath.Join(root, "20", "fd", "0") {
+ return "", os.ErrNotExist
+ }
+ return readlink(p)
+ }
+ if got, err := fdHolders(t.Context(), root, []string{db}, ops); err != nil || !reflect.DeepEqual(got, []int{3}) {
+ t.Fatal("closed fd", got, err)
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ if _, err := fdHolders(ctx, root, []string{db}, realFDOps()); !errors.Is(err, context.Canceled) {
+ t.Fatal(err)
+ }
+}
+
+func TestFDHoldersRealProc(t *testing.T) {
+ f, err := os.CreateTemp(t.TempDir(), "open-db-")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer f.Close()
+ pid := os.Getpid()
+ // Project the real /proc entry into an otherwise empty scan root. This
+ // proves real descriptors work without requiring access to other UIDs.
+ root := t.TempDir()
+ if err := os.Symlink(filepath.Join("/proc", strconv.Itoa(pid)), filepath.Join(root, strconv.Itoa(pid))); err != nil {
+ t.Fatal(err)
+ }
+ got, err := FDHolders(root, []string{f.Name()})
+ if err != nil || !reflect.DeepEqual(got, []int{pid}) {
+ t.Fatal(got, err)
+ }
+ got, err = FDHolders("/proc", []string{f.Name()})
+ if errors.Is(err, ErrIncomplete) {
+ t.Log("whole-machine scan correctly refused; real self descriptor witness passed")
+ return
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, holder := range got {
+ if holder == pid {
+ return
+ }
+ }
+ t.Fatal("real /proc scan missed own descriptor", got)
+}
diff --git a/internal/stopevidence/fds_other.go b/internal/stopevidence/fds_other.go
new file mode 100644
index 000000000..058baae7c
--- /dev/null
+++ b/internal/stopevidence/fds_other.go
@@ -0,0 +1,8 @@
+//go:build !linux
+
+package stopevidence
+
+import "context"
+
+func FDHolders(string, []string) ([]int, error) { return nil, ErrUnsupported }
+func scanFDs(context.Context, string, []string) ([]int, error) { return nil, ErrUnsupported }
diff --git a/internal/stopevidence/fds_other_test.go b/internal/stopevidence/fds_other_test.go
new file mode 100644
index 000000000..309a90b21
--- /dev/null
+++ b/internal/stopevidence/fds_other_test.go
@@ -0,0 +1,14 @@
+//go:build !linux
+
+package stopevidence
+
+import (
+ "errors"
+ "testing"
+)
+
+func TestFDHoldersUnsupported(t *testing.T) {
+ if got, err := FDHolders("/proc", []string{"/data/a.db"}); got != nil || !errors.Is(err, ErrUnsupported) {
+ t.Fatal(got, err)
+ }
+}