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 012-you-cant-rename-a-symbol-that-isnt-there

Post 012 in the C->Rust port series, on the one class of function the L1
differential oracle structurally cannot test: file-`static` functions. The
oracle is built on `objcopy --redefine-syms foo cref_foo`, and the rename map
comes from `nm -g --defined-only` (ircd-testkit/build.rs) -- globals only. A
`static` function has internal linkage, no external symbol, so no `cref_`
twin can be made without editing the C. First hit in P6 (lookup_confhost,
static int in s_conf.c -> the L1 test linked but failed undefined).

Two opposite escape hatches: de-static it (the one C edit the faithful port
allows -- storage-class only, justified in a comment; mysk in P7s minted
cref_mysk for free), or leave it static and port a private Rust twin verified
only transitively through its exported caller. P7 has four twins:
set_sock_opts (s_bsd.c:1489), check_init (:873), check_clones (:1577),
connect_inet (:2680). The honest limit: the oracle diffs at exported-symbol
granularity, so a red caller-diff can't localize to the static callee -- GCC
already inlined the static into its caller C-side; the Rust port un-fused them
for readability, but the diff stays whole-symbol. And the 010 trap recurs:
transitive coverage only walks the branches the caller's tested path takes
(check_clones' age-out branch needs a 2s gap no golden scenario waits, so it's
covered by a reading, not a test).

Topic chosen by the user from a 2-option AskUserQuestion. All citations
verified vs s_bsd.rs (the four twins) + ircd-testkit/build.rs (the nm -g
filter) + the P6 progress log (lookup_confhost). Mandatory xe-writing-style +
sound-like-a-person passes run; successive-paragraph-letter audit clean.

Assisted-by: Claude <noreply@anthropic.com>
Signed-off-by: Xe Iaso <me@xeiaso.net>

+226
+226
docs/blog/012-you-cant-rename-a-symbol-that-isnt-there.md
··· 1 + --- 2 + title: "You can't rename a symbol that isn't there" 3 + desc: "The whole differential oracle is one objcopy trick: rename every C function `foo` to `cref_foo` and run it next to the Rust port in the same process. It works perfectly right up until you hit a function that has no symbol to rename — and a real codebase is full of them. Here's what you do when the oracle is structurally blind to the exact function you just ported." 4 + date: 2026-06-06 5 + --- 6 + 7 + The last three posts were a tour of the differential oracle's reach. The 8 + [leaves](./009-hand-both-of-them-the-same-socket.md) that read the kernel, the 9 + [front door](./010-you-dont-write-a-test-for-the-front-door.md) every connection 10 + enters through, the [dial-out](./011-the-dial-out-you-can-only-test-by-not-dialing.md) 11 + you can only test by refusing to dial. Each one was about a different *shape* of 12 + input — pure args, a real fd, the kernel's say-so — and how you get two 13 + implementations to agree on it. 14 + 15 + Here's a class of function the oracle can't reach for a much dumber reason than 16 + input shape: these functions don't have a name. 17 + 18 + ## The oracle is one objcopy trick 19 + 20 + Worth restating the [machinery](./003-the-oracle.md) in one breath, because the 21 + whole post hangs off how it actually works. The L1 differential doesn't mock 22 + anything and doesn't run two processes. It compiles the frozen reference C, takes 23 + every function `foo` in those objects, and renames the symbol to `cref_foo`. Then 24 + your test links the renamed C archive *and* the Rust port into one binary and calls 25 + both `cref_foo(x)` and the Rust `foo(x)` back to back, on the same inputs, and 26 + asserts the outputs are byte-identical. 27 + 28 + The rename is the entire trick, and it's three lines of `build.rs`: 29 + 30 + ```rust 31 + // ircd-testkit/build.rs — nm over the reference objects, emit a rename map 32 + let is_global_def = 33 + ty.len() == 1 && ty.chars().all(|c| c.is_ascii_uppercase() && c != 'U'); 34 + if is_global_def { 35 + Some(format!("{name} cref_{name}")) 36 + } 37 + ``` 38 + 39 + That runs `nm -g --defined-only` over the reference objects, keeps every line whose 40 + type is a single uppercase letter — `T` for a function in text, `D`/`B`/`R` for 41 + data — and writes `foo cref_foo` into a map that `objcopy --redefine-syms` then 42 + applies. `cref_send_message`, `cref_do_numeric`, `cref_connect_server`: a thousand 43 + of them, generated mechanically, no human picks the list. 44 + 45 + > **Mara** (hacker): `nm -g`. The `-g` is "external symbols only." Hold onto that 46 + > flag — it's the whole problem. 47 + 48 + ## `static` means there's no symbol to rename 49 + 50 + Here's the thing about a `static` function in C. The keyword doesn't mean 51 + "constant" or "shared" the way it does in half a dozen other languages — on a 52 + function it means *internal linkage*. The symbol never leaves the translation unit. 53 + There is no `foo` in the object file's external symbol table for `nm -g` to find, 54 + which means there is nothing for `objcopy` to rename, which means `cref_foo` does 55 + not exist and cannot be made to exist without editing the C. 56 + 57 + So the oracle's granularity is exactly the set of exported symbols. Every global 58 + function gets a twin. A `static` one falls below the resolution of the instrument — 59 + you can port it, but you can't point the differential harness at it, because the 60 + harness finds its subjects by name and this one doesn't have a name the linker will 61 + admit to. 62 + 63 + We hit this wall the first time back in P6, porting the config reader. There's a 64 + helper called `lookup_confhost` that resolves a conf block's hostname, and it's 65 + `static int` in `s_conf.c`. I wrote an L1 diff for it the same way I'd written 66 + thirty others, the test linked, and the link failed: undefined reference to 67 + `cref_lookup_confhost`. Not a typo. The symbol genuinely wasn't in the archive, 68 + because `nm -g` had nothing to report and the rename map skipped it. Its non-static 69 + neighbor `ipv6_convert` got an L1 diff that day. `lookup_confhost` didn't, and 70 + couldn't, and the progress note for that phase just says so in as many words. 71 + 72 + > **Cadey** (coffee): I spent a while convinced I'd broken the build script before 73 + > it clicked that the build script was right and the function was the problem. The 74 + > oracle wasn't refusing to test it. The oracle literally could not see it. 75 + 76 + ## Two ways out, and they're opposites 77 + 78 + Once you accept that a `static` function is invisible to the harness, you've got 79 + exactly two moves, and they pull in opposite directions. 80 + 81 + The first is to *give it a name*. `static int foo` becomes `int foo` — you strike 82 + the `static` keyword, the symbol gets external linkage, `nm -g` finds it, the rename 83 + map picks it up, and `cref_foo` springs into existence. Now you can L1-diff it like 84 + anything else. This is the one and only category of edit I allow myself to make to 85 + the reference C in a project whose entire premise is *don't touch the C*, and I 86 + [justify it in a source comment every time](./008-eat-the-file-one-function-at-a-time.md): 87 + it's a storage-class change, it's behavior-preserving, and in a single-TU build 88 + there's no symbol collision to worry about. P7's `mysk` source-address global went 89 + this way — de-static'd so the still-C neighbors and the Rust port could share it, 90 + which had the side effect of minting `cref_mysk` for free. 91 + 92 + Striking `static` is clean when the function is something you genuinely want a 93 + first-class oracle for. But it's an edit to the thing you're supposed to be holding 94 + fixed, and you can't de-static *everything* without slowly turning the reference 95 + into a different program than the one you froze. So the second move is to leave the 96 + `static` exactly where it is and port the function as what I've started calling a 97 + **private Rust twin**: a module-private `unsafe fn` in the Rust crate that mirrors 98 + the C body, called only by the Rust port of whatever called the C original, and 99 + *never* L1-diffed on its own — because it can't be. 100 + 101 + P7 ended up with four of these, and they're worth listing because the pattern only 102 + gets convincing in bulk: 103 + 104 + - `set_sock_opts` (`s_bsd.c:1489`) — the socket-option setter, called by the 105 + listener constructor `inetport`. 106 + - `check_init` (`s_bsd.c:873`) — resolves a peer address, called by both 107 + `check_client` and `check_server_init`. 108 + - `check_clones` (`s_bsd.c:1577`) — the clone-rate limiter, called only by 109 + `add_connection`. 110 + - `connect_inet` (`s_bsd.c:2680`) — the outbound socket setup, called only by 111 + `connect_server`. 112 + 113 + Each one is `static` in the C. Each one has a Rust twin that's also private — no 114 + `pub`, no `#[no_mangle]`, nothing the linker exports. The doc comment on every one 115 + of them says the same thing in slightly different words: *static in C, no `cref_` 116 + oracle, ported as a private twin, verified through its caller.* 117 + 118 + ## So how do you know the twin is right? 119 + 120 + You diff its caller. That's the whole answer, and it's less of a cop-out than it 121 + sounds, because of a compiler detail that turns out to be load-bearing. 122 + 123 + When `connect_inet` is `static` and lives in the same file as its only caller 124 + `connect_server`, the C compiler is free to *inline* it — and at the optimization 125 + level this builds at, it does. There is no distinct `connect_inet` in the compiled 126 + output to diff against even if you wanted one; it's already been melted into the 127 + body of `connect_server`. So when the L1 harness runs `cref_connect_server` against 128 + the Rust `connect_server`, the C side it's comparing against already *contains* 129 + `connect_inet`, inlined. The Rust side calls out to a separate private `connect_inet` 130 + twin. The two are structured differently and produce identical results, and the 131 + differential proves the second part — which is the part that matters. 132 + 133 + That's the honest shape of it: **the oracle diffs at the granularity of exported 134 + symbols, and a `static` callee is below that line, so you verify it transitively 135 + through the exported caller that swallows it.** When the last C caller of a `static` 136 + finally gets ported — `connect_server` for `connect_inet`, `check_server_init` for 137 + `check_init` — the C `static` loses its remaining caller, so the whole thing gets 138 + guarded out of the reference compile under the same `-DPORT_*` flag. After that 139 + point there is no C `connect_inet` in the build at *any* linkage, exported or not. 140 + The only proof it ever matched is the `connect_server` differential that ran while 141 + both still existed. 142 + 143 + > **Mara** (hacker): which means the per-function granularity is a Rust-side 144 + > fiction. C fused caller and callee at compile time; you un-fused them in the port 145 + > because separate functions are nicer to read. The oracle never saw the seam, so if 146 + > `connect_server_diff` ever goes red, it can't tell you whether the bug is in 147 + > `connect_server` or `connect_inet`. It just says "the pair disagrees." You go 148 + > find out which half yourself. 149 + 150 + I'm fine with that, but I want to be precise about what it costs, because this is 151 + the kind of claim the series is supposed to be careful with. Transitive coverage is 152 + real coverage — both worlds run the actual `connect_inet` logic, on real inputs, and 153 + the bytes are compared. What you lose isn't *whether* it's tested. It's 154 + *localization*: a red diff points at a symbol, and the symbol it points at is the 155 + caller, not the callee that's hiding inside it. 156 + 157 + ## And the trap from last time comes back 158 + 159 + There's a sharper limit, and it's the same one the [front-door 160 + post](./010-you-dont-write-a-test-for-the-front-door.md) was built around, wearing a 161 + different hat. Verifying a `static` through its caller only exercises the *branches 162 + of the static that the caller's tested path walks.* 163 + 164 + Consider `check_clones`. Its Rust twin keeps a function-local `static mut` backlog list 165 + — a static inside a static, faithfully mirroring the C's `static struct abacklog 166 + *backlog` — and ages out stale entries before counting how many recent connections 167 + share your IP: 168 + 169 + ```rust 170 + // ircd-common/src/s_bsd.rs:1746 — the private check_clones twin 171 + unsafe fn check_clones(cptr: *mut aClient) -> c_int { 172 + static mut BACKLOG: *mut AbackLog = std::ptr::null_mut(); 173 + let now = ircd_sys::bindings::timeofday; 174 + // First, ditch old entries. 175 + // ...prepend {cptr->ip, now}, then count matching-ip nodes... 176 + } 177 + ``` 178 + 179 + The only L2 path that reaches `check_clones` is the eleventh connection from one 180 + host inside two seconds — the [clone-reject](./010-you-dont-write-a-test-for-the-front-door.md) 181 + the golden harness has to *stage*, because no well-behaved client floods. Walk that 182 + path and you've proven the count-and-reject branch. The age-out branch — the loop 183 + that frees entries older than the period — only runs when there's a stale entry to 184 + free, which means a connection, then a two-second wait, then another connection from 185 + the same IP. No golden scenario waits two seconds between connections, so that 186 + branch of a function I can't diff directly is covered by nothing at all. I read it, 187 + I believe it's right, and I can't prove it with the oracle. That's the true state, 188 + and rounding it up to "covered" is exactly the move this whole project exists to not 189 + make. 190 + 191 + ## The funny part, as usual 192 + 193 + What delighted me is that `check_clones`' Rust twin has a `static mut` 194 + *inside the function* — Rust's most-nagged-about feature, the one the borrow checker 195 + files under "are you absolutely sure" — sitting there precisely because the C it's 196 + faithful to used a function-local `static` for the same backlog. I ported a C 197 + internal-linkage quirk into a Rust internal-linkage quirk, and the reason the 198 + *outer* function is invisible to my oracle (`static` → no symbol) is the same C 199 + keyword that, one scope deeper, makes the *list* persist across calls. Same word, 200 + two completely different jobs, and both of them are in my way. 201 + 202 + C's `static` is four or five unrelated features wearing one keyword, and this port 203 + keeps running into two of them at once. The oracle can't name the function. The 204 + function can't forget its list. Neither of those is a thing I get to change without 205 + changing the program I promised to preserve, so I work around both and write down 206 + exactly where the instrument went blind. 207 + 208 + --- 209 + 210 + The four twins are in 211 + [`ircd-common/src/s_bsd.rs`](../../ircd-common/src/s_bsd.rs) (`set_sock_opts`, 212 + `check_init`, `check_clones`, `connect_inet` — each `unsafe fn`, no `pub`); the 213 + rename machinery that can't reach them is 214 + [`ircd-testkit/build.rs`](../../ircd-testkit/build.rs) (the `nm -g` filter); and the 215 + first time this bit us is the `lookup_confhost` note in the 216 + [P6 progress log](../progress-log/p6.md), under the phase plan in 217 + [PLAN.md](../../PLAN.md). The [oracle post](./003-the-oracle.md) is the `cref_` 218 + rename these all lean on, the [front door](./010-you-dont-write-a-test-for-the-front-door.md) 219 + is where the branch-coverage trap was first laid out, and 220 + [eating the file one function at a time](./008-eat-the-file-one-function-at-a-time.md) 221 + is where the de-static edit gets its justification. 222 + 223 + And the standing reminder, true on every post here: this is an unsanctioned 224 + experiment on someone else's code. Leave the IRCNet team out of it. The twins I 225 + can't diff, the keyword I keep tripping over, and the branch I covered with a reading 226 + instead of a test are mine.