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.

Mechanically rewrite IRCnet ircd 2.11 from C to Rust (strangler fig)#

Context#

ircd.rs is the IRCnet ircd 2.11 IRC daemon: ~51k LOC of C across common/, ircd/, and iauth/, producing 5 binaries (ircd, iauth, chkconf, ircdwatch, mkpasswd). It is a single‑threaded select()/poll() server with a table‑driven command dispatcher, a bundled BIND DNS resolver, pervasive fixed‑size buffers, and ~230 strcpy/sprintf‑family calls — i.e. exactly the memory‑safety profile a Rust rewrite is meant to eliminate. There are no existing tests (only a clang‑format CI check).

Goal: convert the codebase to Rust module‑by‑module, keeping the program building and running at every step, with every important part fully tested against the original C as a reference oracle.

This plan reflects four locked decisions from the user:

  1. Faithful mechanical port now, then dedicated idiomatic‑Rust passes at the very end (after C is gone). ⚠️ Superseded (2026‑06‑08): decision 1 originally meant an in‑place idiomatic transformation of the mechanical port (the old P9–P12). The project instead forked a parallel greenfield idiomatic daemon, leveva, tested differentially against the now‑complete mechanical port. So the idiomatic end‑state is reached by growing leveva to parity and deleting the mechanical port, not by polishing it in place. See the reframed P9–P12 below.
  2. Rust‑driven build from day one: fn main() is Rust; remaining C compiles as a *-sys crate; we strangle inward.
  3. iauth rewritten first, standalone greenfield Rust binary speaking the identical pipe protocol.
  4. Full 4‑layer testing (differential unit, golden, fuzz, conformance) + a permanent C reference build in CI.

The leverage point: every foo.c already ships foo_ext.h (its public extern API) — 41 headers / ~417 symbols that form a ready‑made per‑module C ABI contract. Dispatch is a function‑pointer table (msgtab[] in common/parse.c). These two facts make the strangler seam tractable.


The migration model (the seam)#

Workspace. A cargo workspace at repo root, coexisting with the untouched autoconf tree:

  • ircd-sys/build.rs runs support/configure to generate setup.h/config.h, then cc::Build compiles the C objects (mirroring IRCD_COMMON_OBJS+IRCD_OBJS from support/Makefile.in:144‑149) minus whatever has been ported, plus bindgen over a wrapper.h that #includes common/struct_def.h + all *_ext.h/*_def.h.
  • ircd-rs/ — the binary crate; src/main.rs owns the real fn main(). Ported modules live here / in leaf sub‑crates.
  • iauth-rs/ — standalone greenfield iauth binary (no ircd-sys dep).
  • ircd-testkit/ — the L1–L4 harness. ircd-fuzz/cargo-fuzz targets.

Runnable baseline (day one). Compile the C main (ircd/ircd.c:766) as c_ircd_main via cc::Build::define("main", Some("c_ircd_main")). Rust main() marshals argc/argv and calls it. The product is byte‑for‑byte the C ircd, just entered from Rust. (Also generate version.c from ircd/version.c.SH.in in build.rs and compile it — the C link needs version.o.)

Strangling inward (per module). To port foo.c: read foo_ext.h for the exact ABI; write Rust #[no_mangle] pub extern "C" fn … (and #[no_mangle] pub static … for exported data) matching the exact signatures (note: C uses mutable char*, not const); remove foo.c from build.rs's source list. Because the .c is dropped (not overridden), exactly one definition of each symbol exists at all times → no duplicate‑symbol/LTO games, program always links. Cross‑language LTO stays off during migration. CI builds + boots the server after every move.

Single source of truth for compile defines. build.rs must mirror Makefile.in per‑object defines (e.g. -DFNAME_USERLOG=…, -DIRCDCONF_PATH=…, CLIENT_COMPILE), and feed the identical -include config.h/setup.h + -D set to cc::Build, to bindgen, and to the reference‑archive build. A divergence here silently corrupts aClient layout. Default config to lock: USE_IAUTH on, XLINE off, ZIP_LINKS off, AFINET=AF_INET6 (so IN_ADDR = libc in6_addr).

Non‑ircd binaries are out of strangler scope. ircdwatch, chkconf, mkpasswd and the cl*.o duplicate‑compiled common objects (-DCLIENT_COMPILE) stay in the permanent C reference tree only; they are never routed through ircd-sys. chkconf gets a Rust port opportunistically in P6.


Keystone hazards & how we neutralize them (all verified in‑tree)#

Hazard Reality (verified) Neutralization
C‑variadic exports ~14 in send_ext.h (sendto_one, sendto_flag, …) + irc_sprintf; stable Rust cannot define an extern "C" variadic fn. Each already has a va_list sibling (vsendto_one, irc_vsprintf). Keep a thin C trampoline per variadic symbol (cc‑compiled): va_start → format via irc_vsprintf into a BUFSIZE buffer → call a non‑variadic Rust core rs_sendto_one_str(to, msg). Rust never sees va_list. These ~25 trampolines persist through the faithful port and are deleted in P8 (call sites switch to Rust format!). ⇒ "100% Rust" is a P8 goal, not P7.
aClient interior self‑pointers make_client sets cptr->name = cptr->namebuf (list.c:121,226); me is a by‑value global with client = &me. Invariant: never bind/move/copy an aClient by value in Rust. All access through stable raw pointers; name set via addr_of_mut!((*c).namebuf).
CLIENT_REMOTE_SIZE trick Remote clients allocated offsetof(aClient,count) bytes; local‑only tail must never be touched when from != self. Port make_client to branch on offset_of!(aClient,count) vs size_of::<aClient>(); build‑test asserts both equal the C offsetof/sizeof, plus offset_of! of every local‑tail field (count,buffer,sendQ,recvQ,ip,hostp,…).
repr(C) layout drift Layout gated by USE_IAUTH/XLINE/ZIP_LINKS/INET6/TKLINE/…. bindgen + cc share one define set (above); compile‑time static_asserts on size/offset; incomplete‑array externs (local[], tolowertab[]) get element counts from bindgen constants (MAXCONNECTIONS) with a C static_assert that sizeof(arr)/sizeof(arr[0]) matches the Rust length.
Mutable dispatch table msgtab[] entries are struct Cmd{handler;count;rcount;bytes;rbytes} mutated every dispatch, read by STATS. Rust static mut msgtab: [Message; …] of the bindgen type, exact field order; handlers point at still‑C m_* until ported; L1 test asserts identical count/bytes after N dispatches.
Function‑like macros bindgen drops IsServer, MyConnect, MyFree, MyMalloc, DBufLength, SetXxx, … Hand‑audited macros.rs of inline equivalents, each cross‑checked by an L1 test (C macro via tiny accessor vs Rust inline).
Intrusive lists + manual refcounts next/prev/hnext, Link union, anUser.refcnt/aServer.refcnt. Invariant during faithful port: lists stay raw‑pointer intrusive lists — no Box/Drop/Rust ownership; refcnt inc/dec byte‑for‑byte as C. Enum‑Link and real ownership are P8 only.
find_* ownership find_client/uid/server/name/person live in parse.c:144‑417 (not hash.c), wrapping hash_find_*. Port these with parse (P4); hash_find_* with hash (P2).

The 4‑layer test harness (built in P0, used every phase)#

  • Permanent C reference build. The pristine C tree stays in‑repo and buildable for the project's life; CI builds it every run as the oracle.
  • L1 — differential unit. Build the reference .c into a separate static archive whose symbols are bulk‑renamed (objcopy --redefine-syms / --prefix-symbols=cref_, applied over the full transitive symbol set so internal callers follow the rename), exporting cref_match, cref_MyMalloc, … Link that archive beside the Rust crate in the test binary and assert Rust‑vs‑cref_ identical outputs (and, for data structures, identical bytes/pointer‑walk order/counters). This is the workhorse for leaf/data/format tiers.
  • L2 — golden / characterization. Run reference‑C ircd and the in‑progress Rust ircd side‑by‑side, diff wire output. Determinism is load‑bearing:
    • Clock pinning: LD_PRELOAD shim overriding time()/gettimeofday() to a fixed value in both processes (the loop reads time(NULL) directly every iteration — a tune/faketime file is insufficient).
    • Output canonicalizer: mask known‑volatile token positions (WHOISIDLE idle/signon, STATSUPTIME, RPL_TIME/RPL_CREATED, fd‑derived ids). Gate = byte‑identical after clock‑pin + canonicalization.
    • Strict serialization: drive scripted clients one action at a time with barriers — hash buckets prepend at head, so WHO/NAMES/LINKS/LIST order follows arrival order. No concurrent clients.
    • S2S: server‑burst diffing uses a TS‑aware linking peer (or link‑against‑reference‑C‑and‑observe), not canned replay.
  • L3 — differential fuzzing. Feed identical bytes through the real reassembly path (dbuf_getmsg in packet.c, to hit 512‑byte truncation/framing) to C parse() vs Rust; diff the full post‑state — mutated aClient bytes, ircstp counters, and selected handler identity — not just parv[].
  • L4 — conformance. Rescoped: (1) extract the numeric→format table from s_err.c replies[] and assert the Rust port reproduces it (an L1 test); (2) curated scripted‑client assertions on numeric code + arg arity/semantics from doc/whox.md, doc/whoistls.txt, rfc2812.txt. (There is no machine‑readable RFC oracle; replies[] is the truth.)

Phases#

Through P7, each phase keeps the program building + L2/L4 green ("Drop" = remove the .c from build.rs). P8 retires the oracle harness after a final green run.

⚠️ Roadmap pivot (2026‑06‑08). P9–P12 below are reframed. The original plan transformed the mechanical port (ircd-common) into idiomatic Rust in place. Instead the project forked a greenfield idiomatic daemon, leveva (its own crate: async tokio, rustls TLS, KDL config, typed identifier strings — already ~6.3k LOC of foundations). The strangler pattern now repeats one level up: ircd-common (the verified‑faithful mechanical port) is the behavioral oracle for leveva, exactly as the C tree was the oracle for ircd-common. leveva-integration holds the cross‑crate differential tests. The idiomatic end‑state is reached by growing leveva to feature parity, differentially pinned against ircd-common, then deleting the mechanical port (ircd-common/ircd-rs + the C‑era iauth-rs scaffolding). An oracle only needs to keep building + passing its tests — it does not need to be idiomatic, so the in‑place cleanup (old P9) is retired as throwaway work.

Phase Modules Approach & gotchas Test gate (exit)
P0 — Scaffold + baseline + harness 🔶 PARTIAL ircd-sys (cc+bindgen, mainc_ircd_main, version.c.SH), ircd-rs/main.rs, ircd-testkit, ircd-fuzz, frozen C reference build Establish the Rust‑driven build; lock the configure‑define single source of truth; emit layout static_asserts; stand up all 4 layers. Done as P0a (baseline), P0b (bindgen + layout drift-net), P0c (L1 differential harness), P0‑L2 (golden harness ircd-golden: reference‑C binary + scripted‑client diff; registration scenario byte‑identical). Not yet built: L3 fuzz (ircd-fuzz), L4 conformance, iauth-rs. cargo build boots an ircd that links iauth and diffs byte‑identical (post‑canonicalization) vs reference‑C across the full scripted suite; layout asserts pass. (L1 + layout MET; L2/L4 gate pending those layers.)
P‑IAUTH — greenfield iauth (parallel, starts at P0) 🔶 ALL 5 MODULES DONE iauth/*iauth-rs Idiomatic Rust allowed (separate process, narrow text ABI). Reimplement event loop (mio), cldata fd‑slab, the <id> <category> <info> parser, the 8‑fn aModule vtable as a trait with 5 impls (dnsbl/socks/webproxy/rfc931/pipe); replace dlopen DSM with a static registry from iauth.conf. Drive C‑iauth vs iauth‑rs with an identical scripted <id> sequence (ids are fixed in input, since <id>=ircd fd); byte‑identical replies per category + per‑module fixtures; end‑to‑end ircd+iauth‑rs golden identical.
P1 — Leaf / pure utils ✅ DONE common/match.c (+tolowertab/touppertab/char_atribs), dbuf.c, pure subset of support.c (mystrdup/strtoken/myctime/mybasename/inetntop/inetpton/irc_memcmp). irc_sprintf.cdeferred to P8 (variadic va_arg engine; stable Rust can't consume va_list, and it has no post-format core to hand a string to Rust → stays C, no trampoline). snprintf_append, dgets, make_isupport, ipv6string also stay C for now. Faithful; exact char* signatures; tables #[no_mangle] static [u8;256] (byte-equality asserted); dbuf owns poolsize/freelist. support.c partial port via inert #ifndef PORT_SUPPORT_P1 guards + a support_link.o second-compile substituted into the link set; MyMalloc/MyRealloc/MyFree + outofmemory() defer to P2. MET: L1 zero-diff over all corpora (match incl. tables + MAX_ITERATIONS cap; dbuf incl. getmsg framing + over-poolsize rollback; support incl. 8-case inet round-trip). Rust-driven ircd binary links; layout net green; 0 warnings. (L3 fuzz / L2/L4 not built yet — deferred from P0.)
P2 — Data & lookup ✅ DONE patricia.c, class.c, whowas.c (ring; m_whowas stays C), hash.c (6 tables; m_hash stays C), list.c (allocators, make_client, svrtop/numclients) + MyMalloc/MyRealloc + outofmemory Faithful; preserved intrusive pointers + refcounts (raw‑pointer invariant); replicated CLIENT_REMOTE_SIZE branch + hash iteration order exactly. Verified findings: ENABLE_CIDR_LIMITS ON (patricia live, add_class carries cidrlen_s); USE_HOSTHASH+USE_IPHASH ON (6 tables); MyFree is a macro (trio = MyMalloc+MyRealloc+outofmemory). m_whowas/m_hash kept in C via whowas_link.o/hash_link.o partial‑ports (-DPORT_*_P2). MET: L1 zero‑diff over all corpora (patricia tree walk; class list + CIDR; whowas ring/index + uwas; hash client/channel/uid bucket chains + counters + stored hashv; make_client local/remote self‑pointers). Rust‑driven ircd binary links with list.o dropped; layout net green; 0 warnings. whowas/class notification (m_whowas/report_classes) sendto_* path deferred to post‑P3 (L2).
P3 — Transport & format ✅ DONE s_err.c (replies[]), s_id.c (UID/SID/CID), s_numeric.c (do_numeric), send.c delivery core (send_message/send_queued/flush_*). Finding: the whole sendto_*/esendto_* family is irreducibly C trampolines (variadic; vsendpreprep consumes va_arg per‑recipient) → no non‑variadic cores extractable; senders + sendto_flog/logfiles/setup_svchans stay C (move to P7/P8). Faithful; replies[] as Rust static (dumped from the oracle so config‑gated #if entries resolve); exact UID/SID base‑N; send.c partial‑ported via send_link.o (-DPORT_SEND_P3, dead_link exposed). ZIP/USE_SERVICES/LOG_SERVER_CHANNELS/etc. all OFF. MET (L1): zero‑diff vs cref_ on replies[] (all 1000), ltoid/idtol/sid_valid/cid_ok/check_uid, do_numeric guards, send_message sendQ buffering + send_queued early returns. L2 (post‑P3): do_numeric/sender wire broadcasts + deliver_it write path.
P4 — Parser & dispatch ✅ DONE common/parse.c (tokenizer, msgtab[], all 11 find_* wrappers, getfield, m_nop/m_nopriv/m_unreg/m_reg, find_sender/cancel_clients/remove_unknown) Faithful; msgtab as #[no_mangle] static mut [Message; 66] (config‑resolved: TKLINE on, SERVSET/KLINE/SIDTRACE off, MOTD→m_unreg; mutable counters); the ~60 real m_* handlers stay C and are referenced as fn pointers; parse.o dropped outright (every symbol ported — no parse_link.o). Rust calls the C variadic senders (sendto_one/sendto_flag/sendto_serv_butone) via bindgen decls (the s_numeric.rs pattern). Exact tokenize semantics (512 trunc, leading/trailing colon, empty trailing param, MPAR cap). Deliverable: per‑handler‑file static‑symbol cluster map (specs/2026-06-02-p5-handler-cluster-map.md). MET (L1): zero‑diff vs cref_ on the msgtab[] structure (cmd/min/max per row + row count), getfield (delimiter/escape/trailing/empty), find_mask/find_name fallbacks, and parse()'s no‑server paths (IsDead, empty, unknown‑cmd, numeric, ENCAP→m_nop dispatch — return code + cptr.flags + ircstp deltas). Full parse() post‑state + hash‑populated find_* = L3/L2 (need the server fixture).
P5 — Command handlers (bulk) ✅ DONE (all m_* handlers; s_auth.c socket/ident/iauth‑pipe I/O → P7) channel.c, s_user.c+s_auth.c, s_serv.c+s_service.c+s_zip.c (zlib→flate2, bit‑exact), s_misc.c (exit_client), s_debug.c.
Per‑cluster sub‑phase progress log (P5a–P5whowas, 56 clusters): see PLAN-P5-progress.md. channel.c (P5hh), s_serv.c (P5bbb) and s_user.c (P5ccc) are now FULLY ported → all three .os dropped. The P2‑deferred m_whowas handler is also ported (P5whowas) → whowas.o dropped, whowas.c fully Rust; the P2‑deferred m_hash (HAZH) handler is ported (P5hash) → hash.o dropped, the hash_link.o partial‑compile gone. Of the P5 TUs, only s_auth.c remains C (no m_* handlers; pure socket/ident/iauth‑pipe I/O → deferred to P7/P‑IAUTH).
Faithful, but cluster‑based, not per‑function: channel.c has 37 file‑statics (set_mode, can_join, get_channel, …); migrate each connected component over shared statics as a unit via the msgtab fn‑pointer seam. s_zip.o is empty (ZIP_LINKS off) → drop for free, no port. Per‑cluster L2 golden + L4 conformance byte‑identical incl. WHO/WHOIS/WHOX, full server‑burst/netsplit; zlib bytes match.
P6 — Config & DNS ✅ DONE s_conf.c+config_read.c, chkconf.c, res.c+res_comp.c+res_init.c+res_mkquery.c.
Per‑cluster sub‑phase progress log (P6a–P6hh): see PLAN-P6-progress.md (one‑line summaries) and docs/progress-log/p6.md (full entries). Outcome: s_conf.c (config grammar + rehash + the conf/kconf/networkname globals) and the whole resolver (res.c + the res_comp.c/res_init.c/res_mkquery.c leaves) are now FULLY Rust → all .os dropped; every msgtab handler is Rust; chkconf.c is a standalone chkconf-rs binary. → P6 COMPLETE.
Faithful config grammar + rehash. Resolver: faithful port or hickory‑dns behind res_ext.h, but golden runs use a fixture nameserver for determinism. Config‑parse + chkconf L1/L3 zero‑diff on fixtures; resolver encode/decode identical; L2/L4 identical with fixture DNS.
P7 — I/O core + event loop + glue (last) ✅ DONE (all C logic ported) s_bsd.c (select/poll loop, local[], highest_fd, timeofday, FdAry), packet.c, bsd.c, ircd.c (main, io_loop, signals, rehash/restart, tune file), s_auth.c (socket/ident/iauth‑pipe I/O, deferred from P5).
Per‑cluster sub‑phase progress log (P7a–P7oo): see PLAN-P7-progress.md (one‑line summaries) and docs/progress-log/p7.md (full entries). Ported bottom‑up (leaf‑first), as in P6. Done: bsd.c (deliver_it) + packet.c (dopacket) dropped outright; s_bsd.c fully drained of logic (P7d–P7ff: socket/file leaves → fd/connection teardown → listeners → add_connection/connect_serverstart_iauth/daemonize → the read_message select/poll event loop) — only its data globals remain; s_auth.c ident/iauth‑pipe I/O ported (P7u–P7y) — only the variadic sendto_iauth/vsendto_iauth remain; ircd.c fully drained of logic (P7c/P7t/P7gg–P7oo: ircd_writetune/ircd_readtune/calculate_preference/try_connections/check_pings/delayed_kills/setup_me + the signal‑handler/restart/server_reboot cluster + setup_signals + io_loop + the bad_command/open_debugfile CLI helpers + c_ircd_main the boot spine (P7oo) via ircd_link.o) — only its data globals remain. ALL C logic is now Rustircd_link.o/s_bsd_link.o/s_auth_link.o/send_link.o/support_link.o hold only data globals + the ~25 variadic sender trampolines.
Faithful: keep single‑threaded select/poll via mio (not tokio); preserve per‑client state machine; globals become Rust‑owned statics; signals via signal‑hook/nix + atomics. Removes c_ircd_main. All C logic gone (only variadic trampolines remain); full L2+L3+L4 + soak byte‑identical under connect storms, partial reads, sendQ backpressure, netsplit, rehash, restart.
P8 — Delete the C + retire the oracle harness ✅ DONE (per-sub-phase: PLAN-P8-progress.md + docs/progress-log/p8.md) remaining C (the ~25 variadic sender trampolines + residual data globals) + ircd-sys C build + ircd-golden (L2) + ircd-testkit/cref_* archives (L1) + the frozen reference‑C tree With all C logic already Rust (P7 exit), delete the last C: replace the variadic sender trampolines with a non‑variadic Rust sender API at every call site (faithful wire!/reply_one!/format! first; leveva typed messages adopted alongside the IRC senders), drop the residual data globals via the data‑symbol seam (bindgen extern decls resolve to #[no_mangle] Rust defs at link), then drop all C compilation from ircd-sys (no more cc::Build), retire the differential harness (cref_*/L1 ircd-testkit/ircd-golden L2 can no longer be built once the oracle is gone), and migrate the load‑bearing tests into ircd-common as self‑contained Rust tests (own fixtures, no cref_ oracle). Progress (P8a–P8r — full entries in the progress logs): every variadic sender trampoline is now Rust (P8a–P8m, incl. the keystones sendto_one/sendto_flag/esendto_*), and the five data‑global .os are dropped outright (s_auth.o iauth globals P8n, send.o P8o, s_bsd.o local[]/highest_fd/… P8p, ircd.o me/client/timers/… P8q). P8r dropped the last C‑logic TU support.odgets/make_isupport ported to Rust + the ipv6string/minus_one data globals defined as #[no_mangle] statics (snprintf_append is dead in Rust — WHOX is field‑by‑field — so it died with the .o; irc_sprintf is deleted not ported, in P11). Every ircd C TU is now Rust: the link set holds only the generated version.o + the L1 harness's ctruth.o. P8s migrated all 115 L1 cref_ differentials into self‑contained ircd-common insta snapshot tests (drive only the Rust port via link_anchor(), no oracle; soundness via the capture chain) — the suite is green + 0 warnings. P8t ported the last generated C TU version.c to Rust (ircd-common/src/version.rs): the data globals generation/creation/pass_version/infotext/isupport are now #[no_mangle] statics via the data‑symbol seam — generation/creation sourced from crate metadata at build time (CARGO_PKG_VERSION + an ircd-common/build.rs UTC build stamp), pass_version/infotext copied verbatim. version.c.SH generation + the version.o compile/archive are gone from ircd-sys/build.rs; libircd_c.a now holds only the L1 harness ctruth.o. (creation is now genuinely build‑time volatile, so the golden canonicalizer masks RPL_CREATED 003, matching the existing 371 Birth‑Date rule.) P8u (FINAL) retired the oracle. Ran the differential suite a last time green — L1 ircd-testkit all pass; L2 ircd-golden 86 pass + only the documented reference‑C‑garbage s_serv_stats flake (where Rust is the correct side) — then dropped all C compilation from ircd-sys/build.rs: the make object build, the cref_*/ctruth.o/res.o recompiles, the libircd_c.a archive, and the whole‑archive link + -lz/-lm/-lcrypt (none needed by Rust — pow resolves from glibc 2.29+). build.rs now only runs configure + bindgen over the C headers (the struct view ircd-common still uses until P9–P12) and expands the install‑path Makefile vars. ircd-testkit (L1) + ircd-golden (L2) are excluded from the workspace (mothballed, git‑recoverable); the ctruth.c/layout.rs drift‑net is deleted. The workspace is now 100% Rust — zero C TUs compiled. P8v (literal end of C) deleted the C source tree itself + retired ircd-sys: froze the generated bindings.rs into a committed ircd-common/src/bindings.rs (a self‑alias extern crate self as ircd_sys; keeps every ircd_sys::bindings::* path resolving, zero src churn) + the install‑path consts as literals, then deleted ircd-sys, the mothballed ircd-testkit/ircd-golden oracle crates, the iauth-rs C‑iauth differential, and all C source (common/ ircd/ iauth/ support/ contrib/ cbuild/ configure .clang-format* clangformat.yml). ircd-rs calls ircd_common::ircd::c_ircd_main directly. Not one line of C remains in the repo. MET. Final differential run green; oracle mothballed; cargo build --workspace 0 warnings links pure Rust; cargo test --workspace 695 pass / 0 fail (P8v; was 698 before deleting the C‑oracle tests); the ircd-common snapshot tests carry the reference‑C correctness forward. C source tree deleted — repo is 100% Rust at the file level.
P9 — std‑library cleanup ❌ RETIRED (2026‑06‑08) — (was: whole Rust tree) Skipped by decision — superseded by the leveva greenfield. P9 assumed an in‑place idiomatic transformation of the mechanical port (MyMalloc/MyFreeBox/Vec, dbuf freelist→owned, raw libcstd/nix, [c_char;N][u8;N]). Because the idiomatic end‑state is now a separate greenfield crate (leveva), ircd-common is throwaway code (the oracle, deleted at parity), so polishing it earns nothing. Nothing was implemented; the row is kept for provenance. n/a — retired.
P10 — leveva greenfield foundations 🔶 IN PROGRESS (docs/progress-log/p10.md) new leveva crate (lib) + leveva-integration differential tests The idiomatic‑Rust product, built from scratch (not transformed from the port). Done so far: RFC 1459 case‑folding (casemap); typed identifier strings (IrcStr/IrcString + Nick/ChanName/Uid/Sid/Cid/ServerName/UserName/HostName validating newtypes via ident_newtype!); Message/MessageBuilder; the full Numeric reply/error enum (184 codes, discriminant = wire code); a generic safe patricia trie; glob matching; mode; KDL config (model/parse/password/privilege); rustls/tokio tls endpoint with hot reload; ident. The async boot path (main.rs) binds listeners + watches certs but currently drops accepted connections — no protocol layer yet. Idiomatic Rust allowed everywhere; pulls real crates (tokio/rustls/kdl/nom/clap). cargo test -p leveva green + clippy clean per module; leveva-integration pins each greenfield reimplementation against ircd-common (the oracle) within their shared domain (documented divergences asserted explicitly).
P11 — leveva protocol layer → feature parity 🔶 IN PROGRESS (docs/progress-log/p11.md) leveva (client/server state machine + handlers) The bulk of the remaining work: the connection + registration state machine, the post‑registration command handlers (JOIN/PRIVMSG/MODE/KICK/TOPIC/WHO/WHOIS/…), channels + channel/user modes, the S2S link/burst protocol (UID‑based: UNICK/NJOIN/SAVE/EOB), and leveva-iauth (native async auth — dnsbl/socks/webproxy/pipe/ident). Each slice is built idiomatically (owned per‑connection state, typed identifiers end‑to‑end, Result/thiserror, format!/typed builders — no sprintf/global buf), unit‑tested + boot‑golden + proptest‑fuzzed, and differentially pinned against ircd-common (or iauth-rs for auth) wherever a pure oracle entry point exists — structural skeleton + documented divergences, since leveva emits a clean modern burst rather than a byte copy. Done: 290 slices (290 = REMOVE: charybdis-style chanop force-part (extensions/m_remove.c) — a channel operator forcibly removes a member who, on the wire, appears to PART rather than be KICKed (so client auto-rejoin-on-kick scripts don't fire); command/remove.rs reuses Channels::kick_gate/kick verbatim so it inherits every KICK rule (403/442/482 chanop gate, 401/441, elemental rank protection, +Y official-join immunity, cross-product, 461), but emits :victim!user@host PART <chan> :requested by <remover> [(<reason>)] (prefix = the victim's own mask via Registry::record_of, works local+remote) echoed to the remover + delivered to all local members incl. the victim, propagated S2S as the victim's PART (s2s::relay::local_part) and recorded to chat-history as a PART; a normal chanop command (no oper privilege), the sibling of KICK/the SA*-force family; leveva-native (the C oracle has no REMOVE) → no differential, the "requested by" wording is leveva's; REMOVE.md help + COMMANDS allowlist + index.md; golden_remove + remove_proptest (2 props); 289 = XLINE/UNXLINE: charybdis realname (gecos) operator ban — the realname sibling of K-line/D-line, completing the runtime ban quartet K/D/X/RESV; bolt-for-bolt the DLINE plane (slice 284) with a single-token gecos matcher (matching::matches) instead of CIDR — leveva::xline::{Xline,XlineStore} (SQLite write-through + boot reload), command/xline.rs (461→OperPrivilege::Xline 481, reap local matching realnames with 465+ERROR), s2s/xline.rs ENCAP * XLINE/UNXLINE propagation + xline_burst re-assertion, registration X-line gate in finalize_registration after the K-line/config-ban gates, STATS x247 RPL_STATSXLINE; lights up the dead xline_exempt flag end-to-end (Admission/ClientRecord.xline_exempt + set_xline_exempt, the dedicated exemption distinct from kline-exempt); divergences = single-token-glob mask (vs charybdis spaced gecos) + dedicated xline-exempt + leveva-native no-differential; golden_xline + xline_proptest (6 props); 288 = TESTMASK: TESTMASK <[nick!]user@host> [<gecos>] — a charybdis-style read-only operator diagnostic that counts how many connected clients match a hostmask (and optional realname glob), split into local vs remote; the population sibling of TESTLINE (285, which reports bans), together completing charybdis's test* diagnostic family. 461 param gate before the plain oper-bit gate (481); a malformed mask (no @, or empty user/host) → NOTICE :Invalid parameters (faithful, not a numeric); a registry sweep glob-matches nick/user/host(+orighost)/gecos (nick & gecos default *) and tallies is_local_uid → one 727 RPL_TESTMASKGECOS <lcount> <gcount> <nick>!<user>@<host> <gecos> :Local/remote clients match; new numeric 727 (charybdis's 724 is dead — no format/caller), pure parse_mask fuzz seam, TESTMASK.md help; leveva-native, local-only (no S2S, mirroring charybdis); confirmed against cloned charybdis m_testmask.c/messages.h; 287 = CHANTRACE: CHANTRACE <#channel> — the channel-scoped sibling of TRACE (26)/ETRACE/MASKTRACE (286), completing the trace quartet: reports every member of a named channel in ETRACE's extended 708 RPL_ETRACEFULL column format, folded-nick order, closing 262 RPL_TRACEEND; gate is 461 (missing channel) → 403 (no such channel, existence before membership) → 442 unless the requester is a member or holds OperPrivilege::Trace (the operspy-equivalent bypass; leveva has no operspy ! prefix); reuses etrace::etrace_line, no new numeric; CHANTRACE.md help; leveva-native (2.11 has no CHANTRACE), reports the full roster incl. remote members (unlike local-only ETRACE/MASKTRACE) but no S2S propagation, no IP-hiding (host==host, no separate IP column); 286 = MASKTRACE: MASKTRACE <nick!user@host mask> [<gecos mask>] — a charybdis-style mask-filtered extended trace, the sibling of TRACE (26) and ETRACE: reports every local client whose nick!user@host glob-matches (and optionally whose realname matches a 2nd gecos glob) in ETRACE's extended column format; 461 param gate before the OperPrivilege::Trace oper gate (481), one reused 708 RPL_ETRACEFULL line per match in folded-nick order via matching::HostMask + the promoted etrace::etrace_line, closing with 262 RPL_TRACEEND (no new numeric); MASKTRACE.md help; leveva-native (2.11 has no MASKTRACE), local-only (no S2S); 285 = TESTLINE: `TESTLINE <[nick!]user@host ip/cidr
P12 — Retire the mechanical port; leveva is the product ✅ DONE (2026‑06‑13) (docs/progress-log/p12.md) deleted ircd-common/ircd-rs + C‑era iauth-rs + leveva-integration + the mechanical port's deployment rigging; workspace = leveva + leveva-iauth Ran the differential suite a final time green (cargo test -p leveva-integration, skeleton‑identical to the oracle), then deleted the mechanical port (ircd-common, ircd-rs), its C‑era auth scaffolding (iauth-rs, long since subsumed by the native leveva-iauth), and the oracle differential crate (leveva-integration). The oracle tests retired with their oracle — the load‑bearing behavior was already carried by leveva's self‑contained 212‑file golden + proptest suite (verified: leveva/leveva-iauth have zero Cargo/use dep on the deleted crates). Also stripped the mechanical port's deployment rigging (docker/Dockerfile.ircd, the ircd bake target, the entire docs/k8s/ example network) and updated the README to describe the finished strangler. This is the literal end of the strangler — same lifecycle the C tree had at P8. MET. Final differential run green; ircd-common/ircd-rs/iauth-rs/leveva-integration deleted; cargo build --workspace 0 warnings; cargo clippy --workspace --tests clean; cargo test -p leveva -p leveva-iauth green — the behavioral guarantees carry forward. The workspace is now leveva + leveva-iauth.

Progress log#

The per-phase progress log lives in docs/progress-log/, one file per top-level phase. Each P5 command-handler cluster appends an entry to p5.md (see the command-cluster-port skill).


Key decisions & open risks#

  • "100% Rust" is P8, not P7. ~25 trivial C variadic trampolines persist through the faithful port (stable Rust can't define variadic extern "C"). They are the last C deleted — P8 replaces them with a non‑variadic Rust sender API at every call site (the full format!‑based cleanup of string formatting then completes in P11). (Alternative: nightly c_variadic — not recommended; pins the toolchain.)
  • Determinism is the project's spine. Hash/list iteration order, clocks, UID/SID sequence, DNS, and fd ordering must all be pinned, or L2 becomes noisy and stops catching regressions. A "faithful" port that changes iteration order passes L1 but fails L2 — by design.
  • zlib bit‑exactness (ZIP_LINKS server links): flate2 must match compression level/strategy/flush points, or P5 golden fails. Validate early in P5.
  • Resolver swap risk: hickory‑dns caching/timeout drift is visible via WHOIS/WHO hostnames; keep a faithful res*.c port as fallback if golden diffs appear (P6).
  • crypt() legacy: keep libc crypt for oper‑password dual‑validation; divergence locks out opers; availability varies by platform.
  • Reference‑build longevity: the pristine C tree must keep compiling as the Rust product diverges; OS/toolchain drift is a maintenance cost (pin a container image).

Verification (end‑to‑end)#

  1. Per module: cargo test -p <crate> runs L1 differential (Rust vs cref_…) to zero diffs; cargo fuzz run <target> for parser/format/config tiers.
  2. Per phase: ircd-testkit boots reference‑C + Rust ircd under the LD_PRELOAD clock shim, replays the scripted serialized session suite (registration, JOIN/PRIVMSG/MODE/KICK/TOPIC, WHO/WHOIS/WHOX, OPER/STATS, server link burst, netsplit, rehash, restart), and asserts canonicalized wire output is byte‑identical. L4 conformance assertions run in the same harness.
  3. iauth: iauth-rs vs C‑iauth replay with fixed <id> scripts + per‑module fixtures; then end‑to‑end ircd+iauth‑rs golden.
  4. CI (every commit): build reference‑C; build Rust workspace; boot the server; run L1 (all crates), L2 golden, L4 conformance; nightly L3 fuzz + soak. Green CI after a module's .c is dropped is the definition of "that module is migrated and fully tested."
  5. Final (P7 exit): ircd-sys compiles no C logic; the entire L1–L4 + soak suite is byte‑identical to the archived reference‑C across the full scenario matrix.