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>
9 files changed,
+394
−50
internal/hostguard/flock_unix.go+2 −3internal/hostguard/guard.go+13 −9internal/hostguard/guard_test.go+159 −0internal/hostguard/record.go+16 −2internal/hostguard/record_other.go+1 −0internal/hostguard/record_unix.go+41 −14internal/stopevidence/evidence_linux_test.go+1 −1internal/stopevidence/fds_linux.go+42 −15internal/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) |
| + } |
| +} |