Proof of concept mechanical port of ircnet/ircd to Rust as part of a bit about C being insecure for network services
0

Configure Feed

Select the types of activity you want to include in your feed.

docs(blog): add 013-the-function-that-launches-the-program-i-didnt-port

The next post in the C->Rust port series. Covers P7dd start_iauth, the
C launcher of the greenfield iauth from post 005: how you run a
differential test on a function whose success means forking and execing
into a different program (you diff the parent-side shoreline, fork for
real, and reap the children), plus the vfork->fork swap kept faithful by
leaving the error message wrong on purpose.

Assisted-by: Claude Opus 4.8 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>

+283
+283
docs/blog/013-the-function-that-launches-the-program-i-didnt-port.md
··· 1 + --- 2 + title: "The function that launches the program I didn't port" 3 + desc: "Eight posts ago I deleted iauth and rewrote it from scratch. This post ports the thirty-year-old C function that launches it — which forks, execs, and stops being my process. You can't diff your way into a subprocess, so here's the shoreline you diff instead, plus the one syscall I changed on purpose and the error message I refused to change with it." 4 + date: 2026-06-06 5 + --- 6 + 7 + Back in [post five](./005-the-greenfield-exception.md) I wrote about the one 8 + component of this codebase I refused to faithfully port: `iauth`, the 9 + authentication slave that decides whether the daemon wants to talk to you before 10 + you've said a word. I deleted the C and rewrote it from scratch in async Rust, 11 + because its contract is a text protocol over a socket, not an in-process ABI — so 12 + the honest thing to verify is the wire, not the function bodies. That post was 13 + about the program. 14 + 15 + This one is about the C function that *starts* that program. It's called 16 + `start_iauth`, it lives in `s_bsd.c`, and its entire job is to fork a child, exec 17 + the iauth binary, and hang onto the parent end of a socketpair so the daemon has 18 + something to talk to. It is the launcher for the one thing I didn't port, and 19 + porting *it* faithfully turns out to be its own small puzzle. 20 + 21 + ## The seam where both strategies meet 22 + 23 + Here's what's actually nice about this function, structurally. One end of the 24 + socketpair it creates is a from-scratch idiomatic-Rust rewrite with its own config 25 + format. The other end is a thirty-year-old C function I'm porting byte-for-byte 26 + under a differential oracle. The whole project's two opposite philosophies — 27 + *rewrite it* and *translate it exactly* — are stitched together at this one fd, and 28 + they have to agree about precisely one thing: the bytes that cross it. I [already 29 + proved that handshake](./005-the-greenfield-exception.md) by running the relinked C 30 + iauth against the Rust one over a socketpair and diffing the wire. 31 + 32 + So the wire was covered. The launcher wasn't, and porting the launcher made me 33 + answer a question the oracle hadn't had to face yet: how do you run a differential 34 + test on a function whose success means *ceasing to be your process*? 35 + 36 + Look at the part that matters: 37 + 38 + ```c 39 + /* ircd/s_bsd.c:625 */ 40 + switch ((iauth_pid = vfork())) 41 + { 42 + case -1: 43 + sendto_flag(SCH_ERROR, "vfork() failed!"); 44 + ... 45 + case 0: 46 + for (fd = 0; fd < MAXCONNECTIONS; fd++) 47 + if (fd != sp[1]) 48 + (void)close(fd); 49 + if (sp[1] != 0) 50 + { 51 + (void)dup2(sp[1], 0); 52 + close(sp[1]); 53 + } 54 + if (execl(IAUTH_PATH, IAUTH, NULL) < 0) 55 + _exit(-1); /* should really not happen.. */ 56 + default: 57 + close(sp[1]); 58 + } 59 + ``` 60 + 61 + The success path is `case 0:`. The child closes every fd except its end of the 62 + socketpair, dups that end onto fd 0, and `execl`s the iauth binary. If that 63 + `execl` succeeds, the child is no longer running ircd. It's running a completely 64 + different program — the one [I rewrote](./005-the-greenfield-exception.md) — and 65 + its memory image, the thing a differential test would inspect, has been thrown away 66 + and replaced wholesale. There is nothing left in there to compare. 67 + 68 + > **Mara** (hacker): `execl` is the point of no return. Before it, the child is a 69 + > copy of you. After it, the child is `iauth` and your code is gone from that 70 + > address space. A test that wants to "diff the child" is asking to diff a process 71 + > that no longer contains either implementation. 72 + 73 + ## You diff the shoreline, not the ocean 74 + 75 + So I can't follow the function into the child. What I *can* do is diff everything 76 + that happens on the near side of the fork — the part that stays in my process. 77 + Before `start_iauth` ever forks, it makes a bunch of decisions and side effects 78 + that are completely observable: it checks the boot flags, it honors a 90-second 79 + restart throttle, it creates the socketpair, sets both ends non-blocking, sizes the 80 + socket buffers to `IAUTH_BUFFER` (65535), and records the parent end in the global 81 + `adfd`. All of that is in *my* process, on the parent side, and all of it is fair 82 + game for the oracle. 83 + 84 + The L1 differential ends up with three cases, and the first two are the easy 85 + decision-logic no-ops. If `BOOT_NOIAUTH` is set, the function returns immediately 86 + and touches nothing — `adfd` stays `-1`, the spawn counter doesn't move. If iauth 87 + is already running (`adfd >= 0`), same deal: it bails without spawning. Both worlds, 88 + the renamed C `cref_start_iauth` and the Rust `start_iauth`, agree to do nothing, 89 + and I assert the nothing. 90 + 91 + Case three is the one with teeth, and it does something none of my earlier 92 + differential tests did: it forks for real. 93 + 94 + ```rust 95 + // ircd-testkit/tests/start_iauth_diff.rs:152 96 + for w in [rust_world(), cref_world()] { 97 + unsafe { 98 + *w.bootopt = saved_boot & !BOOT_NOIAUTH; 99 + *w.adfd = -1; 100 + *w.iauth_spawn = 0; 101 + 102 + (w.start)(0); 103 + 104 + // Observable post-state of the socketpair + set_non_blocking + vfork prefix. 105 + let fd = *w.adfd; 106 + let nonblock = if fd >= 0 { 107 + let fl = libc::fcntl(fd, libc::F_GETFL); 108 + fl >= 0 && (fl & libc::O_NONBLOCK) != 0 109 + } else { false }; 110 + let spawn = *w.iauth_spawn; 111 + results.push((fd >= 0, nonblock, spawn)); 112 + 113 + if fd >= 0 { libc::close(fd); } 114 + reap_children(); 115 + // ...restore globals... 116 + } 117 + } 118 + assert_eq!(results[0], results[1], "rust vs cref spawn post-state differs"); 119 + assert_eq!(results[0], (true, true, 1), "spawn must open a nonblocking adfd and bump iauth_spawn"); 120 + ``` 121 + 122 + Each world actually calls the real spawn path. Each one really `fork`s a real 123 + child, which really tries to `execl` the real `IAUTH_PATH` — which doesn't exist on 124 + a CI box, so the child immediately `_exit(-1)`s. The parent side, the side I care 125 + about, comes back with `adfd` holding an open, non-blocking descriptor and the spawn 126 + counter bumped to one. I capture exactly three booleans-and-a-number from each 127 + world — *did adfd open, is it non-blocking, did the counter move* — and assert the 128 + two worlds produced the same tuple, and that the tuple is the one a real spawn 129 + should produce. 130 + 131 + > **Cadey** (coffee): the assertion is `(true, true, 1)`. That's the entire 132 + > observable footprint of a function that forks a subprocess: a file descriptor 133 + > exists, it has a flag set, and a number went up by one. Everything dramatic 134 + > about the function happens in a child I'm not allowed to look at. 135 + 136 + ## Testing a fork means cleaning up after one 137 + 138 + There's a tax on forking inside a test, and it's the kind of thing that doesn't 139 + show up until your CI starts accumulating zombies. Both worlds spawn a child. Those 140 + children exit almost instantly — the `execl` fails and they `_exit` — but until 141 + something `wait`s on them, they sit in the process table as zombies. A test that 142 + forks and walks away is a test that leaks processes. 143 + 144 + So the harness has to reap, and it has to reap in a way that can't wedge the test 145 + runner if something goes weird: 146 + 147 + ```rust 148 + // ircd-testkit/tests/start_iauth_diff.rs:65 149 + fn reap_children() { 150 + for _ in 0..100 { 151 + let mut status: c_int = 0; 152 + let r = unsafe { libc::waitpid(-1, &mut status, libc::WNOHANG) }; 153 + if r <= 0 { 154 + std::thread::sleep(std::time::Duration::from_millis(2)); 155 + } 156 + if r == -1 { 157 + break; // ECHILD: nothing left to reap 158 + } 159 + } 160 + } 161 + ``` 162 + 163 + Bounded loop, non-blocking `waitpid`, bail on `ECHILD`. If `IAUTH_PATH` happened to 164 + exist and a real iauth came up instead of dying, it'd get EOF the moment I close the 165 + parent `adfd` and shut down on its own — and the same reap loop catches it. The 166 + point is the test can never hang waiting on a child, no matter what's on the far end 167 + of that exec. I spent more time getting the cleanup right than the actual 168 + assertions, which feels backwards until you remember the assertions are three 169 + booleans. 170 + 171 + ## The syscall I changed and the message I didn't 172 + 173 + Now the part that pokes the project's whole premise in the eye. This is a 174 + *faithful* port. The rule, the one I keep [shipping deliberate bugs to 175 + honor](./009-hand-both-of-them-the-same-socket.md), is that if the C does a weird 176 + thing, the Rust does the same weird thing. So look at what I did with the actual 177 + fork call: the C says `vfork()`, and my Rust says `fork()`. 178 + 179 + ```rust 180 + // ircd-common/src/s_bsd.rs:310 181 + // C uses `vfork()`; we use `fork()`. Behaviorally identical here — the child only 182 + // runs a close loop + `dup2` + `execl`, so copy-on-write `fork` produces the same 183 + // result — and Rust's libc deprecates `vfork` as memory-unsafe (the compiler may 184 + // insert code between vfork and exec while parent and child share a stack, which is 185 + // exactly the close-loop/dup2 pattern below). 186 + START_IAUTH_PID = libc::fork(); 187 + ``` 188 + 189 + I went back and forth on this, because changing a syscall in a port whose entire 190 + pitch is *don't change anything* should make me nervous, and it did. Here's why I 191 + landed where I landed. `vfork` is an optimization that suspends the parent and lets 192 + the child borrow its address space until `exec`, and it comes with a vicious 193 + contract: the child may not touch anything but the variable holding the return value 194 + and may not return from the function. Rust's `libc` deprecates it precisely because 195 + the compiler can legally insert code between the `vfork` and the `exec` while the two 196 + processes share a stack — and a close-loop-plus-`dup2` is exactly the kind of code 197 + that gets to run in that window. A copy-on-write `fork` does the identical thing here 198 + because the child only closes fds and execs; it never writes anything the parent 199 + would miss. The observable result is byte-for-byte the same. The unsafe version just 200 + buys a few microseconds I will never be able to measure. 201 + 202 + > **Mara** (hacker): the tell is that `vfork`'s whole benefit is avoiding the 203 + > page-table copy, and CoW `fork` already doesn't copy the pages — it copies the 204 + > table and shares the pages until a write. For a child that does nothing but close 205 + > fds and exec, there are no writes worth the name. You're paying for `vfork`'s 206 + > sharp edges to save almost nothing. 207 + 208 + Here's the detail I actually love, though. When that fork *fails*, the C logs 209 + `"vfork() failed!"` to the oper notice channel — and my Rust logs `"vfork() 210 + failed!"` too, even though my Rust didn't call `vfork`. I kept the wrong word on 211 + purpose. 212 + 213 + That's not sloppiness. It's the faithfulness rule doing exactly what it's for, just 214 + at a finer grain than I usually need it. The syscall is below the oracle's resolution 215 + — once the child execs, nothing downstream can tell `fork` from `vfork` — so I get to 216 + pick the safe one. The error string isn't below anything. It goes out on the wire to 217 + every connected oper, the harness would catch a single changed byte of it, and some 218 + operator out there might be grepping their logs for that exact phrase. So I changed 219 + the call nobody can observe and kept the word everybody can. That's the line, and I 220 + don't think I've ever seen it drawn this sharply. 221 + 222 + ## What this test doesn't prove 223 + 224 + I want to be straight about the holes, because this is the kind of claim the series 225 + exists to be careful with, and there are two real ones. 226 + 227 + The child is unverified. The L1 proves the parent shoreline — `adfd` opened, 228 + non-blocking, counted — but it proves nothing about what the child *does* before it 229 + disappears: that it closes the right descriptors, dups its socketpair end onto fd 0, 230 + and execs the right binary. Both children vanish before any of that is observable 231 + in-process. Whether iauth actually comes up on the other end of fd 0 is an 232 + end-to-end fact, and the only honest place to check it is L2/soak with iauth 233 + genuinely enabled — not a unit test that forks into the void. 234 + 235 + And there's a block I straight-up don't reach. On every call after the first, 236 + `start_iauth` bursts the list of currently-connected local clients to iauth as a 237 + pile of `"%d O\n"` lines, so a freshly-restarted iauth learns who's already on. That 238 + behavior is gated behind a function `static char first` — one of three function 239 + statics I had to lift into module-private Rust globals, the [same `static` 240 + quirk](./012-you-cant-rename-a-symbol-that-isnt-there.md) that keeps biting this 241 + port — and on the very first call it's skipped. My L1 driver only ever makes a first 242 + call per world, so both worlds skip the burst identically and the differential is 243 + green by *symmetric absence*: neither side runs the code, so neither side can 244 + disagree. That's not coverage. Exercising it for real needs a populated `local[]` 245 + and a second call after `first` flips, which is, again, L2/soak. I wrote that down in 246 + the plan rather than letting "the test is green" stand in for "the burst is right." 247 + 248 + > **Cadey** (coffee): "green by symmetric absence" is my new least-favorite kind of 249 + > green. The test passes because both implementations agree to do nothing. Technically 250 + > a match. Spiritually a TODO. 251 + 252 + ## The shape of it 253 + 254 + So that's a launcher, ported. Almost all of it — the flags, the throttle, the 255 + socketpair, the buffer sizing, the descriptor handoff — happens before the fork, in 256 + your process, where the oracle can see it. The fork you run for real and clean up 257 + after. The child past `execl` you don't get to diff, because none of your code is in 258 + it anymore. 259 + 260 + A weird thing to spend a day on: three booleans of assertion and a reaper loop longer 261 + than the function it tests. But it's the door the [greenfield 262 + rewrite](./005-the-greenfield-exception.md) walks through, and now both sides of that 263 + door are mine. 264 + 265 + --- 266 + 267 + The port is 268 + [`ircd-common/src/s_bsd.rs`](../../ircd-common/src/s_bsd.rs) (`start_iauth`, with the 269 + `vfork`→`fork` note and the three module-private statics); the fork-for-real 270 + differential is 271 + [`ircd-testkit/tests/start_iauth_diff.rs`](../../ircd-testkit/tests/start_iauth_diff.rs); 272 + and the per-leaf plan that scoped it is 273 + [`p7dd-s_bsd-start_iauth`](../superpowers/plans/2026-06-06-p7dd-s_bsd-start_iauth.md), 274 + under the phase plan in [PLAN.md](../../PLAN.md). The [greenfield 275 + exception](./005-the-greenfield-exception.md) is the iauth rewrite this function 276 + launches, the [`static` post](./012-you-cant-rename-a-symbol-that-isnt-there.md) is 277 + where the three function statics come from, and the [oracle](./003-the-oracle.md) is 278 + the `cref_` rename the whole differential leans on. 279 + 280 + And the standing reminder, true on every post here: this is an unsanctioned 281 + experiment on someone else's code. Leave the IRCNet team out of it. The launcher I 282 + forked, the children I reaped, and the syscall I swapped while keeping its name on 283 + the error message are mine.