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.

ircd.rs / docs / blog / 017-the-keystone-wasnt-the-monster.md
15 kB

title: "The keystone wasn't the monster" desc: "Post four and post sixteen both named sendto_one the scary one: ~478 call sites and a formatting decision made per recipient. When I finally pulled it, the per-recipient horror was in a different function entirely, and the only hard thing left was that there were 435 of them." date: 2026-06-08#

I ended post sixteen nervous. The first variadic trampoline had come out like a loose tooth, but I closed by naming the two I was actually scared of — sendto_one, with something like 478 call sites, and sendto_flag with around 255 — and admitting I hadn't solved the thing that made them frightening: a formatting decision made per recipient, deep in vsendpreprep, where there's no "format once at the call site" to hoist out.

P8 is done now. Every variadic sender is Rust. The keystone — sendto_one, the single-client sender behind very nearly every numeric reply the daemon emits — came out, and the part worth writing down is that the thing I was scared of wasn't in it.

Here's the punchline up front: I called sendto_one the keystone because of its call-site count, and that turned out to be the only hard thing about it. The per-recipient formatting horror — the real monster from post four — lives in a different function, and I'd already dealt with it a few cuts earlier without quite clocking that's where it had been hiding the whole time.

The thing I was actually afraid of#

Back up to post four. The reason a sendto_* function had to stay a C variadic was never the ... by itself — stable Rust can call those fine. It was vsendpreprep (send.c:388), the worker behind the prefix senders, the ones that emit :nick!user@host PRIVMSG .... That function re-walks its va_list once per recipient, because the prefix it builds differs for each one: a local client who knows you gets the full nick!user@host, a server downstream gets a bare :nick. You cannot format the line once and reuse it. The format depends on who's reading it.

That's the monster, and it's real — I'm not waving it away. But notice what it's a property of: vsendpreprep, and the senders that lean on it. sendto_one is not one of them.

sendto_one was the easy shape all along#

Look at what sendto_one actually does, stripped to bones. It's a variadic wrapper around vsendto_one, which calls vsendprep (send.c:385): take the pattern and args, format them once with libc vsprintf into a buffer, cap the body at 510 bytes, append CRLF, hand it to send_message for the one target. That's the whole function. One format, one delivery. There's no per-recipient branch because there's exactly one recipient.

Which makes it — mechanically — the same shape as sendto_iauth from post sixteen: a variadic shell wrapped around a format-once worker. The trick that made that one a loose tooth applies here unchanged. Move the va_start work out to the callers, where the arguments already live, and the function left behind takes finished bytes:

// ircd-common/src/send.rs:553
pub unsafe fn sendto_one(to: *mut aClient, body: &[u8]) -> c_int {
    let mut buf = [0u8; 520];
    let len = prep_line(body, &mut buf);
    send_message(to, buf.as_mut_ptr() as *mut c_char, len);
    len
}

prep_line is the faithful vsendprep: copy at most 510 body bytes, append \r\n, return the length. The 510 cap is the real one from the C, not a round number I liked — it's the line-length limit every IRC client expects, and getting it wrong by a byte shifts the truncation point on long lines. The core returns that prepped length because a couple of callers read it (two rlen += sendto_one(...) sites in channel.rs that tally how much they've queued).

Cadey (aha): I spent two posts treating sendto_one like it'd be the hard one, and the whole time it was sendto_iauth with a bigger guest list. The keystone isn't keystone-shaped. It's just popular.

I was wrong about needing leveva first#

There's a second thing I got wrong, and it's the more interesting mistake.

When I first sketched this rip-out I assumed sendto_one was blocked — that I couldn't touch it until I'd built leveva's typed Numeric layer over in P10. The reasoning: most of these call sites don't pass a literal format string. They pass reply(ERR_NOSUCHNICK), a lookup into the replies[] table that hands back a char * format string at runtime. So the format string is data, chosen at runtime, and you can't fold a runtime-chosen format into a compile-time wire! builder. Therefore, I told myself, I need the typed numeric layer first.

That's wrong, and it's wrong in a way worth being precise about. The delivery path I'm replacing — vsendprep — formats with libc vsprintf. Plain libc. Not the in-tree irc_vsprintf, just the C library's printf. So all I need to come out byte-identical is the same printf engine, and libc has a snprintf I can call straight from Rust. The runtime format string stops being a problem; I hand it to snprintf exactly the way C handed it to vsprintf, and the bytes match. No typed numeric layer required to be faithful. That's a P11 nicety, not a P8 blocker.

So that's all reply_one! is (send.rs:519): render the runtime format string and its args through libc snprintf into a scratch buffer, then hand the resulting C string to the non-variadic sendto_one core.

// ircd-common/src/send.rs:519
macro_rules! reply_one {
    ($to:expr, $fmt:expr $(, $arg:expr)* $(,)?) => {{
        let mut __rbuf = [0 as ::std::os::raw::c_char; 2048];
        ::libc::snprintf(
            __rbuf.as_mut_ptr(),
            __rbuf.len(),
            $fmt as *const ::std::os::raw::c_char,
            $($arg,)*
        );
        $crate::send::sendto_one($to, $crate::send::cstr_bytes(__rbuf.as_ptr()));
    }};
}

The buffer is oversized on purpose — 2048 bytes for a line that gets truncated to 510 — so its own cap never bites before the real one. It's the same printf engine, called the same way the C called it. That's the whole reason the bytes match, and it holds for the literal sites and the runtime ones alike.

Mara (hacker): The thing that makes this safe is narrow, and worth stating exactly. It's not "snprintf and vsprintf are both printf, close enough." It's that the C I'm matching also went through the libc engine — so I'm not reimplementing printf, I'm calling the identical implementation with the identical format string and args. If the original had used irc_vsprintf (the custom in-tree formatter, with its own quirks), this would not hold and I'd be back to needing a faithful port of that. The faithfulness comes from both paths sharing libc, not from snprintf being "standard."

Two builders, one core#

So the call sites split cleanly into two kinds, and they get two builders feeding one core. The 58 sites with a literal pattern — c":%s NOTICE %s :%s" and friends — use wire!, the raw-byte line builder, which stitches byte-literals and char * pointers together without routing through a lossy UTF-8 String. The roughly 375 sites that pull a format string out of replies[] use reply_one!. And two stragglers in channel.rs that read the return value call the core directly.

Here's why the split is forced, in one real pair. The reply helper is nothing but a replies[] lookup:

// ircd-common/src/s_user.rs:116
unsafe fn reply(i: u32) -> *mut c_char {
    *(addr_of!(replies) as *const *mut c_char).add(i as usize)
}

A real WHO reply — the classic 352 — threads that runtime-fetched format string and a dozen client fields through reply_one!:

// ircd-common/src/s_user.rs:473
crate::reply_one!(
    sptr,
    reply(RPL_WHOREPLY),
    me_name(),
    bad_to((*sptr).name),
    if repchan.is_null() { c"*".as_ptr() as *mut c_char } else { chname_ptr(repchan) },
    (*user).username.as_ptr(),
    (*user).host.as_ptr(),
    (*user).server,
    (*acptr).name,
    status_p,
    (*acptr).hopcount,
    (*(*user).servp).sid.as_ptr(),
    // ...
);

You can't wire! that. The format string reply(RPL_WHOREPLY) is a pointer fetched from a table at runtime; wire! would need the literal at compile time to interleave the pieces. reply_one! doesn't care — it hands the whole thing to snprintf the way the C handed it to vsprintf.

435 edits is a job you delegate#

So if the formatting wasn't hard and the blocker wasn't real, what was the work? Volume.

There were about 435 call sites across fifteen files. Post four guessed 478 and post sixteen repeated it; the real count when I got there was 435, which is the kind of small honesty I'd rather log than quietly correct. None of them is hard alone. Each is a mechanical rewrite: find the sendto_one(to, fmt, args...), decide whether it's a literal (wire!) or a replies[] lookup (reply_one!), rewrite it, move on. Four hundred and thirty-five times.

I did not do that by hand, and I'm not going to pretend I did. I ran it as a fan-out: fifteen agents, one per file, each told to convert only its file's call sites to the two builders, with a single central pass afterward that built the whole workspace and ran the differential. Edit-only workers, central verify. The judgment was in designing the two builders and proving the snprintf path byte-identical; the other 435 reps were the careful-but-boring kind of work you hand off and then check.

Cadey (coffee): There's a version of this post where I describe 435 hand edits as a feat of endurance. That'd be a lie. The feat was the two builders and the proof that snprintf matches vsprintf. Once that was nailed the edits were a loop, and I ran the loop in parallel. The judgment lives in the part you can't parallelize — deciding the line is byte-safe — not the part you can.

How you check 435 rewrites at once#

Verification is the same A/B-then-snapshot shape as every other cut in this phase, so I'll keep it short.

The original L1 test (sendto_one_diff.rs) built two parallel worlds — the Rust core fed pre-formatted bytes, and the still-variadic cref_sendto_one oracle, the renamed C copy — pointed each at a fresh client on a dead fd, and diffed the bytes that landed in the target's send queue. Because the oracle is still C, it's allowed to be the variadic I can't write, exactly as in post sixteen. Once that diff was green, the test was migrated to a self-contained snapshot of just the Rust side, because by P8's end the C oracle is going away.

Cases are chosen for what a format-once-then-truncate sender can get wrong: a literal wire! line, a reply_one! template with string args, the same with %d/%02d numerics through the printf engine, a NULL %s arg where glibc emits the literal (null) and I want that byte-for-byte, and the 510-byte truncation boundary:

// ircd-common/tests/sendto_one_snap.rs
#[test]
fn truncation_at_510() {
    let r = target();
    let long = vec![b'x'; 600];
    let fmt = c":%s NOTICE bob :%s".as_ptr();
    let longc = std::ffi::CString::new(long).unwrap();
    reply_one!(r, fmt, c"irc.test".as_ptr(), longc.as_ptr());
    let bytes = map_bytes(r);
    assert_eq!(bytes.len(), 512, "truncated to 510 + CRLF");
    insta::assert_debug_snapshot!("truncation_at_510", snap_bytes(&bytes));
}

Six hundred bytes in, 512 out — 510 of body plus the CRLF. That one assertion is the whole faithfulness contract for long lines: the C capped at 510, prep_line caps at 510, and if either ever drifts the byte count moves and the snapshot screams.

On top of the L1, the full golden suite — 111 scripted end-to-end scenarios — runs byte-for-byte identical, minus the two STATS flakes I've written about before, where the reference C prints uninitialized garbage and the Rust is the correct one. Since sendto_one sits behind nearly every numeric reply, those 111 scenarios are in effect 111 more tests of this one function — the coverage-by-ubiquity I leaned on in post ten, except here it's honest, because a botched format would corrupt visible replies in basically every scenario at once, loudly.

What the keystone actually was#

So the keystone is Rust, and the lesson is that I'd mislabeled it. sendto_one earned the name because it has the most call sites in the program, and call-site count measures how central a function is, not how hard it is to port. Different axes. The hard axis — the per-recipient formatting — belongs to vsendpreprep and the prefix senders, which I'd already pulled a few cuts earlier (sendto_prefix_one and its relatives), and which deserve their own post, because that's where the format-depends-on-the-reader problem actually got solved.

The honest shape of P8, now that it's finished: the famous function was the easy kind in big numbers, and the genuinely hard one is a function almost nobody's heard of. I spent two posts pointing at the wrong one. That's not a humblebrag — it's the thing the differential harness is for. I can be wrong about which function scares me, run the byte-diff, and find out the fear was misfiled. The test doesn't care what I named the keystone.

What's left after this is the prefix-sender story I keep gesturing at, and then the part where the whole C oracle gets switched off for good. The keystone's out. It just wasn't holding up what I thought it was.


The port is in ircd-common/src/send.rssendto_one at line 553, with the wire! builder and the reply_one! bridge just above it. The byte-diff lives on as ircd-common/tests/sendto_one_snap.rs, migrated from the original cref_ differential. The phase plan is PLAN.md. Post four is why these functions had to be C; post sixteen is the first one out and the format-at-the-callsite trick this reuses; post three is the cref_ rename the test leans on.

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 keystone I mislabeled and the monster I'd already pulled are both mine.