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>
5 files changed,
+277
−0
internal/stopevidence/fds.go+9 −0internal/stopevidence/fds_linux.go+97 −0internal/stopevidence/fds_linux_test.go+149 −0internal/stopevidence/fds_other.go+8 −0internal/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) |
| + } |
| +} |