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 / 016-the-trampoline-with-no-riders.md
13 kB

title: "The trampoline with no riders" desc: "Post four called the ~25 variadic sender trampolines the last C standing and promised they'd die in P8 by deletion, not translation. P8 started. The first one out had zero C callers left, and pulling it was almost an anticlimax — which is exactly why I picked it." date: 2026-06-07#

Back in post four I spent two thousand words on a single asymmetry: stable Rust can call a C variadic but it cannot define one, which is why a family of about twenty-five sendto_* trampolines was going to outlive nearly every other line of C in this port. I ended that post insisting that "100% Rust" was a goal with a date on it — P8 — and that the trampolines would die there by deletion, not translation. Every call site that says sendto_one(to, ":%s NOTICE %s :%s", ...) gets rewritten to build the string with format! and hand it to a plain non-variadic Rust sender, and once nobody needs the ... the variadic has no reason to exist.

P8 started. This is the first one out, and the part worth telling is how little of a fight it put up. The reason it was easy is the whole point.

Pick the one nobody's calling#

The first trampoline I deleted is sendto_iauth (ircd/s_auth.c:120-170), the function that ships a line to the iauth slave process over its pipe. I did not pick it because it was important. I picked it because of one line of nm output: there are zero undefined references to sendto_iauth anywhere in the link set.

Unpacking that: by the time P8 starts, almost everything is already Rust — the whole point of the strangler-fig order was to port inward until the C is a hollow shell. Every caller of sendto_iauth had already become Rust over the preceding phases. The only C that still mentioned the function at all was inside #ifdef'd-out bodies — code that doesn't compile into the link object. So at the moment I came to delete it, the function had no riders. Twenty-four call sites, all of them Rust, all of them code I already control.

Cadey (coffee): I built this thing up in post four as a keystone horror, the most C-shaped C in the building, and then the first one came out like a loose tooth. No drama. I'd already done the hard part in P1 through P7 without noticing it was the hard part — I'd moved every caller across the seam one at a time, and deleting the function afterward was almost bookkeeping.

And this was deliberate. P8 has real monsters in it, and I wanted my first cut to be the one where I couldn't break a C caller I'd forgotten about, because there were none. You learn the mechanics on the safe one.

You don't translate the variadic, you move the va_start#

Here's the trick, and it's the same trick post four described from the other side. The reason you can't port a variadic is the va_start/va_arg machinery in its own signature. So you don't port it. You move that work to the callers, where Rust's format! already does the same job, and the function it leaves behind isn't variadic anymore.

The C sendto_iauth was a thin variadic shell around a worker that did a vsprintf and a write loop:

/* ircd/s_auth.c:120 */
int vsendto_iauth(char *pattern, va_list va)
{
    static char abuf[BUFSIZ], *p;
    int i, len;

    if (adfd < 0)
        return -1;

    vsprintf(abuf, pattern, va);    /* the va_list dies here */
    strcat(abuf, "\n");
    p = abuf;
    len = strlen(p);

    do {
        i = write(adfd, abuf, len);
        /* ... EAGAIN/EWOULDBLOCK and lost-slave handling ... */
        p += i;
        len -= i;
    } while (len > 0);

    return 0;
}

int sendto_iauth(char *pattern, ...)   /* the variadic wrapper */
{
    va_list va;
    va_start(va, pattern);
    int i = vsendto_iauth(pattern, va);
    va_end(va);
    return i;
}

Its Rust replacement just takes the finished bytes. No pattern, no ..., no va_list anywhere in sight:

// ircd-common/src/s_auth.rs:38
pub unsafe fn sendto_iauth(line: &[u8]) -> c_int {
    if adfd < 0 {
        return -1;
    }
    let mut abuf: Vec<u8> = Vec::with_capacity(line.len() + 1);
    abuf.extend_from_slice(line);
    abuf.push(b'\n');
    let mut len = abuf.len() as isize;
    loop {
        let mut i = libc::write(adfd, abuf.as_ptr() as *const c_void, len as usize) as isize;
        if i == -1 {
            let e = *libc::__errno_location();
            if e != libc::EAGAIN && e != libc::EWOULDBLOCK {
                sendto_flag(SCH_AUTH, c"Aiiie! lost slave authentication process".as_ptr() as *mut c_char);
                libc::close(adfd);
                adfd = -1;
                ircd_sys::bindings::start_iauth(0);
                return -1;
            }
            i = 0;
        }
        len -= i;
        if len <= 0 {
            break;
        }
    }
    0
}

And the formatting that used to live inside the C vsprintf moves out to the call sites, which were already Rust. Here's a real before/after from register_user:

// before — calling the C variadic
sendto_iauth(c"%d U %s".as_ptr() as *mut c_char, (*sptr).fd, (*user).username.as_ptr());

// after — format! at the call site, hand over the bytes
sendto_iauth(format!("{} U {}", (*sptr).fd, ia_str((*user).username.as_ptr())).as_bytes());

That ia_str is the one new helper the conversion needed. The %s arguments at these call sites are raw C char * pointers into client structs, and format! wants something it can print, so ia_str reads a NUL-terminated C string into an owned String (ircd-common/src/s_auth.rs:77). It's a lossy UTF-8 decode, which sounds scary until you remember the iauth pipe protocol is plain ASCII — the lossy read is byte-faithful to the C %s copy for every byte that can actually appear on that wire.

Faithful to a bug nobody can hit#

One wart in that C loop is there on purpose, and it's a nice small example of what "faithful port" actually commits you to.

Look at the write loop again. It sets p = abuf, then in the loop it calls write(adfd, abuf, len) — writing from abuf, the buffer base — but advances p += i, never abuf. The p cursor is dead. If a partial write ever happened, the next iteration would re-send the whole line from the start, duplicating the bytes it already wrote. It's a real bug.

It just can't fire. The lines sendto_iauth sends are tiny, the pipe to a local slave process accepts each one in a single write, and so the loop runs exactly once and the broken cursor never gets used. Thirty years of this code running in production agree.

Mara (hacker): The faithful-port question is always "is this observable?" A partial write on this fd is unreachable on the platforms anyone runs, so the bug has no behavior to be faithful to — but the structure of the loop is right there in the source, and re-deriving a "correct" loop would be me editing the program while claiming to translate it. So the Rust re-issues from the buffer base every iteration too. Same dead cursor, same harmlessness. If I'm ever wrong about it being unreachable, both worlds are wrong identically, and that's the contract.

Two things I left deliberately not differentially tested, and wrote them down rather than let a green checkmark imply more than it should. One is a debug line that formats raw heap-pointer addresses (-1 E last=%u start=%x …) — those differ between the two processes by construction, so there's nothing to diff. The other is the lost-slave hard-error branch, because I can't force a fatal write error deterministically from a test. Both are ported by reading the C against the Rust, which is weaker than a byte-diff, and saying so is the price of being able to trust the parts that are byte-diffed.

The oracle is allowed to be the thing I can't write#

Here's the part I find genuinely funny. The whole reason this function had to be a trampoline is that I can't define an extern "C" variadic in stable Rust. But the differential oracle — the cref_ renamed copy of the original C — is still C. It compiles from the unguarded s_auth.o. So cref_sendto_iauth is allowed to be exactly the variadic I'm not allowed to write.

Which makes the test a clean A/B. I build the same iauth line two ways — through the C oracle, still variadic, still formatting via vsprintf; and through the Rust core, fed pre-formatted bytes — each writing to its own socketpair, and I diff the bytes that land on the pipe:

// ircd-testkit/tests/sendto_iauth_diff.rs
// `<fd> U <user>` — the ident username push.
diff(b"42 U someuser", || {
    cref_sendto_iauth(c"%d U %s".as_ptr(), 42 as c_int, c"someuser".as_ptr() as *const c_char)
});

One side runs the C I deleted from the binary, the other runs the Rust that replaced it, and the harness checks the pipe bytes match. Every %s/%d format family that actually appears at the twenty-four call sites gets a case, plus the already-finished-buffer passthrough (where start_auth builds the whole C line with sprintf and hands it straight in), plus the adfd < 0 inverse where both worlds return -1 and write nothing. So the function is gone from the daemon but still pinned by a test that rebuilds it on demand whenever I want to check.

One down, and it was the cheap one#

So sendto_iauth is Rust now, with no variadic anywhere in its lineage. The C vsendto_iauth/sendto_iauth pair is #ifdef'd out of the link object behind PORT_S_AUTH_SENDTO_IAUTH_P8a (ircd-sys/build.rs:292), nm confirms the symbol is gone from the binary, and the only C left in s_auth.c is data globals. The oracle copy lives on in libcref.a for the test.

I'm not going to round this up, though, because rounding up is the one thing this whole series is supposed to not do. That's one trampoline. There are roughly twenty-four left, and the two that matter — sendto_one with something like 478 call sites, sendto_flag with around 255 — are the keystones, and they're hard for exactly the reason post four laid out. sendto_iauth writes one line to one pipe; there's no per-recipient anything. The iterating senders re-walk their va_list per target inside vsendpreprep, making a different formatting decision for each one, so there's no "format once at the call site" to hoist out the way there was here. The trick that made this one a loose tooth doesn't straightforwardly apply to them.

Cadey (aha): the honest read is that I picked the trampoline whose deletion taught me the least about the ones I'm scared of. That's fine. It taught me the mechanics — the seam-inversion, the ia_str bridge, the format-at-the-callsite rewrite, the oracle-stays-variadic test shape — on a function where a mistake couldn't take down a caller I'd forgotten. The keystones get those mechanics plus the part I haven't solved yet, which is what format! even means when the format depends on who's receiving it.

One other thing I'm holding off on. These call sites format with format! and raw {} placeholders, not with leveva's typed Message and identifier newtypes, even though leveva exists now. That's on purpose: sendto_iauth carries the iauth pipe protocol — <id> <category> <info> — not IRC messages, so the typed-message layer doesn't fit it. The typed senders show up alongside the IRC clusters, where the content of the line genuinely is an IRC message and there's something for the types to describe. Faithful bytes first. The nice types can wait for where they'd actually do something.

The first trampoline came out without a fight because I spent seven phases quietly moving everyone off it first. The hard ones are still up there, full of riders.


You'll find the port and its ia_str helper in ircd-common/src/s_auth.rs; the byte-diff is ircd-testkit/tests/sendto_iauth_diff.rs; and the per-trampoline plan, including why this was the cleanest first cut, is 2026-06-07-p8a-sendto_iauth, under the phase plan in PLAN.md. Post four is why these functions had to be C in the first place, post three is the cref_ rename the test leans on, and post five is the iauth process on the other end of this pipe.

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 trampolines, the one I pulled and the keystones I'm still nervous about, are mine.