code wiki / _hdl_build / nx_scm_rights_gate.nx
nx_scm_rights_gate.nx source
↩ module page · 283 lines · 13703 B
1// nx_scm_rights_gate.nx -- does SCM_RIGHTS descriptor passing ACTUALLY MOVE A DESCRIPTOR?
2//
3// 2026-08-21, /compare/trafficsafety ITEM 2. sys_sendmsg was ABSENT-PROVEN from the whole tree
4// (corpus_complete=1) until today, so SCM_RIGHTS -- the mechanism nginx, HAProxy and Envoy all use
5// to replace a process WITHOUT releasing its listening socket -- could not be written at all.
6//
7// WHY THE TEETH LOOK LIKE THIS. A wrong msghdr or cmsghdr layout DOES NOT FAIL LOUD: sendmsg still
8// returns a positive byte count and simply transfers nothing, and recvmsg still returns a positive
9// byte count having delivered no ancillary data. A gate that read return codes would be GREEN on a
10// shim that moves no descriptor at all. So every proof here is BEHAVIOURAL and runs between two
11// REAL processes: the parent hands a descriptor over, RELEASES ITS OWN COPY, and only then does the
12// child use it in a way the parent can observe from the other side.
13//
14// THE ANTI-VACUITY DESIGN IS THE SUBJECT'S CREATION ORDER. Every descriptor under test is created
15// AFTER the fork, so the child cannot possibly have inherited it -- a child that "succeeded" by
16// using an inherited descriptor number is impossible by construction, not merely unlikely.
17//
18// AND THE ORDERING IS DETERMINISTIC, NOT HOPEFUL. The child waits for a go-byte that the parent
19// writes only AFTER closing its own copy, so "the descriptor outlives the sender letting go of it"
20// is a proven sequence rather than a race the test usually wins.
21//
22// PHASE 2 IS THE TS1 INVARIANT IN MINIATURE: the descriptor passed is a LISTENING SOCKET, the
23// parent closes its copy, and then the parent CONNECTS TO THE PORT. That connect can only succeed
24// if a listener still exists, and the only process holding one is the child that received it. This
25// is the property SO_REUSEPORT co-binding can never demonstrate: not "two processes can bind", but
26// "the socket never stopped existing while its owner changed".
27//
28// TRANSPORT NOTE, MEASURED NOT ASSUMED: the control channel is a NAMED AF_UNIX rendezvous rather
29// than a socketpair, because socketpair returns EFAULT for every input on this host (pinned by
30// controls in nx_scm_probe; the note lives beside sys_socketpair in the shim). Named rendezvous is
31// also what the field actually uses, so nothing is lost by the substitution.
32//
33// NEG-CONTROL: a receiver must REFUSE to invent a descriptor. An ordinary byte with no ancillary
34// data must come back as the NAMED refusal SCM_ERR_NO_CMSG, never as a plausible small integer that
35// a caller would happily use as an fd.
36// expect_exit: 0 license_tier: ORIGINAL No hw writes (Rule 26).
37import "nx_syscalls.nx"
38import "nx_unix_socket.nx"
39import "nx_gate_emit_lib.nx"
40import "nx_gate_verdict.nx"
41
42const SG_NUM_SCRATCH: i64 = 24
43const SG_ASCII_ZERO: i64 = 48
44const SG_B10: i64 = 10
45const SG_ONE_BYTE: i64 = 1
46const SG_BOX_BYTES: i64 = 8
47const SG_SA_IN_BYTES: i64 = 16
48const SG_BYTE_RADIX: i64 = 256
49const SG_BACKLOG: i64 = 4
50// Loopback ONLY. A daemon binding INADDR_ANY is on the LAN the instant it starts, and a gate is no
51// exception: the estate has already published an unauthenticated fetcher that way.
52const SG_LOOPBACK_A: i64 = 127
53const SG_LOOPBACK_D: i64 = 1
54// The port is not chosen, it is FOUND: bind is attempted without SO_REUSEADDR from this base, and
55// the first bind that succeeds proves that port was free. A probe port you did not verify free is
56// not a control, it is a second instance. The base sits outside every port in proxy_routes.conf.
57const SG_PORT_BASE: i64 = 39000
58const SG_PORT_TRIES: i64 = 32
59const SG_PORT_NONE: i64 = 0 - 1
60// Recognisable and DISTINCT, so a mis-ordered exchange cannot pass by reading back its own signal.
61const SG_PROOF_BYTE: i64 = 88
62const SG_GO_BYTE: i64 = 71
63const SG_PING_BYTE: i64 = 80
64const SG_ECHO_BYTE: i64 = 90
65// Child exit codes. Each names WHICH step failed -- a child that just exits 1 sends its reader
66// hunting through eight candidate causes.
67const SG_CHILD_OK: i64 = 0
68const SG_CHILD_CONN_FAIL: i64 = 2
69const SG_CHILD_NEG_FAIL: i64 = 3
70const SG_CHILD_RECV_FAIL: i64 = 4
71const SG_CHILD_GO_FAIL: i64 = 5
72const SG_CHILD_WRITE_FAIL: i64 = 6
73const SG_CHILD_LRECV_FAIL: i64 = 7
74const SG_CHILD_GO2_FAIL: i64 = 8
75const SG_CHILD_ACCEPT_FAIL: i64 = 9
76const SG_CHILD_ECHO_FAIL: i64 = 10
77// The whole exchange is local IPC and loopback TCP; it completes in milliseconds. The bound exists
78// ONLY so a broken transfer fails LOUD instead of hanging a gate forever, and it reuses the shim's
79// own established socket deadline rather than inventing a second budget for the same kind of wait.
80const SG_DEADLINE_S: i64 = ACCEPT_TMO_S
81// The child's hard watchdog is the same deadline doubled: it must outlast every bounded wait the
82// child performs (the rendezvous read and the accept) so it fires only on a genuine wedge.
83const SG_CHILD_ALARM_S: i64 = ACCEPT_TMO_S * 2
84// Runtime scratch under /tmp/<gate>/, never a production path: a gate that shares a fixture with a
85// production beat reports on the fixture and not on the code.
86const SG_DIR: *u8 = "/tmp/nx_scm_rights_gate" as *u8
87const SG_SOCK: *u8 = "/tmp/nx_scm_rights_gate/rv.sock" as *u8
88
89func g_putn(v: i64) -> i64 {
90 if v == 0 { sys_write(1, "0" as *u8, 1); return 0 }
91 var m: i64 = v
92 if m < 0 { sys_write(1, "-" as *u8, 1); m = 0 - m }
93 let d: *u8 = sys_mmap(SG_NUM_SCRATCH); var k: i64 = 0
94 while m > 0 { d[k] = ((SG_ASCII_ZERO + (m - (m / SG_B10) * SG_B10)) as u8); m = m / SG_B10; k = k + 1 }
95 var j: i64 = k - 1
96 while j >= 0 { sys_write(1, ((d as i64)+j) as *u8, 1); j = j - 1 }
97 return 0
98}
99
100func gq(label: *u8, got: i64, want: i64, ctr: *i64) -> i64 {
101 var c: i64 = 0
102 if got == want { c = 1 }
103 let r: i64 = gv_check(label, c, ctr)
104 if c == 0 {
105 g_puts(" got=" as *u8); g_putn(got)
106 g_puts(" want=" as *u8); g_putn(want)
107 g_puts("\n" as *u8)
108 }
109 return r
110}
111
112// sockaddr_in for 127.0.0.1:<port>. Port is stored network byte order (big-endian).
113func g_sa_loopback(sa: *u8, port: i64) -> i64 {
114 var i: i64 = 0
115 while i < SG_SA_IN_BYTES { sa[i] = 0; i = i + 1 }
116 sa[0] = AF_INET
117 sa[1] = 0
118 sa[2] = port / SG_BYTE_RADIX
119 sa[3] = port % SG_BYTE_RADIX
120 sa[4] = SG_LOOPBACK_A
121 sa[5] = 0
122 sa[6] = 0
123 sa[7] = SG_LOOPBACK_D
124 return 0
125}
126
127// Write one byte; returns 1 on success.
128func g_put1(fd: i64, v: i64) -> i64 {
129 let b: *u8 = sys_mmap(SG_ONE_BYTE)
130 b[0] = v
131 return sys_write(fd, b, SG_ONE_BYTE)
132}
133// Read one byte; returns its value, or a negative number if the read did not deliver exactly one.
134func g_get1(fd: i64) -> i64 {
135 let b: *u8 = sys_mmap(SG_ONE_BYTE)
136 b[0] = 0
137 let r: i64 = sys_read(fd, b, SG_ONE_BYTE)
138 if r != SG_ONE_BYTE { return 0 - 1 }
139 return b[0] as i64
140}
141
142func main() -> i64 {
143 let ctr: *i64 = gv_ctr()
144 gv_head("nx_scm_rights_gate -- SCM_RIGHTS moves a REAL descriptor between two REAL processes" as *u8)
145
146 // SETUP, idempotent: a teardown does not run when a run crashes, so every run starts by
147 // clearing what a previous run may have left behind.
148 sys_mkdir(SG_DIR, MODE_0755)
149 sys_unlinkat(SG_SOCK)
150
151 let ls: i64 = sys_unix_listen(SG_SOCK, SG_BACKLOG)
152 var ls_ok: i64 = 0
153 if ls >= 0 { ls_ok = 1 }
154 if gv_need("a named AF_UNIX rendezvous can be created under /tmp" as *u8, ls_ok, ctr) == 0 {
155 let rcs: i64 = gv_verdict("SCM-RIGHTS-GATE" as *u8, ctr, "each tooth states its own strength above" as *u8)
156 sys_exit(rcs)
157 return rcs
158 }
159 sys_set_socket_timeout(ls, SG_DEADLINE_S)
160
161 let pid: i64 = sys_fork()
162 if pid == 0 {
163 // CHILD. Nothing here prints or touches the counter: the parent owns the verdict, and the
164 // child speaks only through its exit code.
165 sys_alarm(SG_CHILD_ALARM_S)
166 let cs: i64 = nx_unix_connect(SG_SOCK)
167 if cs < 0 { sys_exit(SG_CHILD_CONN_FAIL) }
168 sys_set_socket_timeout(cs, SG_DEADLINE_S)
169 // PHASE 0 -- the receiver must refuse to invent a descriptor from a plain byte.
170 if sys_recv_fd(cs, 0) != SCM_ERR_NO_CMSG { sys_exit(SG_CHILD_NEG_FAIL) }
171 // PHASE 1 -- an ordinary pipe write end.
172 let rfd: i64 = sys_recv_fd(cs, 0)
173 if rfd < 0 { sys_exit(SG_CHILD_RECV_FAIL) }
174 if g_get1(cs) != SG_GO_BYTE { sys_exit(SG_CHILD_GO_FAIL) }
175 if g_put1(rfd, SG_PROOF_BYTE) != SG_ONE_BYTE { sys_exit(SG_CHILD_WRITE_FAIL) }
176 // PHASE 2 -- a LISTENING SOCKET. This is the TS1 invariant.
177 let lfd: i64 = sys_recv_fd(cs, 0)
178 if lfd < 0 { sys_exit(SG_CHILD_LRECV_FAIL) }
179 if g_get1(cs) != SG_GO_BYTE { sys_exit(SG_CHILD_GO2_FAIL) }
180 sys_set_socket_timeout(lfd, SG_DEADLINE_S)
181 let acc: i64 = sys_accept(lfd)
182 if acc < 0 { sys_exit(SG_CHILD_ACCEPT_FAIL) }
183 sys_set_socket_timeout(acc, SG_DEADLINE_S)
184 if g_get1(acc) != SG_PING_BYTE { sys_exit(SG_CHILD_ECHO_FAIL) }
185 if g_put1(acc, SG_ECHO_BYTE) != SG_ONE_BYTE { sys_exit(SG_CHILD_ECHO_FAIL) }
186 sys_exit(SG_CHILD_OK)
187 }
188
189 // PARENT.
190 let conn: i64 = sys_accept(ls)
191 var conn_ok: i64 = 0
192 if conn >= 0 { conn_ok = 1 }
193 gq("T01 anti-vacuity a second REAL process connected to the rendezvous" as *u8, conn_ok, 1, ctr)
194 sys_set_socket_timeout(conn, SG_DEADLINE_S)
195
196 // PHASE 0.
197 let n0: i64 = g_put1(conn, SG_PING_BYTE)
198 gq("T02 anti-vacuity the neg-control plain byte was delivered" as *u8, n0, SG_ONE_BYTE, ctr)
199
200 // PHASE 1 -- the subject is created AFTER the fork, so the child has no copy of it and cannot
201 // acquire one except through the transfer under test.
202 let pb: *u8 = sys_mmap(SG_BOX_BYTES)
203 scm_zero(pb, SG_BOX_BYTES)
204 let pr: i64 = sys_pipe2(pb as *i64, 0)
205 gq("T03 the subject pipe is created AFTER the fork" as *u8, pr, 0, ctr)
206 let prd: i64 = scm_get_u32(pb, 0)
207 let pwr: i64 = scm_get_u32(pb, SCM_U32_BYTES)
208
209 let s1: i64 = sys_send_fd(conn, pwr)
210 gq("T04 sendmsg carried the ancillary data and its one payload byte" as *u8, s1, SCM_PAYLOAD_BYTES, ctr)
211 let c1: i64 = sys_close(pwr)
212 gq("T05 the sender RELEASES its own copy before the receiver uses it" as *u8, c1, 0, ctr)
213 let g1: i64 = g_put1(conn, SG_GO_BYTE)
214 gq("T06 anti-vacuity the go-byte that orders the exchange was delivered" as *u8, g1, SG_ONE_BYTE, ctr)
215 let got1: i64 = g_get1(prd)
216 gq("T07 the receiver USED the transferred descriptor and a byte came back" as *u8, got1, SG_PROOF_BYTE, ctr)
217 sys_close(prd)
218
219 // PHASE 2 -- THE LISTENING SOCKET. Also created after the fork.
220 let sa: *u8 = sys_mmap(SG_SA_IN_BYTES)
221 var port: i64 = SG_PORT_NONE
222 var lsock: i64 = 0 - 1
223 var tries: i64 = 0
224 while tries < SG_PORT_TRIES {
225 let cand: i64 = SG_PORT_BASE + tries
226 let s: i64 = sys_socket(AF_INET, SOCK_STREAM, 0)
227 if s >= 0 {
228 g_sa_loopback(sa, cand)
229 // NO SO_REUSEADDR on purpose: the bind IS the free-port check, and an option that lets
230 // a used port be bound would turn the check into a second instance.
231 if sys_bind(s, sa, SG_SA_IN_BYTES) == 0 {
232 if sys_listen(s, SG_BACKLOG) == 0 {
233 lsock = s
234 port = cand
235 tries = SG_PORT_TRIES
236 } else { sys_close(s) }
237 } else { sys_close(s) }
238 }
239 tries = tries + 1
240 }
241 var port_ok: i64 = 0
242 if port != SG_PORT_NONE { port_ok = 1 }
243 g_puts(" loopback port found free by bind=" as *u8); g_putn(port); g_puts("\n" as *u8)
244 if gv_need("a free loopback port in the probe range" as *u8, port_ok, ctr) == 1 {
245 let s2: i64 = sys_send_fd(conn, lsock)
246 gq("T08 the LISTENING socket was handed over" as *u8, s2, SCM_PAYLOAD_BYTES, ctr)
247 // The sender lets go of the listener entirely. From here on the only process in the system
248 // holding a listener for this port is the child that received it.
249 let c2: i64 = sys_close(lsock)
250 gq("T09 the sender CLOSES the listener it just handed over" as *u8, c2, 0, ctr)
251 let g2: i64 = g_put1(conn, SG_GO_BYTE)
252 gq("T10 anti-vacuity the second go-byte was delivered" as *u8, g2, SG_ONE_BYTE, ctr)
253
254 let cli: i64 = sys_socket(AF_INET, SOCK_STREAM, 0)
255 sys_set_socket_timeout(cli, SG_DEADLINE_S)
256 g_sa_loopback(sa, port)
257 let cr: i64 = sys_connect(cli, sa, SG_SA_IN_BYTES)
258 gq("T11 THE INVARIANT a connect still succeeds after the original owner let go" as *u8, cr, 0, ctr)
259 let p2: i64 = g_put1(cli, SG_PING_BYTE)
260 gq("T12 anti-vacuity the request byte reached the transferred listener" as *u8, p2, SG_ONE_BYTE, ctr)
261 let e2: i64 = g_get1(cli)
262 gq("T13 and the NEW owner served it end to end" as *u8, e2, SG_ECHO_BYTE, ctr)
263 sys_close(cli)
264 }
265
266 let st: *i64 = (sys_mmap(SG_BOX_BYTES)) as *i64
267 st[0] = 0
268 sys_wait4(pid, st, 0)
269 let code: i64 = wait_status_rc(st[0])
270 g_puts(" child exit (0 ok, 2 connect, 3 NEG, 4 recv, 5 go, 6 write, 7 lrecv, 8 go2, 9 accept, 10 echo)=" as *u8)
271 g_putn(code); g_puts("\n" as *u8)
272 // The child's exit code carries the neg-control: it exits 3 unless sys_recv_fd NAMED the absence
273 // of ancillary data, so a receiver that fabricated a descriptor fails HERE and not silently.
274 gq("T14 neg-control-no-ancillary and every child step completed" as *u8, code, SG_CHILD_OK, ctr)
275
276 sys_close(conn)
277 sys_close(ls)
278 sys_unlinkat(SG_SOCK)
279
280 let rc: i64 = gv_verdict("SCM-RIGHTS-GATE" as *u8, ctr, "each tooth states its own strength above" as *u8)
281 sys_exit(rc)
282 return rc
283}