Replacing the crate nobody maintains
An unmaintained serialization crate sat one step upstream of every hash the network trusts. Replacing it took ten lines of code and one hard look at the bytes.
The smallest diff I shipped this week was three lines in a TOML file. I deleted an entry from the ignore list in deny.toml, and I have been thinking about it more than the other 285 commits that landed on main recently.
The entry was RUSTSEC-2021-0127. It said: serde_cbor 0.11 is unmaintained. We knew. We had written the ignore ourselves, with a comment pointing at issue #267, which said, in so many words, "swapping it touches the wire format, so this needs care." Care is what the ignore list was for: a conscious decision, documented, instead of a silent pass. The gate stayed green for new advisories while the old ones waited their turn.
This week was #267's turn.
What the crate was doing
MossyMesh's consensus crate encodes trie node payloads, leaf paths, extension pointers, branch children, as CBOR. CBOR is the binary cousin of JSON: compact, self-describing, good on a wire. The encoded bytes get a domain tag prepended and hashed with Blake3, and those hashes become the roots that the whole ledger agrees on. So the serialization crate sits exactly one step upstream of every hash the network trusts.
serde_cbor did that job. It also has not had a release since 2021. The author posted in 2020 asking for maintainers. People volunteered. The crate went quiet anyway. The code still compiles, the bytes still decode, and that is precisely the trap: an unmaintained crate fails silently, by never failing at all, until the day it does. Nobody is watching for the soundness bug, the edge case in a new compiler, the advisory with no one to patch it.
The decision, and the shape of it
When an advisory names one of my dependencies, I ask one thing first: is the crate on the hot path, or just in the tree? serde_cbor was on the hot path. Every trie root flows through it. That settled the priority.
Then: can the fix preserve the format, or does it change the bytes? This is the question that decides whether a migration is a swap or a refactor. CBOR to CBOR is a swap. The wire format is a published standard, RFC 7049. For the types we use, any correct implementation encodes the same logical value to the same bytes: structs as maps, byte strings as byte strings, absent options as null. Moving to a different format, say bincode's fixed-width integers to postcard's varints, changes bytes, which changes hashes, which changes consensus. That is a refactor, and it gets its own issue and its own week. (We have one: #265, still open, still ignored on purpose.)
That call had company. RUSTSEC-2021-0127 itself names ciborium and minicbor as the original author's proposed replacements, and the migration path is well trodden. Other Rust projects have done exactly this swap, serde_cbor 0.11 to ciborium 0.2, including the same deny.toml ignore removal. One of them even hit the same lockfile ripple I did, consolidating half 1.8.3 to 2.7.1. The API mapped nearly one to one. serde_cbor::to_vec became ciborium:: into a Vec; into_writerserde_cbor::from_slice became ciborium::. Two call sites. The diff in from_readeripld_codec.rs was ten lines. One honest cost, from the published benchmarks: the old crate still wins some encode/decode micro-benchmarks. We encode trie nodes, not market data, so maintenance won over nanoseconds.
The part I actually worried about
"Nearly one to one" is doing a lot of work in that paragraph. Two CBOR libraries can both be correct and still disagree on bytes in corners: float encoding, map key ordering, how they handle indefinite-length items. If ciborium encoded our node structs even slightly differently, every trie root in the test suite would shift, and worse, we would have no principled way to know which encoding was "right." They would just be different.
This was not a hypothetical worry. A few years back, an r/rust thread caught serde_cbor emitting non-minimal integer encodings: an extra byte where a minimal encoder writes none. Same logical value, different bytes, exactly the failure mode I was afraid of. So before trusting the test suite, I ran both encoders side by side on every payload shape we use: leaf, extension, branch, pointer, small values and 70KB ones alike. The bytes came out identical, every one. The check was the point, not the result.
So the migration had an oracle, and the oracle was not the compiler. It was the determinism tests: root_hash_deterministic, the trie merge convergence tests, the proof verification tests, all of which hash the actual CBOR bytes. If the bytes had moved, the roots would have moved, and the suite would have caught it. I carried one caveat in from another project's migration notes. If you need canonical CBOR with sorted map keys, neither crate sorts for you. You have to handle that yourself. Our payloads are structs, which serialize as maps in field order, so the encoding was already deterministic. All 521 workspace tests passed, fmt and clippy clean, and serde_cbor is now fully out of the dependency tree. The ignore entry is deleted, which means the deny gate is guarding this for us from now on: if ciborium ever rots the same way, CI will tell us instead of a human remembering to check.
Takeaway: a checklist for unmaintained-crate advisories
If an advisory names a crate in your tree, this is the sequence that worked:
- Locate it precisely. Is it on the hot path (hashes, wire bytes, auth) or just in the tree? Hot path means priority; in-tree means schedule.
- Ask whether the fix preserves the format. Same-format swaps (CBOR to CBOR) are verifiable by tests. Format changes (bincode to postcard) are protocol changes in disguise; treat them as such.
- Find your oracle before you migrate. You need a test that observes the bytes. Round-trip tests only check the types; they pass even when the encoding changes completely. Determinism tests, golden vectors, and hash assertions are what catch a byte-level drift.
- Delete the ignore when you fix the code. The advisory database is a sensor. An ignore entry you leave behind after fixing the problem is a blindfold you forgot you were wearing.
One thread is still open, deliberately. The bincode migration (#265) changes wire bytes, so it is a protocol decision with a migration story, not a weekend swap. And the hickory-proto DoS (#266) arrives transitively through libp2p, which means the fix is a major version upgrade of the networking stack, with its own API churn to absorb. Both are still on the ignore list, still documented, still waiting their turn. That is what the list is for.