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}