detached 8e85801

refactor(peerexe): move the pidfd peer exe proof into a shared adapter library

commit
8e85801c6bcf58a347d212394260701b9ff03a2c
author
brice · · 2026-10-14 18:45 UTC
committer
brice · · 2026-10-14 21:39 UTC
parent
fa9d828
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +242 -132
M Makefile +1 -1
@@ -61,7 +61,7 @@ arch: adapter-imports
6161 # Adapters stay independent: production code may import only the shared
6262 # adapter libraries below. outputkit holds what both output backends (drm,
6363 # headless) run; the others are leaf helpers.
64-ADAPTER_LIBS := fdwait|busretry|outputkit/capture|outputkit/corewake|outputkit/presented|outputkit/surfaces|captureallow|clock|logging|sessionsecurity|syncfile|workspaceid|xkb
64+ADAPTER_LIBS := fdwait|busretry|outputkit/capture|outputkit/corewake|outputkit/presented|outputkit/surfaces|captureallow|clock|logging|peerexe|sessionsecurity|syncfile|workspaceid|xkb
6565 adapter-imports:
6666 @edges=$$(CGO_ENABLED=0 go list -f '{{$$p := .ImportPath}}{{range .Imports}}{{$$p}} {{.}}{{"\n"}}{{end}}' ./internal/adapters/...) || exit 1; \
6767 bad=$$(echo "$$edges" | grep -E ' \S+/internal/adapters/' | grep -vE '/internal/adapters/($(ADAPTER_LIBS))$$' \
@@ -61,7 +61,7 @@ arch: adapter-imports
61 # Adapters stay independent: production code may import only the shared61 # Adapters stay independent: production code may import only the shared
62 # adapter libraries below. outputkit holds what both output backends (drm,62 # adapter libraries below. outputkit holds what both output backends (drm,
63 # headless) run; the others are leaf helpers.63 # headless) run; the others are leaf helpers.
64-ADAPTER_LIBS := fdwait|busretry|outputkit/capture|outputkit/corewake|outputkit/presented|outputkit/surfaces|captureallow|clock|logging|sessionsecurity|syncfile|workspaceid|xkb64+ADAPTER_LIBS := fdwait|busretry|outputkit/capture|outputkit/corewake|outputkit/presented|outputkit/surfaces|captureallow|clock|logging|peerexe|sessionsecurity|syncfile|workspaceid|xkb
65 adapter-imports:65 adapter-imports:
66 @edges=$$(CGO_ENABLED=0 go list -f '{{$$p := .ImportPath}}{{range .Imports}}{{$$p}} {{.}}{{"\n"}}{{end}}' ./internal/adapters/...) || exit 1; \66 @edges=$$(CGO_ENABLED=0 go list -f '{{$$p := .ImportPath}}{{range .Imports}}{{$$p}} {{.}}{{"\n"}}{{end}}' ./internal/adapters/...) || exit 1; \
67 bad=$$(echo "$$edges" | grep -E ' \S+/internal/adapters/' | grep -vE '/internal/adapters/($(ADAPTER_LIBS))$$' \67 bad=$$(echo "$$edges" | grep -E ' \S+/internal/adapters/' | grep -vE '/internal/adapters/($(ADAPTER_LIBS))$$' \
A internal/adapters/peerexe/peerexe.go +110 -0
@@ -0,0 +1,110 @@
1+// Package peerexe names the executable behind a process through a pidfd.
2+//
3+// A pidfd pins its process: the pid cannot be recycled while the descriptor
4+// is open. ExeOfPidfd reads the executable through that pin and proves the
5+// process still existed after the read, so the answer is never the exe of a
6+// process that took over a recycled pid. There is no fallback to a bare pid,
7+// which has no such proof.
8+package peerexe
9+
10+import (
11+ "bufio"
12+ "errors"
13+ "fmt"
14+ "os"
15+ "strconv"
16+ "strings"
17+
18+ "golang.org/x/sys/unix"
19+)
20+
21+var (
22+ // ErrExeDeleted: the executable of the process was removed.
23+ ErrExeDeleted = errors.New("executable deleted")
24+ // ErrNoPidfd is a kernel without SO_PEERPIDFD (before Linux 6.5): the
25+ // peer cannot be identified safely.
26+ ErrNoPidfd = errors.New("kernel has no SO_PEERPIDFD")
27+)
28+
29+// Pidfd returns a pidfd of the peer of the connected unix socket sockFD
30+// (SO_PEERPIDFD). The caller closes it. ErrNoPidfd means the kernel cannot
31+// give one.
32+func Pidfd(sockFD int) (*os.File, error) {
33+ pidfd, err := unix.GetsockoptInt(sockFD, unix.SOL_SOCKET, unix.SO_PEERPIDFD)
34+ switch {
35+ case err == nil:
36+ return os.NewFile(uintptr(pidfd), "pidfd"), nil
37+ case errors.Is(err, unix.ENOPROTOOPT) || errors.Is(err, unix.EINVAL) || errors.Is(err, unix.ENOSYS) || errors.Is(err, unix.EOPNOTSUPP):
38+ return nil, ErrNoPidfd
39+ default:
40+ return nil, fmt.Errorf("SO_PEERPIDFD: %w", err)
41+ }
42+}
43+
44+// Open returns a pidfd of pid (pidfd_open). The caller closes it. A pid
45+// learned from a source that can be outrun by recycling (a D-Bus
46+// GetConnectionUnixProcessID reply) must be confirmed against the source
47+// again after Open, so the pidfd names the same process.
48+func Open(pid int) (*os.File, error) {
49+ fd, err := unix.PidfdOpen(pid, 0)
50+ if err != nil {
51+ return nil, fmt.Errorf("pidfd_open(%d): %w", pid, err)
52+ }
53+ return os.NewFile(uintptr(fd), "pidfd"), nil
54+}
55+
56+// ExeOfPidfd returns the executable and the pid of the process behind pidfd:
57+//
58+// 1. The pid is read from /proc/self/fdinfo/<pidfd>.
59+// 2. /proc/<pid>/exe is read.
60+// 3. pidfd_send_signal(pidfd, 0) then proves the process still exists. If it
61+// had exited before the read, the pid could have named someone else, and
62+// the answer is refused.
63+//
64+// A deleted executable is ErrExeDeleted. The caller keeps pidfd open (and
65+// alive, runtime.KeepAlive if it holds an *os.File) during the call.
66+func ExeOfPidfd(pidfd int) (string, int, error) {
67+ pid, err := pidOfPidfd(pidfd)
68+ if err != nil {
69+ return "", 0, err
70+ }
71+ exe, err := readExe(pid)
72+ // After the read: a process that is gone may have let its pid be reused.
73+ if alive := unix.PidfdSendSignal(pidfd, 0, nil, 0); alive != nil {
74+ return "", pid, fmt.Errorf("peer exited: %w", alive)
75+ }
76+ return exe, pid, err
77+}
78+
79+// pidOfPidfd reads the Pid: line of /proc/self/fdinfo/<pidfd>: -1 once the
80+// process has exited, 0 when it is outside this pid namespace.
81+func pidOfPidfd(pidfd int) (int, error) {
82+ f, err := os.Open("/proc/self/fdinfo/" + strconv.Itoa(pidfd))
83+ if err != nil {
84+ return 0, err
85+ }
86+ defer f.Close()
87+ sc := bufio.NewScanner(f)
88+ for sc.Scan() {
89+ if v, ok := strings.CutPrefix(sc.Text(), "Pid:"); ok {
90+ pid, err := strconv.Atoi(strings.TrimSpace(v))
91+ if err != nil || pid <= 0 {
92+ return 0, fmt.Errorf("peer pid not visible (%q)", strings.TrimSpace(v))
93+ }
94+ return pid, nil
95+ }
96+ }
97+ return 0, errors.New("pidfd fdinfo has no Pid")
98+}
99+
100+// readExe reads /proc/<pid>/exe and refuses a deleted executable.
101+func readExe(pid int) (string, error) {
102+ exe, err := os.Readlink("/proc/" + strconv.Itoa(pid) + "/exe")
103+ if err != nil {
104+ return "", err
105+ }
106+ if strings.HasSuffix(exe, " (deleted)") {
107+ return "", ErrExeDeleted
108+ }
109+ return exe, nil
110+}
@@ -0,0 +1,110 @@
1+// Package peerexe names the executable behind a process through a pidfd.
2+//
3+// A pidfd pins its process: the pid cannot be recycled while the descriptor
4+// is open. ExeOfPidfd reads the executable through that pin and proves the
5+// process still existed after the read, so the answer is never the exe of a
6+// process that took over a recycled pid. There is no fallback to a bare pid,
7+// which has no such proof.
8+package peerexe
9+
10+import (
11+ "bufio"
12+ "errors"
13+ "fmt"
14+ "os"
15+ "strconv"
16+ "strings"
17+
18+ "golang.org/x/sys/unix"
19+)
20+
21+var (
22+ // ErrExeDeleted: the executable of the process was removed.
23+ ErrExeDeleted = errors.New("executable deleted")
24+ // ErrNoPidfd is a kernel without SO_PEERPIDFD (before Linux 6.5): the
25+ // peer cannot be identified safely.
26+ ErrNoPidfd = errors.New("kernel has no SO_PEERPIDFD")
27+)
28+
29+// Pidfd returns a pidfd of the peer of the connected unix socket sockFD
30+// (SO_PEERPIDFD). The caller closes it. ErrNoPidfd means the kernel cannot
31+// give one.
32+func Pidfd(sockFD int) (*os.File, error) {
33+ pidfd, err := unix.GetsockoptInt(sockFD, unix.SOL_SOCKET, unix.SO_PEERPIDFD)
34+ switch {
35+ case err == nil:
36+ return os.NewFile(uintptr(pidfd), "pidfd"), nil
37+ case errors.Is(err, unix.ENOPROTOOPT) || errors.Is(err, unix.EINVAL) || errors.Is(err, unix.ENOSYS) || errors.Is(err, unix.EOPNOTSUPP):
38+ return nil, ErrNoPidfd
39+ default:
40+ return nil, fmt.Errorf("SO_PEERPIDFD: %w", err)
41+ }
42+}
43+
44+// Open returns a pidfd of pid (pidfd_open). The caller closes it. A pid
45+// learned from a source that can be outrun by recycling (a D-Bus
46+// GetConnectionUnixProcessID reply) must be confirmed against the source
47+// again after Open, so the pidfd names the same process.
48+func Open(pid int) (*os.File, error) {
49+ fd, err := unix.PidfdOpen(pid, 0)
50+ if err != nil {
51+ return nil, fmt.Errorf("pidfd_open(%d): %w", pid, err)
52+ }
53+ return os.NewFile(uintptr(fd), "pidfd"), nil
54+}
55+
56+// ExeOfPidfd returns the executable and the pid of the process behind pidfd:
57+//
58+// 1. The pid is read from /proc/self/fdinfo/<pidfd>.
59+// 2. /proc/<pid>/exe is read.
60+// 3. pidfd_send_signal(pidfd, 0) then proves the process still exists. If it
61+// had exited before the read, the pid could have named someone else, and
62+// the answer is refused.
63+//
64+// A deleted executable is ErrExeDeleted. The caller keeps pidfd open (and
65+// alive, runtime.KeepAlive if it holds an *os.File) during the call.
66+func ExeOfPidfd(pidfd int) (string, int, error) {
67+ pid, err := pidOfPidfd(pidfd)
68+ if err != nil {
69+ return "", 0, err
70+ }
71+ exe, err := readExe(pid)
72+ // After the read: a process that is gone may have let its pid be reused.
73+ if alive := unix.PidfdSendSignal(pidfd, 0, nil, 0); alive != nil {
74+ return "", pid, fmt.Errorf("peer exited: %w", alive)
75+ }
76+ return exe, pid, err
77+}
78+
79+// pidOfPidfd reads the Pid: line of /proc/self/fdinfo/<pidfd>: -1 once the
80+// process has exited, 0 when it is outside this pid namespace.
81+func pidOfPidfd(pidfd int) (int, error) {
82+ f, err := os.Open("/proc/self/fdinfo/" + strconv.Itoa(pidfd))
83+ if err != nil {
84+ return 0, err
85+ }
86+ defer f.Close()
87+ sc := bufio.NewScanner(f)
88+ for sc.Scan() {
89+ if v, ok := strings.CutPrefix(sc.Text(), "Pid:"); ok {
90+ pid, err := strconv.Atoi(strings.TrimSpace(v))
91+ if err != nil || pid <= 0 {
92+ return 0, fmt.Errorf("peer pid not visible (%q)", strings.TrimSpace(v))
93+ }
94+ return pid, nil
95+ }
96+ }
97+ return 0, errors.New("pidfd fdinfo has no Pid")
98+}
99+
100+// readExe reads /proc/<pid>/exe and refuses a deleted executable.
101+func readExe(pid int) (string, error) {
102+ exe, err := os.Readlink("/proc/" + strconv.Itoa(pid) + "/exe")
103+ if err != nil {
104+ return "", err
105+ }
106+ if strings.HasSuffix(exe, " (deleted)") {
107+ return "", ErrExeDeleted
108+ }
109+ return exe, nil
110+}
A internal/adapters/peerexe/peerexe_test.go +115 -0
@@ -0,0 +1,115 @@
1+package peerexe
2+
3+import (
4+ "errors"
5+ "os"
6+ "path/filepath"
7+ "runtime"
8+ "testing"
9+ "time"
10+
11+ "golang.org/x/sys/unix"
12+)
13+
14+// The real lookup, against a socket whose peer is this process.
15+func TestPidfdSelf(t *testing.T) {
16+ self, err := os.Executable()
17+ if err != nil {
18+ t.Fatal(err)
19+ }
20+ fds, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0)
21+ if err != nil {
22+ t.Fatal(err)
23+ }
24+ defer unix.Close(fds[0])
25+ defer unix.Close(fds[1])
26+ pidfd, err := Pidfd(fds[0])
27+ if errors.Is(err, ErrNoPidfd) {
28+ t.Skip("kernel without SO_PEERPIDFD")
29+ }
30+ if err != nil {
31+ t.Fatal(err)
32+ }
33+ defer pidfd.Close()
34+ exe, pid, err := ExeOfPidfd(int(pidfd.Fd()))
35+ runtime.KeepAlive(pidfd)
36+ if err != nil {
37+ t.Fatal(err)
38+ }
39+ if exe != self || pid != os.Getpid() {
40+ t.Fatalf("ExeOfPidfd = %q, %d; want %q, %d", exe, pid, self, os.Getpid())
41+ }
42+}
43+
44+// Open of the own pid names the own executable.
45+func TestOpenSelf(t *testing.T) {
46+ self, err := os.Executable()
47+ if err != nil {
48+ t.Fatal(err)
49+ }
50+ pidfd, err := Open(os.Getpid())
51+ if err != nil {
52+ t.Skip("pidfd_open: ", err)
53+ }
54+ defer pidfd.Close()
55+ exe, pid, err := ExeOfPidfd(int(pidfd.Fd()))
56+ runtime.KeepAlive(pidfd)
57+ if err != nil {
58+ t.Fatal(err)
59+ }
60+ if exe != self || pid != os.Getpid() {
61+ t.Fatalf("ExeOfPidfd = %q, %d; want %q, %d", exe, pid, self, os.Getpid())
62+ }
63+}
64+
65+func TestOpenMissingProcess(t *testing.T) {
66+ // Pids above the kernel's pid_max do not exist.
67+ if f, err := Open(1 << 30); err == nil {
68+ f.Close()
69+ t.Fatal("Open of a missing pid succeeded")
70+ }
71+}
72+
73+// A process whose binary was removed is refused, and a peer that already
74+// exited is never resolved from its (possibly recycled) pid.
75+func TestExeOfPidfdDeletedAndExited(t *testing.T) {
76+ dir := t.TempDir()
77+ path := filepath.Join(dir, "child")
78+ src, err := os.ReadFile("/bin/sleep")
79+ if err != nil {
80+ t.Skip("no /bin/sleep")
81+ }
82+ if err := os.WriteFile(path, src, 0o755); err != nil {
83+ t.Fatal(err)
84+ }
85+ proc, err := os.StartProcess(path, []string{path, "30"}, &os.ProcAttr{})
86+ if err != nil {
87+ t.Skip("cannot run a copy of sleep: ", err)
88+ }
89+ defer func() { _ = proc.Kill(); _, _ = proc.Wait() }()
90+ _ = os.Remove(path)
91+ pidfd, err := unix.PidfdOpen(proc.Pid, 0)
92+ if err != nil {
93+ t.Skip("pidfd_open: ", err)
94+ }
95+ defer unix.Close(pidfd)
96+ // Wait until exec has completed so /proc/<pid>/exe is the copy.
97+ deadline := time.Now().Add(2 * time.Second)
98+ for {
99+ if _, _, err := ExeOfPidfd(pidfd); errors.Is(err, ErrExeDeleted) {
100+ break
101+ }
102+ if time.Now().After(deadline) {
103+ t.Fatal("deleted executable not refused")
104+ }
105+ time.Sleep(5 * time.Millisecond)
106+ }
107+ if _, err := readExe(proc.Pid); !errors.Is(err, ErrExeDeleted) {
108+ t.Fatalf("readExe: %v", err)
109+ }
110+ _ = proc.Kill()
111+ _, _ = proc.Wait()
112+ if exe, _, err := ExeOfPidfd(pidfd); err == nil {
113+ t.Fatalf("exited peer resolved to %q", exe)
114+ }
115+}
@@ -0,0 +1,115 @@
1+package peerexe
2+
3+import (
4+ "errors"
5+ "os"
6+ "path/filepath"
7+ "runtime"
8+ "testing"
9+ "time"
10+
11+ "golang.org/x/sys/unix"
12+)
13+
14+// The real lookup, against a socket whose peer is this process.
15+func TestPidfdSelf(t *testing.T) {
16+ self, err := os.Executable()
17+ if err != nil {
18+ t.Fatal(err)
19+ }
20+ fds, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0)
21+ if err != nil {
22+ t.Fatal(err)
23+ }
24+ defer unix.Close(fds[0])
25+ defer unix.Close(fds[1])
26+ pidfd, err := Pidfd(fds[0])
27+ if errors.Is(err, ErrNoPidfd) {
28+ t.Skip("kernel without SO_PEERPIDFD")
29+ }
30+ if err != nil {
31+ t.Fatal(err)
32+ }
33+ defer pidfd.Close()
34+ exe, pid, err := ExeOfPidfd(int(pidfd.Fd()))
35+ runtime.KeepAlive(pidfd)
36+ if err != nil {
37+ t.Fatal(err)
38+ }
39+ if exe != self || pid != os.Getpid() {
40+ t.Fatalf("ExeOfPidfd = %q, %d; want %q, %d", exe, pid, self, os.Getpid())
41+ }
42+}
43+
44+// Open of the own pid names the own executable.
45+func TestOpenSelf(t *testing.T) {
46+ self, err := os.Executable()
47+ if err != nil {
48+ t.Fatal(err)
49+ }
50+ pidfd, err := Open(os.Getpid())
51+ if err != nil {
52+ t.Skip("pidfd_open: ", err)
53+ }
54+ defer pidfd.Close()
55+ exe, pid, err := ExeOfPidfd(int(pidfd.Fd()))
56+ runtime.KeepAlive(pidfd)
57+ if err != nil {
58+ t.Fatal(err)
59+ }
60+ if exe != self || pid != os.Getpid() {
61+ t.Fatalf("ExeOfPidfd = %q, %d; want %q, %d", exe, pid, self, os.Getpid())
62+ }
63+}
64+
65+func TestOpenMissingProcess(t *testing.T) {
66+ // Pids above the kernel's pid_max do not exist.
67+ if f, err := Open(1 << 30); err == nil {
68+ f.Close()
69+ t.Fatal("Open of a missing pid succeeded")
70+ }
71+}
72+
73+// A process whose binary was removed is refused, and a peer that already
74+// exited is never resolved from its (possibly recycled) pid.
75+func TestExeOfPidfdDeletedAndExited(t *testing.T) {
76+ dir := t.TempDir()
77+ path := filepath.Join(dir, "child")
78+ src, err := os.ReadFile("/bin/sleep")
79+ if err != nil {
80+ t.Skip("no /bin/sleep")
81+ }
82+ if err := os.WriteFile(path, src, 0o755); err != nil {
83+ t.Fatal(err)
84+ }
85+ proc, err := os.StartProcess(path, []string{path, "30"}, &os.ProcAttr{})
86+ if err != nil {
87+ t.Skip("cannot run a copy of sleep: ", err)
88+ }
89+ defer func() { _ = proc.Kill(); _, _ = proc.Wait() }()
90+ _ = os.Remove(path)
91+ pidfd, err := unix.PidfdOpen(proc.Pid, 0)
92+ if err != nil {
93+ t.Skip("pidfd_open: ", err)
94+ }
95+ defer unix.Close(pidfd)
96+ // Wait until exec has completed so /proc/<pid>/exe is the copy.
97+ deadline := time.Now().Add(2 * time.Second)
98+ for {
99+ if _, _, err := ExeOfPidfd(pidfd); errors.Is(err, ErrExeDeleted) {
100+ break
101+ }
102+ if time.Now().After(deadline) {
103+ t.Fatal("deleted executable not refused")
104+ }
105+ time.Sleep(5 * time.Millisecond)
106+ }
107+ if _, err := readExe(proc.Pid); !errors.Is(err, ErrExeDeleted) {
108+ t.Fatalf("readExe: %v", err)
109+ }
110+ _ = proc.Kill()
111+ _, _ = proc.Wait()
112+ if exe, _, err := ExeOfPidfd(pidfd); err == nil {
113+ t.Fatalf("exited peer resolved to %q", exe)
114+ }
115+}
M internal/adapters/wayland/captureauth.go +2 -1
@@ -5,6 +5,7 @@ import (
55 "os"
66
77 "github.com/bnema/neferwl/internal/adapters/captureallow"
8+ "github.com/bnema/neferwl/internal/adapters/peerexe"
89 "github.com/bnema/purego-libwayland/server"
910 )
1011
@@ -136,7 +137,7 @@ func (s *Server) peerIdentity(c server.Client) *peerID {
136137 id.pidfd, err = s.peer.Pidfd(fd)
137138 if err != nil {
138139 id.pidfd, id.err = nil, err
139- if errors.Is(err, errNoPidfd) && !s.noPidfdWarned {
140+ if errors.Is(err, peerexe.ErrNoPidfd) && !s.noPidfdWarned {
140141 s.noPidfdWarned = true
141142 s.log.Warn().Msg("kernel has no SO_PEERPIDFD (Linux 6.5+): the capture allowlist cannot identify clients, so none may capture; list `*` in the allowlist to disable the check")
142143 }
@@ -5,6 +5,7 @@ import (
5 "os"5 "os"
6 6
7 "github.com/bnema/neferwl/internal/adapters/captureallow"7 "github.com/bnema/neferwl/internal/adapters/captureallow"
8+ "github.com/bnema/neferwl/internal/adapters/peerexe"
8 "github.com/bnema/purego-libwayland/server"9 "github.com/bnema/purego-libwayland/server"
9 )10 )
10 11
@@ -136,7 +137,7 @@ func (s *Server) peerIdentity(c server.Client) *peerID {
136 id.pidfd, err = s.peer.Pidfd(fd)137 id.pidfd, err = s.peer.Pidfd(fd)
137 if err != nil {138 if err != nil {
138 id.pidfd, id.err = nil, err139 id.pidfd, id.err = nil, err
139- if errors.Is(err, errNoPidfd) && !s.noPidfdWarned {140+ if errors.Is(err, peerexe.ErrNoPidfd) && !s.noPidfdWarned {
140 s.noPidfdWarned = true141 s.noPidfdWarned = true
141 s.log.Warn().Msg("kernel has no SO_PEERPIDFD (Linux 6.5+): the capture allowlist cannot identify clients, so none may capture; list `*` in the allowlist to disable the check")142 s.log.Warn().Msg("kernel has no SO_PEERPIDFD (Linux 6.5+): the capture allowlist cannot identify clients, so none may capture; list `*` in the allowlist to disable the check")
142 }143 }
M internal/adapters/wayland/captureauth_test.go +4 -47
@@ -13,6 +13,7 @@ import (
1313 source "github.com/bnema/go-wayland-bindings/server/extimagecapturesource"
1414 ext "github.com/bnema/go-wayland-bindings/server/extimagecopycapture"
1515 "github.com/bnema/neferwl/internal/adapters/captureallow"
16+ "github.com/bnema/neferwl/internal/adapters/peerexe"
1617 "github.com/bnema/neferwl/internal/adapters/wayland/imagecapture"
1718 "github.com/bnema/neferwl/internal/ports"
1819 "github.com/bnema/zerowrap"
@@ -275,7 +276,7 @@ func TestCaptureAllowlistNoPidfdFailsClosed(t *testing.T) {
275276 }
276277
277278 func TestCaptureAllowlistUnresolvedExecutableRefused(t *testing.T) {
278- for name, err := range map[string]error{"deleted": errExeDeleted, "unknown": errors.New("no such process")} {
279+ for name, err := range map[string]error{"deleted": peerexe.ErrExeDeleted, "unknown": errors.New("no such process")} {
279280 t.Run(name, func(t *testing.T) {
280281 peer := newMockpeerExe(t)
281282 pidfd := devNull(t)
@@ -350,7 +351,7 @@ func TestLinuxPeerExeSelf(t *testing.T) {
350351 defer unix.Close(fds[1])
351352 var l linuxPeerExe
352353 pidfd, err := l.Pidfd(fds[0])
353- if errors.Is(err, errNoPidfd) {
354+ if errors.Is(err, peerexe.ErrNoPidfd) {
354355 t.Skip("kernel without SO_PEERPIDFD")
355356 }
356357 if err != nil {
@@ -364,55 +365,11 @@ func TestLinuxPeerExeSelf(t *testing.T) {
364365 if exe != self || pid != os.Getpid() {
365366 t.Fatalf("Exe = %q, %d; want %q, %d", exe, pid, self, os.Getpid())
366367 }
367- if _, err := (linuxPeerExe{noPidfd: true}).Pidfd(fds[0]); !errors.Is(err, errNoPidfd) {
368+ if _, err := (linuxPeerExe{noPidfd: true}).Pidfd(fds[0]); !errors.Is(err, peerexe.ErrNoPidfd) {
368369 t.Fatalf("noPidfd: %v", err)
369370 }
370371 }
371372
372-// A process whose binary was removed is refused, and a peer that already
373-// exited is never resolved from its (possibly recycled) pid.
374-func TestLinuxPeerExeDeletedAndExited(t *testing.T) {
375- dir := t.TempDir()
376- path := filepath.Join(dir, "child")
377- src, err := os.ReadFile("/bin/sleep")
378- if err != nil {
379- t.Skip("no /bin/sleep")
380- }
381- if err := os.WriteFile(path, src, 0o755); err != nil {
382- t.Fatal(err)
383- }
384- proc, err := os.StartProcess(path, []string{path, "30"}, &os.ProcAttr{})
385- if err != nil {
386- t.Skip("cannot run a copy of sleep: ", err)
387- }
388- defer func() { _ = proc.Kill(); _, _ = proc.Wait() }()
389- _ = os.Remove(path)
390- // Wait until exec has completed so /proc/<pid>/exe is the copy.
391- deadline := time.Now().Add(2 * time.Second)
392- for {
393- if _, err := readExe(proc.Pid); errors.Is(err, errExeDeleted) {
394- break
395- }
396- if time.Now().After(deadline) {
397- t.Fatal("deleted executable not refused")
398- }
399- time.Sleep(5 * time.Millisecond)
400- }
401- pidfd, err := unix.PidfdOpen(proc.Pid, 0)
402- if err != nil {
403- t.Skip("pidfd_open: ", err)
404- }
405- defer unix.Close(pidfd)
406- if _, _, err := exeOfPidfd(pidfd); !errors.Is(err, errExeDeleted) {
407- t.Fatalf("deleted: %v", err)
408- }
409- _ = proc.Kill()
410- _, _ = proc.Wait()
411- if exe, _, err := exeOfPidfd(pidfd); err == nil {
412- t.Fatalf("exited peer resolved to %q", exe)
413- }
414-}
415-
416373 // The exclusion attach asks too: a HUD whose executable is not listed gets
417374 // `unauthorized` even with the right token, while the recorder that owns the
418375 // exclusion (listed) is unaffected.
@@ -13,6 +13,7 @@ import (
13 source "github.com/bnema/go-wayland-bindings/server/extimagecapturesource"13 source "github.com/bnema/go-wayland-bindings/server/extimagecapturesource"
14 ext "github.com/bnema/go-wayland-bindings/server/extimagecopycapture"14 ext "github.com/bnema/go-wayland-bindings/server/extimagecopycapture"
15 "github.com/bnema/neferwl/internal/adapters/captureallow"15 "github.com/bnema/neferwl/internal/adapters/captureallow"
16+ "github.com/bnema/neferwl/internal/adapters/peerexe"
16 "github.com/bnema/neferwl/internal/adapters/wayland/imagecapture"17 "github.com/bnema/neferwl/internal/adapters/wayland/imagecapture"
17 "github.com/bnema/neferwl/internal/ports"18 "github.com/bnema/neferwl/internal/ports"
18 "github.com/bnema/zerowrap"19 "github.com/bnema/zerowrap"
@@ -275,7 +276,7 @@ func TestCaptureAllowlistNoPidfdFailsClosed(t *testing.T) {
275 }276 }
276 277
277 func TestCaptureAllowlistUnresolvedExecutableRefused(t *testing.T) {278 func TestCaptureAllowlistUnresolvedExecutableRefused(t *testing.T) {
278- for name, err := range map[string]error{"deleted": errExeDeleted, "unknown": errors.New("no such process")} {279+ for name, err := range map[string]error{"deleted": peerexe.ErrExeDeleted, "unknown": errors.New("no such process")} {
279 t.Run(name, func(t *testing.T) {280 t.Run(name, func(t *testing.T) {
280 peer := newMockpeerExe(t)281 peer := newMockpeerExe(t)
281 pidfd := devNull(t)282 pidfd := devNull(t)
@@ -350,7 +351,7 @@ func TestLinuxPeerExeSelf(t *testing.T) {
350 defer unix.Close(fds[1])351 defer unix.Close(fds[1])
351 var l linuxPeerExe352 var l linuxPeerExe
352 pidfd, err := l.Pidfd(fds[0])353 pidfd, err := l.Pidfd(fds[0])
353- if errors.Is(err, errNoPidfd) {354+ if errors.Is(err, peerexe.ErrNoPidfd) {
354 t.Skip("kernel without SO_PEERPIDFD")355 t.Skip("kernel without SO_PEERPIDFD")
355 }356 }
356 if err != nil {357 if err != nil {
@@ -364,55 +365,11 @@ func TestLinuxPeerExeSelf(t *testing.T) {
364 if exe != self || pid != os.Getpid() {365 if exe != self || pid != os.Getpid() {
365 t.Fatalf("Exe = %q, %d; want %q, %d", exe, pid, self, os.Getpid())366 t.Fatalf("Exe = %q, %d; want %q, %d", exe, pid, self, os.Getpid())
366 }367 }
367- if _, err := (linuxPeerExe{noPidfd: true}).Pidfd(fds[0]); !errors.Is(err, errNoPidfd) {368+ if _, err := (linuxPeerExe{noPidfd: true}).Pidfd(fds[0]); !errors.Is(err, peerexe.ErrNoPidfd) {
368 t.Fatalf("noPidfd: %v", err)369 t.Fatalf("noPidfd: %v", err)
369 }370 }
370 }371 }
371 372
372-// A process whose binary was removed is refused, and a peer that already
373-// exited is never resolved from its (possibly recycled) pid.
374-func TestLinuxPeerExeDeletedAndExited(t *testing.T) {
375- dir := t.TempDir()
376- path := filepath.Join(dir, "child")
377- src, err := os.ReadFile("/bin/sleep")
378- if err != nil {
379- t.Skip("no /bin/sleep")
380- }
381- if err := os.WriteFile(path, src, 0o755); err != nil {
382- t.Fatal(err)
383- }
384- proc, err := os.StartProcess(path, []string{path, "30"}, &os.ProcAttr{})
385- if err != nil {
386- t.Skip("cannot run a copy of sleep: ", err)
387- }
388- defer func() { _ = proc.Kill(); _, _ = proc.Wait() }()
389- _ = os.Remove(path)
390- // Wait until exec has completed so /proc/<pid>/exe is the copy.
391- deadline := time.Now().Add(2 * time.Second)
392- for {
393- if _, err := readExe(proc.Pid); errors.Is(err, errExeDeleted) {
394- break
395- }
396- if time.Now().After(deadline) {
397- t.Fatal("deleted executable not refused")
398- }
399- time.Sleep(5 * time.Millisecond)
400- }
401- pidfd, err := unix.PidfdOpen(proc.Pid, 0)
402- if err != nil {
403- t.Skip("pidfd_open: ", err)
404- }
405- defer unix.Close(pidfd)
406- if _, _, err := exeOfPidfd(pidfd); !errors.Is(err, errExeDeleted) {
407- t.Fatalf("deleted: %v", err)
408- }
409- _ = proc.Kill()
410- _, _ = proc.Wait()
411- if exe, _, err := exeOfPidfd(pidfd); err == nil {
412- t.Fatalf("exited peer resolved to %q", exe)
413- }
414-}
415-
416 // The exclusion attach asks too: a HUD whose executable is not listed gets373 // The exclusion attach asks too: a HUD whose executable is not listed gets
417 // `unauthorized` even with the right token, while the recorder that owns the374 // `unauthorized` even with the right token, while the recorder that owns the
418 // exclusion (listed) is unaffected.375 // exclusion (listed) is unaffected.
M internal/adapters/wayland/captureexe.go +10 -83
@@ -1,15 +1,10 @@
11 package wayland
22
33 import (
4- "bufio"
5- "errors"
6- "fmt"
74 "os"
85 "runtime"
9- "strconv"
10- "strings"
116
12- "golang.org/x/sys/unix"
7+ "github.com/bnema/neferwl/internal/adapters/peerexe"
138 )
149
1510 // peerExe names the executable behind a client connection, in two steps so a
@@ -18,7 +13,8 @@ import (
1813 type peerExe interface {
1914 // Pidfd returns a pidfd of the peer of the connected unix socket sockFD
2015 // (SO_PEERPIDFD). It pins the peer process: its pid cannot be recycled
21- // while the file is open. errNoPidfd means the kernel cannot give one.
16+ // while the file is open. peerexe.ErrNoPidfd means the kernel cannot give
17+ // one.
2218 Pidfd(sockFD int) (*os.File, error)
2319 // Exe returns the path of the executable running as the process behind
2420 // pidfd, and its pid. A deleted executable, or a process that has
@@ -26,24 +22,10 @@ type peerExe interface {
2622 Exe(pidfd *os.File) (exe string, pid int, err error)
2723 }
2824
29-var (
30- errExeDeleted = errors.New("executable deleted")
31- // errNoPidfd is a kernel without SO_PEERPIDFD (before Linux 6.5): the
32- // peer cannot be identified safely.
33- errNoPidfd = errors.New("kernel has no SO_PEERPIDFD")
34-)
35-
36-// linuxPeerExe resolves the peer through a pidfd (SO_PEERPIDFD, Linux 6.5+):
37-//
38-// 1. The pidfd pins the peer process: its pid cannot be recycled while the
39-// descriptor is open. The pid is read from /proc/self/fdinfo/<pidfd>.
40-// 2. /proc/<pid>/exe is read.
41-// 3. pidfd_send_signal(pidfd, 0) then proves the process still exists. If it
42-// had exited before the read, the pid could have named someone else, and
43-// the answer is refused.
44-//
45-// There is no fallback to the SO_PEERCRED pid: it has no such proof, so a
46-// kernel without SO_PEERPIDFD identifies nobody.
25+// linuxPeerExe resolves the peer through a pidfd (SO_PEERPIDFD, Linux 6.5+),
26+// with the proof of peerexe.ExeOfPidfd. There is no fallback to the
27+// SO_PEERCRED pid: it has no such proof, so a kernel without SO_PEERPIDFD
28+// identifies nobody.
4729 type linuxPeerExe struct {
4830 // noPidfd simulates a kernel without SO_PEERPIDFD (tests).
4931 noPidfd bool
@@ -51,67 +33,12 @@ type linuxPeerExe struct {
5133
5234 func (l linuxPeerExe) Pidfd(sockFD int) (*os.File, error) {
5335 if l.noPidfd {
54- return nil, errNoPidfd
55- }
56- pidfd, err := unix.GetsockoptInt(sockFD, unix.SOL_SOCKET, unix.SO_PEERPIDFD)
57- switch {
58- case err == nil:
59- return os.NewFile(uintptr(pidfd), "pidfd"), nil
60- case errors.Is(err, unix.ENOPROTOOPT) || errors.Is(err, unix.EINVAL) || errors.Is(err, unix.ENOSYS) || errors.Is(err, unix.EOPNOTSUPP):
61- return nil, errNoPidfd
62- default:
63- return nil, fmt.Errorf("SO_PEERPIDFD: %w", err)
36+ return nil, peerexe.ErrNoPidfd
6437 }
38+ return peerexe.Pidfd(sockFD)
6539 }
6640
6741 func (linuxPeerExe) Exe(pidfd *os.File) (string, int, error) {
6842 defer runtime.KeepAlive(pidfd)
69- return exeOfPidfd(int(pidfd.Fd()))
70-}
71-
72-// exeOfPidfd runs the pidfd procedure above.
73-func exeOfPidfd(pidfd int) (string, int, error) {
74- pid, err := pidOfPidfd(pidfd)
75- if err != nil {
76- return "", 0, err
77- }
78- exe, err := readExe(pid)
79- // After the read: a process that is gone may have let its pid be reused.
80- if alive := unix.PidfdSendSignal(pidfd, 0, nil, 0); alive != nil {
81- return "", pid, fmt.Errorf("peer exited: %w", alive)
82- }
83- return exe, pid, err
84-}
85-
86-// pidOfPidfd reads the Pid: line of /proc/self/fdinfo/<pidfd>: -1 once the
87-// process has exited, 0 when it is outside this pid namespace.
88-func pidOfPidfd(pidfd int) (int, error) {
89- f, err := os.Open("/proc/self/fdinfo/" + strconv.Itoa(pidfd))
90- if err != nil {
91- return 0, err
92- }
93- defer f.Close()
94- sc := bufio.NewScanner(f)
95- for sc.Scan() {
96- if v, ok := strings.CutPrefix(sc.Text(), "Pid:"); ok {
97- pid, err := strconv.Atoi(strings.TrimSpace(v))
98- if err != nil || pid <= 0 {
99- return 0, fmt.Errorf("peer pid not visible (%q)", strings.TrimSpace(v))
100- }
101- return pid, nil
102- }
103- }
104- return 0, errors.New("pidfd fdinfo has no Pid")
105-}
106-
107-// readExe reads /proc/<pid>/exe and refuses a deleted executable.
108-func readExe(pid int) (string, error) {
109- exe, err := os.Readlink("/proc/" + strconv.Itoa(pid) + "/exe")
110- if err != nil {
111- return "", err
112- }
113- if strings.HasSuffix(exe, " (deleted)") {
114- return "", errExeDeleted
115- }
116- return exe, nil
43+ return peerexe.ExeOfPidfd(int(pidfd.Fd()))
11744 }
@@ -1,15 +1,10 @@
1 package wayland1 package wayland
2 2
3 import (3 import (
4- "bufio"
5- "errors"
6- "fmt"
7 "os"4 "os"
8 "runtime"5 "runtime"
9- "strconv"
10- "strings"
11 6
12- "golang.org/x/sys/unix"7+ "github.com/bnema/neferwl/internal/adapters/peerexe"
13 )8 )
14 9
15 // peerExe names the executable behind a client connection, in two steps so a10 // peerExe names the executable behind a client connection, in two steps so a
@@ -18,7 +13,8 @@ import (
18 type peerExe interface {13 type peerExe interface {
19 // Pidfd returns a pidfd of the peer of the connected unix socket sockFD14 // Pidfd returns a pidfd of the peer of the connected unix socket sockFD
20 // (SO_PEERPIDFD). It pins the peer process: its pid cannot be recycled15 // (SO_PEERPIDFD). It pins the peer process: its pid cannot be recycled
21- // while the file is open. errNoPidfd means the kernel cannot give one.16+ // while the file is open. peerexe.ErrNoPidfd means the kernel cannot give
17+ // one.
22 Pidfd(sockFD int) (*os.File, error)18 Pidfd(sockFD int) (*os.File, error)
23 // Exe returns the path of the executable running as the process behind19 // Exe returns the path of the executable running as the process behind
24 // pidfd, and its pid. A deleted executable, or a process that has20 // pidfd, and its pid. A deleted executable, or a process that has
@@ -26,24 +22,10 @@ type peerExe interface {
26 Exe(pidfd *os.File) (exe string, pid int, err error)22 Exe(pidfd *os.File) (exe string, pid int, err error)
27 }23 }
28 24
29-var (25+// linuxPeerExe resolves the peer through a pidfd (SO_PEERPIDFD, Linux 6.5+),
30- errExeDeleted = errors.New("executable deleted")26+// with the proof of peerexe.ExeOfPidfd. There is no fallback to the
31- // errNoPidfd is a kernel without SO_PEERPIDFD (before Linux 6.5): the27+// SO_PEERCRED pid: it has no such proof, so a kernel without SO_PEERPIDFD
32- // peer cannot be identified safely.28+// identifies nobody.
33- errNoPidfd = errors.New("kernel has no SO_PEERPIDFD")
34-)
35-
36-// linuxPeerExe resolves the peer through a pidfd (SO_PEERPIDFD, Linux 6.5+):
37-//
38-// 1. The pidfd pins the peer process: its pid cannot be recycled while the
39-// descriptor is open. The pid is read from /proc/self/fdinfo/<pidfd>.
40-// 2. /proc/<pid>/exe is read.
41-// 3. pidfd_send_signal(pidfd, 0) then proves the process still exists. If it
42-// had exited before the read, the pid could have named someone else, and
43-// the answer is refused.
44-//
45-// There is no fallback to the SO_PEERCRED pid: it has no such proof, so a
46-// kernel without SO_PEERPIDFD identifies nobody.
47 type linuxPeerExe struct {29 type linuxPeerExe struct {
48 // noPidfd simulates a kernel without SO_PEERPIDFD (tests).30 // noPidfd simulates a kernel without SO_PEERPIDFD (tests).
49 noPidfd bool31 noPidfd bool
@@ -51,67 +33,12 @@ type linuxPeerExe struct {
51 33
52 func (l linuxPeerExe) Pidfd(sockFD int) (*os.File, error) {34 func (l linuxPeerExe) Pidfd(sockFD int) (*os.File, error) {
53 if l.noPidfd {35 if l.noPidfd {
54- return nil, errNoPidfd36+ return nil, peerexe.ErrNoPidfd
55- }
56- pidfd, err := unix.GetsockoptInt(sockFD, unix.SOL_SOCKET, unix.SO_PEERPIDFD)
57- switch {
58- case err == nil:
59- return os.NewFile(uintptr(pidfd), "pidfd"), nil
60- case errors.Is(err, unix.ENOPROTOOPT) || errors.Is(err, unix.EINVAL) || errors.Is(err, unix.ENOSYS) || errors.Is(err, unix.EOPNOTSUPP):
61- return nil, errNoPidfd
62- default:
63- return nil, fmt.Errorf("SO_PEERPIDFD: %w", err)
64 }37 }
38+ return peerexe.Pidfd(sockFD)
65 }39 }
66 40
67 func (linuxPeerExe) Exe(pidfd *os.File) (string, int, error) {41 func (linuxPeerExe) Exe(pidfd *os.File) (string, int, error) {
68 defer runtime.KeepAlive(pidfd)42 defer runtime.KeepAlive(pidfd)
69- return exeOfPidfd(int(pidfd.Fd()))43+ return peerexe.ExeOfPidfd(int(pidfd.Fd()))
70-}
71-
72-// exeOfPidfd runs the pidfd procedure above.
73-func exeOfPidfd(pidfd int) (string, int, error) {
74- pid, err := pidOfPidfd(pidfd)
75- if err != nil {
76- return "", 0, err
77- }
78- exe, err := readExe(pid)
79- // After the read: a process that is gone may have let its pid be reused.
80- if alive := unix.PidfdSendSignal(pidfd, 0, nil, 0); alive != nil {
81- return "", pid, fmt.Errorf("peer exited: %w", alive)
82- }
83- return exe, pid, err
84-}
85-
86-// pidOfPidfd reads the Pid: line of /proc/self/fdinfo/<pidfd>: -1 once the
87-// process has exited, 0 when it is outside this pid namespace.
88-func pidOfPidfd(pidfd int) (int, error) {
89- f, err := os.Open("/proc/self/fdinfo/" + strconv.Itoa(pidfd))
90- if err != nil {
91- return 0, err
92- }
93- defer f.Close()
94- sc := bufio.NewScanner(f)
95- for sc.Scan() {
96- if v, ok := strings.CutPrefix(sc.Text(), "Pid:"); ok {
97- pid, err := strconv.Atoi(strings.TrimSpace(v))
98- if err != nil || pid <= 0 {
99- return 0, fmt.Errorf("peer pid not visible (%q)", strings.TrimSpace(v))
100- }
101- return pid, nil
102- }
103- }
104- return 0, errors.New("pidfd fdinfo has no Pid")
105-}
106-
107-// readExe reads /proc/<pid>/exe and refuses a deleted executable.
108-func readExe(pid int) (string, error) {
109- exe, err := os.Readlink("/proc/" + strconv.Itoa(pid) + "/exe")
110- if err != nil {
111- return "", err
112- }
113- if strings.HasSuffix(exe, " (deleted)") {
114- return "", errExeDeleted
115- }
116- return exe, nil
117 }44 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 25

Tags 10

↑↓ moveenter openesc close