carlos / platform Public

hostguard, stopevidence: per-task fd scan, durable idempotent retries, bounded filenames (Plan 3 review round 1)

Co-Authored-By: Codex <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 8c40aca61d6b25ea5e936ffab171d237f1b13483 parent 01ed2bd
9 files changed, +394 −50
  • internal/hostguard/flock_unix.go +2 −3
  • internal/hostguard/guard.go +13 −9
  • internal/hostguard/guard_test.go +159 −0
  • internal/hostguard/record.go +16 −2
  • internal/hostguard/record_other.go +1 −0
  • internal/hostguard/record_unix.go +41 −14
  • internal/stopevidence/evidence_linux_test.go +1 −1
  • internal/stopevidence/fds_linux.go +42 −15
  • internal/stopevidence/fds_linux_test.go +119 −6

CI passed — run details

diff --git a/internal/hostguard/flock_unix.go b/internal/hostguard/flock_unix.go
index e425fe09c..9ba78ef87 100644
--- a/internal/hostguard/flock_unix.go
+++ b/internal/hostguard/flock_unix.go
@@ -8,7 +8,6 @@ import (
"os"
"time"
- "github.com/carlosframework/platform/internal/controlname"
"golang.org/x/sys/unix"
)
@@ -19,8 +18,8 @@ func acquire(ctx context.Context, dir, host string) (func(), error) {
if err := ctx.Err(); err != nil {
return nil, err
}
- if !controlname.ValidHostShape(host) {
- return nil, ErrInvalidHost
+ if err := validateFileHost(host); err != nil {
+ return nil, err
}
dirFD, err := openDirectory(dir)
if err != nil {
diff --git a/internal/hostguard/guard.go b/internal/hostguard/guard.go
index 5b4b356a1..09aac8f0b 100644
--- a/internal/hostguard/guard.go
+++ b/internal/hostguard/guard.go
@@ -4,7 +4,6 @@ import (
"context"
"errors"
- "github.com/carlosframework/platform/internal/controlname"
"github.com/carlosframework/platform/internal/hostclaim"
"github.com/carlosframework/platform/internal/release"
)
@@ -39,14 +38,15 @@ func (r StoreReader) Read(ctx context.Context, host string) (hostclaim.Observed,
}
type Guard struct {
- DataDir string
- LockDir string
- Claims ClaimReader
+ DataDir string
+ LockDir string
+ Claims ClaimReader
+ openRecords func(string) (*recordDir, error)
}
func (g Guard) locked(ctx context.Context, host string, fn func(*recordDir) error) error {
- if !controlname.ValidHostShape(host) {
- return ErrInvalidHost
+ if err := validateFileHost(host); err != nil {
+ return err
}
if g.DataDir == "" || ctx == nil {
return ErrInvalidInput
@@ -60,7 +60,11 @@ func (g Guard) locked(ctx context.Context, host string, fn func(*recordDir) erro
return err
}
defer unlock()
- d, err := openRecordDir(g.DataDir)
+ open := g.openRecords
+ if open == nil {
+ open = openRecordDir
+ }
+ d, err := open(g.DataDir)
if err != nil {
return err
}
@@ -182,7 +186,7 @@ func (g Guard) Activate(ctx context.Context, host, box string, participant func(
return ErrOldStamp
}
if stamp == r.ActiveStamp {
- return nil
+ return d.sync(host)
}
r.retire()
}
@@ -214,7 +218,7 @@ func (g Guard) RetireInstallation(ctx context.Context, host string, check func(*
return ErrRecordIncarnation
}
if r.ActiveStamp == 0 {
- return nil
+ return d.sync(host)
}
r.retire()
if err := ctx.Err(); err != nil {
diff --git a/internal/hostguard/guard_test.go b/internal/hostguard/guard_test.go
index f6496618b..02ab90304 100644
--- a/internal/hostguard/guard_test.go
+++ b/internal/hostguard/guard_test.go
@@ -5,13 +5,17 @@ package hostguard
import (
"context"
"errors"
+ "fmt"
"os"
"path/filepath"
"reflect"
+ "strings"
"testing"
+ "github.com/carlosframework/platform/internal/controlname"
"github.com/carlosframework/platform/internal/hostclaim"
"github.com/carlosframework/platform/internal/release"
+ "golang.org/x/sys/unix"
)
type readerFunc func(context.Context, string) (hostclaim.Observed, error)
@@ -342,3 +346,158 @@ func TestStoreReaderUsesHostclaimRead(t *testing.T) {
t.Fatal(err)
}
}
+
+func TestLifecycleRetrySyncsRecord(t *testing.T) {
+ for _, retired := range []bool{false, true} {
+ name := "activate"
+ if retired {
+ name = "retire"
+ }
+ t.Run(name, func(t *testing.T) {
+ f := newGuardFixture(t)
+ host := f.obs.Host
+ operation := func() error { return f.g.Activate(t.Context(), host, "uk", nil) }
+ wantStamp := uint64(1)
+ if retired {
+ if err := operation(); err != nil {
+ t.Fatal(err)
+ }
+ operation = func() error { return f.g.RetireInstallation(t.Context(), host, allow) }
+ wantStamp = 0
+ }
+ failure := errors.New("injected fsync failure")
+ failKind := "directory"
+ var calls []string
+ f.g.openRecords = func(path string) (*recordDir, error) {
+ d, err := openRecordDir(path)
+ if err != nil {
+ return nil, err
+ }
+ d.fsync = func(fd int) error {
+ var st unix.Stat_t
+ if err := unix.Fstat(fd, &st); err != nil {
+ return err
+ }
+ kind := "file"
+ if st.Mode&unix.S_IFMT == unix.S_IFDIR {
+ kind = "directory"
+ }
+ calls = append(calls, kind)
+ if kind == failKind {
+ return failure
+ }
+ return unix.Fsync(fd)
+ }
+ return d, nil
+ }
+ if err := operation(); !errors.Is(err, failure) {
+ t.Fatalf("post-rename failure: %v", err)
+ }
+ if !reflect.DeepEqual(calls, []string{"file", "directory"}) {
+ t.Fatalf("initial syncs: %v", calls)
+ }
+ captured, err := Capture(f.g.DataDir, host)
+ if err != nil || captured.Stamp != wantStamp {
+ t.Fatalf("rename did not publish: %+v %v", captured, err)
+ }
+ defer unchangedFile(t, filepath.Join(f.g.DataDir, "hostguard", host+".json"))()
+ for _, kind := range []string{"file", "directory", ""} {
+ failKind, calls = kind, nil
+ err := operation()
+ if kind == "" {
+ if err != nil {
+ t.Fatal(err)
+ }
+ } else if !errors.Is(err, failure) {
+ t.Fatalf("retry acknowledged %s sync failure: %v", kind, err)
+ }
+ want := []string{"file", "directory"}
+ if kind == "file" {
+ want = []string{"file"}
+ }
+ if !reflect.DeepEqual(calls, want) {
+ t.Fatalf("retry syncs: %v want %v", calls, want)
+ }
+ }
+ })
+ }
+}
+
+func TestGuardHostnameLengthBoundary(t *testing.T) {
+ for _, length := range []int{221, 222, 250, 251, 252, 253, 254} {
+ t.Run(fmt.Sprint(length), func(t *testing.T) {
+ host := strings.Repeat(strings.Repeat("a", 63)+".", 3) + strings.Repeat("b", length-192)
+ if controlname.ValidHostShape(host) != (length <= 253) {
+ t.Fatalf("unexpected hostname validation for %d bytes", length)
+ }
+ f := newGuardFixture(t)
+ f.obs.Host, f.obs.Claim.Host = host, host
+ if length > 250 {
+ want := ErrHostTooLong
+ if length > 253 {
+ want = ErrInvalidHost
+ }
+ for name, operation := range map[string]func() error{
+ "capture": func() error { _, err := Capture(f.g.DataDir, host); return err },
+ "activate": func() error { return f.g.Activate(t.Context(), host, "uk", nil) },
+ "retire": func() error { return f.g.RetireInstallation(t.Context(), host, allow) },
+ "with": func() error {
+ return f.g.With(t.Context(), host, Captured{Incarnation: "inc", Stamp: 1}, allow, func() error { t.Fatal("callback ran"); return nil })
+ },
+ } {
+ if err := operation(); !errors.Is(err, want) {
+ t.Errorf("%s: got %v, want %v", name, err, want)
+ }
+ }
+ if f.reads != 0 {
+ t.Fatalf("read claim before rejecting hostname: %d", f.reads)
+ }
+ for _, dir := range []string{f.g.LockDir, filepath.Join(f.g.DataDir, "hostguard")} {
+ files, err := os.ReadDir(dir)
+ if err != nil || len(files) != 0 {
+ t.Fatalf("refusal created files: %v %v", files, err)
+ }
+ }
+ return
+ }
+ if err := f.g.Activate(t.Context(), host, "uk", nil); err != nil {
+ t.Fatal(err)
+ }
+ captured, err := Capture(f.g.DataDir, host)
+ if err != nil || captured != (Captured{Incarnation: "inc", Stamp: 1}) {
+ t.Fatal(captured, err)
+ }
+ if err := f.g.Activate(t.Context(), host, "uk", nil); err != nil {
+ t.Fatal(err)
+ }
+ called := false
+ if err := f.g.With(t.Context(), host, captured, allow, func() error { called = true; return nil }); err != nil || !called {
+ t.Fatal(called, err)
+ }
+ for i := 0; i < 2; i++ {
+ if err := f.g.RetireInstallation(t.Context(), host, allow); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if got, err := Capture(f.g.DataDir, host); err != nil || got.Stamp != 0 {
+ t.Fatal(got, err)
+ }
+ if err := f.g.With(t.Context(), host, captured, allow, func() error { t.Fatal("retired callback ran"); return nil }); !errors.Is(err, ErrInactive) {
+ t.Fatal(err)
+ }
+ f.obs.Claim.Holder.Stamp = 2
+ if err := f.g.Activate(t.Context(), host, "uk", nil); err != nil {
+ t.Fatal(err)
+ }
+ for _, path := range []string{filepath.Join(f.g.DataDir, "hostguard", host+".json"), filepath.Join(f.g.LockDir, host+".lock")} {
+ if _, err := os.Stat(path); err != nil {
+ t.Fatal(err)
+ }
+ }
+ files, err := os.ReadDir(filepath.Join(f.g.DataDir, "hostguard"))
+ if err != nil || len(files) != 1 {
+ t.Fatalf("temporary files remain: %v %v", files, err)
+ }
+ })
+ }
+}
diff --git a/internal/hostguard/record.go b/internal/hostguard/record.go
index 5c6d8d62a..bee23a619 100644
--- a/internal/hostguard/record.go
+++ b/internal/hostguard/record.go
@@ -15,6 +15,7 @@ var (
ErrNotProvisioned = errors.New("hostguard: directory not provisioned")
ErrUnsafeFile = errors.New("hostguard: expected regular file")
ErrInvalidHost = errors.New("hostguard: invalid host")
+ ErrHostTooLong = errors.New("hostguard: host too long for record or lock filename")
ErrNoRecord = errors.New("hostguard: no local record")
ErrMalformedRecord = errors.New("hostguard: malformed local record")
)
@@ -46,9 +47,19 @@ func (r record) validate(host string) error {
return nil
}
-func readRecord(dataDir, host string) (record, error) {
+func validateFileHost(host string) error {
if !controlname.ValidHostShape(host) {
- return record{}, ErrInvalidHost
+ return ErrInvalidHost
+ }
+ if len(host)+len(".json") > 255 || len(host)+len(".lock") > 255 {
+ return ErrHostTooLong
+ }
+ return nil
+}
+
+func readRecord(dataDir, host string) (record, error) {
+ if err := validateFileHost(host); err != nil {
+ return record{}, err
}
d, err := openRecordDir(dataDir)
if err != nil {
@@ -83,6 +94,9 @@ func Capture(dataDir, host string) (Captured, error) {
}
func writeRecord(dataDir string, r record) error {
+ if err := validateFileHost(r.Host); err != nil {
+ return err
+ }
if err := r.validate(r.Host); err != nil {
return err
}
diff --git a/internal/hostguard/record_other.go b/internal/hostguard/record_other.go
index c0d8d90f0..f65922179 100644
--- a/internal/hostguard/record_other.go
+++ b/internal/hostguard/record_other.go
@@ -8,3 +8,4 @@ func openRecordDir(string) (*recordDir, error) { return nil, ErrUnsupported }
func (*recordDir) close() {}
func (*recordDir) read(string) (record, error) { return record{}, ErrUnsupported }
func (*recordDir) write(record) error { return ErrUnsupported }
+func (*recordDir) sync(string) error { return ErrUnsupported }
diff --git a/internal/hostguard/record_unix.go b/internal/hostguard/record_unix.go
index 322fdeadc..ab71019c8 100644
--- a/internal/hostguard/record_unix.go
+++ b/internal/hostguard/record_unix.go
@@ -12,11 +12,13 @@ import (
"os"
"path/filepath"
- "github.com/carlosframework/platform/internal/controlname"
"golang.org/x/sys/unix"
)
-type recordDir struct{ fd int }
+type recordDir struct {
+ fd int
+ fsync func(int) error
+}
func openDirectory(path string) (int, error) {
fd, err := unix.Open(path, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
@@ -43,32 +45,42 @@ func openRecordDir(dataDir string) (*recordDir, error) {
if err != nil {
return nil, err
}
- return &recordDir{fd: fd}, nil
+ return &recordDir{fd: fd, fsync: unix.Fsync}, nil
}
func (d *recordDir) close() { _ = unix.Close(d.fd) }
-func (d *recordDir) read(host string) (record, error) {
- if !controlname.ValidHostShape(host) {
- return record{}, ErrInvalidHost
+func (d *recordDir) openRecord(host string) (*os.File, error) {
+ if err := validateFileHost(host); err != nil {
+ return nil, err
}
// NONBLOCK prevents a planted FIFO from hanging before Fstat can refuse it.
fd, err := unix.Openat(d.fd, host+".json", unix.O_RDONLY|unix.O_NOFOLLOW|unix.O_CLOEXEC|unix.O_NONBLOCK, 0)
if errors.Is(err, unix.ENOENT) {
- return record{}, ErrNoRecord
+ return nil, ErrNoRecord
}
if err != nil {
- return record{}, err
+ return nil, err
}
f := os.NewFile(uintptr(fd), host+".json")
- defer f.Close()
var st unix.Stat_t
if err := unix.Fstat(fd, &st); err != nil {
- return record{}, err
+ f.Close()
+ return nil, err
}
if st.Mode&unix.S_IFMT != unix.S_IFREG {
- return record{}, ErrUnsafeFile
+ f.Close()
+ return nil, ErrUnsafeFile
}
+ return f, nil
+}
+
+func (d *recordDir) read(host string) (record, error) {
+ f, err := d.openRecord(host)
+ if err != nil {
+ return record{}, err
+ }
+ defer f.Close()
b, err := io.ReadAll(f)
if err != nil {
return record{}, err
@@ -76,15 +88,30 @@ func (d *recordDir) read(host string) (record, error) {
return decodeRecord(b, host)
}
+func (d *recordDir) sync(host string) error {
+ f, err := d.openRecord(host)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ if err := d.fsync(int(f.Fd())); err != nil {
+ return err
+ }
+ return d.fsync(d.fd)
+}
+
func (d *recordDir) write(r record) error {
var nonce [16]byte
if _, err := rand.Read(nonce[:]); err != nil {
return err
}
- return d.writeUsing(r, "."+r.Host+"-"+hex.EncodeToString(nonce[:]), unix.Renameat)
+ return d.writeUsing(r, ".tmp-"+hex.EncodeToString(nonce[:]), unix.Renameat)
}
func (d *recordDir) writeUsing(r record, tmp string, rename func(int, string, int, string) error) error {
+ if err := validateFileHost(r.Host); err != nil {
+ return err
+ }
if err := r.validate(r.Host); err != nil {
return err
}
@@ -114,7 +141,7 @@ func (d *recordDir) writeUsing(r record, tmp string, rename func(int, string, in
if _, err := f.Write(append(b, '\n')); err != nil {
return err
}
- if err := unix.Fsync(fd); err != nil {
+ if err := d.fsync(fd); err != nil {
return err
}
if err := f.Close(); err != nil {
@@ -123,5 +150,5 @@ func (d *recordDir) writeUsing(r record, tmp string, rename func(int, string, in
if err := rename(d.fd, tmp, d.fd, r.Host+".json"); err != nil {
return err
}
- return unix.Fsync(d.fd)
+ return d.fsync(d.fd)
}
diff --git a/internal/stopevidence/evidence_linux_test.go b/internal/stopevidence/evidence_linux_test.go
index c80347c28..dbe3b585d 100644
--- a/internal/stopevidence/evidence_linux_test.go
+++ b/internal/stopevidence/evidence_linux_test.go
@@ -160,7 +160,7 @@ func TestCollectRequiresEveryCondition(t *testing.T) {
func addHolder(t *testing.T, in *Inputs, suffix string) {
t.Helper()
- dir := filepath.Join(in.ProcRoot, "55", "fd")
+ dir := filepath.Join(in.ProcRoot, "55", "task", "55", "fd")
if err := os.MkdirAll(dir, 0700); err != nil {
t.Fatal(err)
}
diff --git a/internal/stopevidence/fds_linux.go b/internal/stopevidence/fds_linux.go
index 3313d73d9..ce7df03fd 100644
--- a/internal/stopevidence/fds_linux.go
+++ b/internal/stopevidence/fds_linux.go
@@ -11,6 +11,7 @@ import (
"sort"
"strconv"
"strings"
+ "syscall"
)
type fdOps struct {
@@ -53,32 +54,42 @@ func fdHolders(ctx context.Context, procRoot string, paths []string, ops fdOps)
continue
}
pidDir := filepath.Join(procRoot, entry.Name())
- fds, err := ops.readDir(filepath.Join(pidDir, "fd"))
+ tasks, err := readProcDir(filepath.Join(pidDir, "task"), pidDir, ops)
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 {
+ for _, task := range tasks {
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) {
+ tid, err := strconv.Atoi(task.Name())
+ if err != nil || tid <= 0 {
continue
}
+ taskDir := filepath.Join(pidDir, "task", task.Name())
+ fdDir := filepath.Join(taskDir, "fd")
+ fds, err := readProcDir(fdDir, taskDir, ops)
if err != nil {
- return nil, fmt.Errorf("%w: pid %d fd %s: %w", ErrIncomplete, pid, fd.Name(), err)
+ return nil, fmt.Errorf("%w: pid %d tid %d: %w", ErrIncomplete, pid, tid, 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
+ for _, fd := range fds {
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+ target, err := ops.readlink(filepath.Join(fdDir, fd.Name()))
+ if procGone(err) {
+ continue
+ }
+ if err != nil {
+ return nil, fmt.Errorf("%w: pid %d tid %d fd %s: %w", ErrIncomplete, pid, tid, fd.Name(), err)
+ }
+ if !filepath.IsAbs(target) {
+ continue
+ }
+ if wanted[filepath.Clean(target)] || wanted[filepath.Clean(strings.TrimSuffix(target, " (deleted)"))] {
+ found = true
+ }
}
}
if found {
@@ -95,3 +106,19 @@ func fdHolders(ctx context.Context, procRoot string, paths []string, ops fdOps)
func scanFDs(ctx context.Context, root string, paths []string) ([]int, error) {
return fdHolders(ctx, root, paths, realFDOps())
}
+
+func procGone(err error) bool {
+ return errors.Is(err, os.ErrNotExist) || errors.Is(err, syscall.ESRCH)
+}
+
+func readProcDir(path, owner string, ops fdOps) ([]os.DirEntry, error) {
+ entries, err := ops.readDir(path)
+ if procGone(err) {
+ if _, ownerErr := ops.stat(owner); procGone(ownerErr) {
+ return nil, nil
+ } else if ownerErr != nil {
+ return nil, ownerErr
+ }
+ }
+ return entries, err
+}
diff --git a/internal/stopevidence/fds_linux_test.go b/internal/stopevidence/fds_linux_test.go
index 0d1d8d898..b113c98c2 100644
--- a/internal/stopevidence/fds_linux_test.go
+++ b/internal/stopevidence/fds_linux_test.go
@@ -8,9 +8,12 @@ import (
"os"
"path/filepath"
"reflect"
+ "runtime"
"strconv"
"syscall"
"testing"
+
+ "golang.org/x/sys/unix"
)
func procFixture(t *testing.T) (string, string) {
@@ -21,7 +24,7 @@ func procFixture(t *testing.T) (string, string) {
"3": {db + " (deleted)"},
"90": {db + ".other"},
} {
- dir := filepath.Join(root, pid, "fd")
+ dir := filepath.Join(root, pid, "task", pid, "fd")
if err := os.MkdirAll(dir, 0700); err != nil {
t.Fatal(err)
}
@@ -58,13 +61,13 @@ func TestFDScanRefusesIncompleteReads(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")) {
+ if (where == "root" && p == root) || (where == "fd-directory" && p == filepath.Join(root, "20", "task", "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") {
+ if where == "readlink" && p == filepath.Join(root, "20", "task", "20", "fd", "0") {
return "", syscall.EACCES
}
return readlink(p)
@@ -78,7 +81,7 @@ func TestFDScanRefusesIncompleteReads(t *testing.T) {
ops := realFDOps()
readDir := ops.readDir
ops.readDir = func(p string) ([]os.DirEntry, error) {
- if p == filepath.Join(root, "20", "fd") {
+ if p == filepath.Join(root, "20", "task", "20", "fd") {
return nil, os.ErrNotExist
}
return readDir(p)
@@ -88,7 +91,7 @@ func TestFDScanRefusesIncompleteReads(t *testing.T) {
}
stat := ops.stat
ops.stat = func(p string) (os.FileInfo, error) {
- if p == filepath.Join(root, "20") {
+ if p == filepath.Join(root, "20", "task", "20") {
return nil, os.ErrNotExist
}
return stat(p)
@@ -100,7 +103,7 @@ func TestFDScanRefusesIncompleteReads(t *testing.T) {
ops = realFDOps()
readlink := ops.readlink
ops.readlink = func(p string) (string, error) {
- if p == filepath.Join(root, "20", "fd", "0") {
+ if p == filepath.Join(root, "20", "task", "20", "fd", "0") {
return "", os.ErrNotExist
}
return readlink(p)
@@ -147,3 +150,113 @@ func TestFDHoldersRealProc(t *testing.T) {
}
t.Fatal("real /proc scan missed own descriptor", got)
}
+
+func TestFDHoldersTaskTables(t *testing.T) {
+ root, db := procFixture(t)
+ for _, tid := range []string{"91", "92"} {
+ dir := filepath.Join(root, "90", "task", tid, "fd")
+ if err := os.MkdirAll(dir, 0700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(db, filepath.Join(dir, "4")); err != nil {
+ t.Fatal(err)
+ }
+ }
+ got, err := FDHolders(root, []string{db})
+ if err != nil || !reflect.DeepEqual(got, []int{3, 20, 90}) {
+ t.Fatalf("task-only holders: %v %v", got, err)
+ }
+}
+
+func TestFDTaskScanErrors(t *testing.T) {
+ root, db := procFixture(t)
+ taskDir := filepath.Join(root, "90", "task", "91")
+ fdDir := filepath.Join(taskDir, "fd")
+ if err := os.MkdirAll(fdDir, 0700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(db, filepath.Join(fdDir, "4")); err != nil {
+ t.Fatal(err)
+ }
+ for _, path := range []string{filepath.Join(root, "90", "task"), fdDir, filepath.Join(fdDir, "4")} {
+ for _, failure := range []error{syscall.EACCES, syscall.ENOENT, syscall.ESRCH} {
+ t.Run(filepath.Base(path)+"/"+failure.Error(), func(t *testing.T) {
+ ops := realFDOps()
+ readDir, readlink, stat := ops.readDir, ops.readlink, ops.stat
+ ops.readDir = func(p string) ([]os.DirEntry, error) {
+ if p == path {
+ return nil, failure
+ }
+ return readDir(p)
+ }
+ ops.readlink = func(p string) (string, error) {
+ if p == path {
+ return "", failure
+ }
+ return readlink(p)
+ }
+ ops.stat = func(p string) (os.FileInfo, error) {
+ if p == taskDir || p == filepath.Join(root, "90") {
+ return nil, failure
+ }
+ return stat(p)
+ }
+ got, err := fdHolders(t.Context(), root, []string{db}, ops)
+ if failure == syscall.EACCES {
+ if got != nil || !errors.Is(err, ErrIncomplete) || !errors.Is(err, failure) {
+ t.Fatalf("partial success: %v %v", got, err)
+ }
+ } else if err != nil || !reflect.DeepEqual(got, []int{3, 20}) {
+ t.Fatalf("vanished task: %v %v", got, err)
+ }
+ })
+ }
+ }
+}
+
+func TestFDHoldersUnsharedThread(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "thread.db")
+ ready, release, done := make(chan error), make(chan struct{}), make(chan struct{})
+ go func() {
+ runtime.LockOSThread()
+ defer close(done)
+ if err := unix.Unshare(unix.CLONE_FILES); err != nil {
+ ready <- err
+ return
+ }
+ fd, err := unix.Open(path, unix.O_RDWR|unix.O_CREAT|unix.O_CLOEXEC, 0600)
+ if err != nil {
+ ready <- err
+ return
+ }
+ defer unix.Close(fd)
+ ready <- nil
+ <-release
+ }()
+ defer func() { close(release); <-done }()
+ if err := <-ready; err != nil {
+ if errors.Is(err, unix.EPERM) || errors.Is(err, unix.EACCES) || errors.Is(err, unix.ENOSYS) {
+ t.Skipf("unshare unavailable: %v", err)
+ }
+ t.Fatal(err)
+ }
+ pid := strconv.Itoa(os.Getpid())
+ entries, err := os.ReadDir(filepath.Join("/proc", pid, "fd"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, entry := range entries {
+ target, err := os.Readlink(filepath.Join("/proc", pid, "fd", entry.Name()))
+ if err == nil && target == path {
+ t.Fatal("private descriptor visible in leader table")
+ }
+ }
+ root := t.TempDir()
+ if err := os.Symlink(filepath.Join("/proc", pid), filepath.Join(root, pid)); err != nil {
+ t.Fatal(err)
+ }
+ got, err := FDHolders(root, []string{path})
+ if err != nil || !reflect.DeepEqual(got, []int{os.Getpid()}) {
+ t.Fatalf("unshared thread holder: %v %v", got, err)
+ }
+}