title: "You can't rename a symbol that isn't there"
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."
date: 2026-06-06#
The last three posts were a tour of the differential oracle's reach. The leaves that read the kernel, the front door every connection enters through, the dial-out you can only test by refusing to dial. Each one was about a different shape of input — pure args, a real fd, the kernel's say-so — and how you get two implementations to agree on it.
Here's a class of function the oracle can't reach for a much dumber reason than input shape: these functions don't have a name.
The oracle is one objcopy trick#
Worth restating the machinery in one breath, because the
whole post hangs off how it actually works. The L1 differential doesn't mock
anything and doesn't run two processes. It compiles the frozen reference C, takes
every function foo in those objects, and renames the symbol to cref_foo. Then
your test links the renamed C archive and the Rust port into one binary and calls
both cref_foo(x) and the Rust foo(x) back to back, on the same inputs, and
asserts the outputs are byte-identical.
The rename is the entire trick, and it's three lines of build.rs:
// ircd-testkit/build.rs — nm over the reference objects, emit a rename map
let is_global_def =
ty.len() == 1 && ty.chars().all(|c| c.is_ascii_uppercase() && c != 'U');
if is_global_def {
Some(format!("{name} cref_{name}"))
}
That runs nm -g --defined-only over the reference objects, keeps every line whose
type is a single uppercase letter — T for a function in text, D/B/R for
data — and writes foo cref_foo into a map that objcopy --redefine-syms then
applies. cref_send_message, cref_do_numeric, cref_connect_server: a thousand
of them, generated mechanically, no human picks the list.
Mara (hacker):
nm -g. The-gis "external symbols only." Hold onto that flag — it's the whole problem.
static means there's no symbol to rename#
Here's the thing about a static function in C. The keyword doesn't mean
"constant" or "shared" the way it does in half a dozen other languages — on a
function it means internal linkage. The symbol never leaves the translation unit.
There is no foo in the object file's external symbol table for nm -g to find,
which means there is nothing for objcopy to rename, which means cref_foo does
not exist and cannot be made to exist without editing the C.
So the oracle's granularity is exactly the set of exported symbols. Every global
function gets a twin. A static one falls below the resolution of the instrument —
you can port it, but you can't point the differential harness at it, because the
harness finds its subjects by name and this one doesn't have a name the linker will
admit to.
We hit this wall the first time back in P6, porting the config reader. There's a
helper called lookup_confhost that resolves a conf block's hostname, and it's
static int in s_conf.c. I wrote an L1 diff for it the same way I'd written
thirty others, the test linked, and the link failed: undefined reference to
cref_lookup_confhost. Not a typo. The symbol genuinely wasn't in the archive,
because nm -g had nothing to report and the rename map skipped it. Its non-static
neighbor ipv6_convert got an L1 diff that day. lookup_confhost didn't, and
couldn't, and the progress note for that phase just says so in as many words.
Cadey (coffee): I spent a while convinced I'd broken the build script before it clicked that the build script was right and the function was the problem. The oracle wasn't refusing to test it. The oracle literally could not see it.
Two ways out, and they're opposites#
Once you accept that a static function is invisible to the harness, you've got
exactly two moves, and they pull in opposite directions.
The first is to give it a name. static int foo becomes int foo — you strike
the static keyword, the symbol gets external linkage, nm -g finds it, the rename
map picks it up, and cref_foo springs into existence. Now you can L1-diff it like
anything else. This is the one and only category of edit I allow myself to make to
the reference C in a project whose entire premise is don't touch the C, and I
justify it in a source comment every time:
it's a storage-class change, it's behavior-preserving, and in a single-TU build
there's no symbol collision to worry about. P7's mysk source-address global went
this way — de-static'd so the still-C neighbors and the Rust port could share it,
which had the side effect of minting cref_mysk for free.
Striking static is clean when the function is something you genuinely want a
first-class oracle for. But it's an edit to the thing you're supposed to be holding
fixed, and you can't de-static everything without slowly turning the reference
into a different program than the one you froze. So the second move is to leave the
static exactly where it is and port the function as what I've started calling a
private Rust twin: a module-private unsafe fn in the Rust crate that mirrors
the C body, called only by the Rust port of whatever called the C original, and
never L1-diffed on its own — because it can't be.
P7 ended up with four of these, and they're worth listing because the pattern only gets convincing in bulk:
set_sock_opts(s_bsd.c:1489) — the socket-option setter, called by the listener constructorinetport.check_init(s_bsd.c:873) — resolves a peer address, called by bothcheck_clientandcheck_server_init.check_clones(s_bsd.c:1577) — the clone-rate limiter, called only byadd_connection.connect_inet(s_bsd.c:2680) — the outbound socket setup, called only byconnect_server.
Each one is static in the C. Each one has a Rust twin that's also private — no
pub, no #[no_mangle], nothing the linker exports. The doc comment on every one
of them says the same thing in slightly different words: static in C, no cref_
oracle, ported as a private twin, verified through its caller.
So how do you know the twin is right?#
You diff its caller. That's the whole answer, and it's less of a cop-out than it sounds, because of a compiler detail that turns out to be load-bearing.
When connect_inet is static and lives in the same file as its only caller
connect_server, the C compiler is free to inline it — and at the optimization
level this builds at, it does. There is no distinct connect_inet in the compiled
output to diff against even if you wanted one; it's already been melted into the
body of connect_server. So when the L1 harness runs cref_connect_server against
the Rust connect_server, the C side it's comparing against already contains
connect_inet, inlined. The Rust side calls out to a separate private connect_inet
twin. The two are structured differently and produce identical results, and the
differential proves the second part — which is the part that matters.
That's the honest shape of it: the oracle diffs at the granularity of exported
symbols, and a static callee is below that line, so you verify it transitively
through the exported caller that swallows it. When the last C caller of a static
finally gets ported — connect_server for connect_inet, check_server_init for
check_init — the C static loses its remaining caller, so the whole thing gets
guarded out of the reference compile under the same -DPORT_* flag. After that
point there is no C connect_inet in the build at any linkage, exported or not.
The only proof it ever matched is the connect_server differential that ran while
both still existed.
Mara (hacker): which means the per-function granularity is a Rust-side fiction. C fused caller and callee at compile time; you un-fused them in the port because separate functions are nicer to read. The oracle never saw the seam, so if
connect_server_diffever goes red, it can't tell you whether the bug is inconnect_serverorconnect_inet. It just says "the pair disagrees." You go find out which half yourself.
I'm fine with that, but I want to be precise about what it costs, because this is
the kind of claim the series is supposed to be careful with. Transitive coverage is
real coverage — both worlds run the actual connect_inet logic, on real inputs, and
the bytes are compared. What you lose isn't whether it's tested. It's
localization: a red diff points at a symbol, and the symbol it points at is the
caller, not the callee that's hiding inside it.
And the trap from last time comes back#
There's a sharper limit, and it's the same one the front-door
post was built around, wearing a
different hat. Verifying a static through its caller only exercises the branches
of the static that the caller's tested path walks.
Consider check_clones. Its Rust twin keeps a function-local static mut backlog list
— a static inside a static, faithfully mirroring the C's static struct abacklog *backlog — and ages out stale entries before counting how many recent connections
share your IP:
// ircd-common/src/s_bsd.rs:1746 — the private check_clones twin
unsafe fn check_clones(cptr: *mut aClient) -> c_int {
static mut BACKLOG: *mut AbackLog = std::ptr::null_mut();
let now = ircd_sys::bindings::timeofday;
// First, ditch old entries.
// ...prepend {cptr->ip, now}, then count matching-ip nodes...
}
The only L2 path that reaches check_clones is the eleventh connection from one
host inside two seconds — the clone-reject
the golden harness has to stage, because no well-behaved client floods. Walk that
path and you've proven the count-and-reject branch. The age-out branch — the loop
that frees entries older than the period — only runs when there's a stale entry to
free, which means a connection, then a two-second wait, then another connection from
the same IP. No golden scenario waits two seconds between connections, so that
branch of a function I can't diff directly is covered by nothing at all. I read it,
I believe it's right, and I can't prove it with the oracle. That's the true state,
and rounding it up to "covered" is exactly the move this whole project exists to not
make.
The funny part, as usual#
What delighted me is that check_clones' Rust twin has a static mut
inside the function — Rust's most-nagged-about feature, the one the borrow checker
files under "are you absolutely sure" — sitting there precisely because the C it's
faithful to used a function-local static for the same backlog. I ported a C
internal-linkage quirk into a Rust internal-linkage quirk, and the reason the
outer function is invisible to my oracle (static → no symbol) is the same C
keyword that, one scope deeper, makes the list persist across calls. Same word,
two completely different jobs, and both of them are in my way.
C's static is four or five unrelated features wearing one keyword, and this port
keeps running into two of them at once. The oracle can't name the function. The
function can't forget its list. Neither of those is a thing I get to change without
changing the program I promised to preserve, so I work around both and write down
exactly where the instrument went blind.
The four twins are in
ircd-common/src/s_bsd.rs (set_sock_opts,
check_init, check_clones, connect_inet — each unsafe fn, no pub); the
rename machinery that can't reach them is
ircd-testkit/build.rs (the nm -g filter); and the
first time this bit us is the lookup_confhost note in the
P6 progress log, under the phase plan in
PLAN.md. The oracle post is the cref_
rename these all lean on, the front door
is where the branch-coverage trap was first laid out, and
eating the file one function at a time
is where the de-static edit gets its justification.
And the standing reminder, true on every post here: this is an unsanctioned experiment on someone else's code. Leave the IRCNet team out of it. The twins I can't diff, the keyword I keep tripping over, and the branch I covered with a reading instead of a test are mine.