Punycode
rusty-js-punycode encodes and decodes Punycode (RFC 3492), the ASCII form of internationalized domain labels carried behind the xn-- prefix. It works one label at a time, guards every arithmetic step against the classic integer-overflow attack, and returns a typed error rather than crashing on malformed input.
Punycode runs behind every day of browsing without being called by name. When a browser, a fetch client, or a DNS resolver meets a domain label with non-ASCII characters, it does not send those bytes. It transcodes the label into a purely ASCII form prefixed with xn--, ships that, and reverses the transform on the way back. That transcoding is Punycode (RFC 3492). In Node the punycode package (once built into the runtime, now a userland shim) exposes punycode.encode / punycode.decode. This page is about what happens inside, in Cruft's replacement.
Alpha.rusty-js-punycode(Cruft 0.0.10) is a zero-dependency crate that stands in for the crates.iopunycodecrate / Node'spunycodemodule (RFC 3492, the Bootstring codec). It has not been externally audited. Treat its properties as unproven except where this page cites an in-tree witness. Do not rely on it in production or on adversarial input yet.
Decode runs an integer machine over untrusted digits
punycode.decode(s) returns Unicode, but that output hides the mechanism. Punycode is Bootstring: a generalized variable-length-integer code. Decoding walks the ASCII digits and accumulates an integer (i in the source), repeatedly multiplying a running weight w and adding digit values, then uses that integer to decide which code point to insert and where. So the thing consuming an untrusted xn--... label is an arithmetic loop.
That distinction matters because the classic Punycode vulnerability is an integer overflow. A crafted label with a long run of high-value digits drives i or w past the integer range. In C reference code that silently wraps and desynchronizes the decoder; the RFC explicitly calls for overflow checks. Once decode is seen as an integer machine, "does it check for overflow?" is the first question.
What the crate is
One surface, two directions. The source is a single module implementing the Bootstring transform over the fixed Punycode parameter set (base 36, tmin 1, tmax 26, skew 38, damp 700, initial_bias 72, initial_n 128):
encode(&str) -> Result<String, PunycodeError>, Unicode label to raw Punycode (RFC 3492 §6.3).decode(&str) -> Result<String, PunycodeError>, raw Punycode back to Unicode (§6.2).label_to_ascii/label_to_unicode, single-label convenience helpers that add/strip thexn--ACE prefix.
Every path is a Result with a typed PunycodeError, one of five variants:
There is no unwrap on input, no panic!, no todo!(), and no unsafe anywhere in the file. Malformed input becomes an Err, not a crash.
The overflow question: it is guarded
This is the crate's strongest property, and the reason it is the most solid codec in the set. Every arithmetic step that could overflow uses Rust's checked arithmetic and maps a wrap to PunycodeError::Overflow:
- decode accumulates with
digit.checked_mul(w)thenchecked_add, advances the weight withw.checked_mul(BASE - t), and advances the code point withn.checked_add(i / out_len). - encode guards its delta accumulation the same way with
checked_add/checked_mul.
So the RFC's overflow requirement is satisfied by construction: a hostile label cannot wrap the decoder into a bad state. It returns Overflow.
Two things back this as a tested property rather than an intended one:
- A regression case feeds
"a-"followed by 1024'9'digits and gets exactlyErr(PunycodeError::Overflow). The checked arithmetic was always present; this input drives it. - A fuzz target runs arbitrary bytes (via
String::from_utf8_lossy) throughdecode,label_to_unicode, and an encode-then-decode round trip, surfacing any panic, abort, or hang on decode of untrusted input. It compile-checks; a long fuzzing campaign against a corpus has not been run.
Beyond overflow, decode also rejects the two other malformed-input classes the RFC cares about: a decoded code point that lands in the basic (ASCII) range or outside Unicode scalar range returns InvalidCodePoint, and non-ASCII bytes in the input return NonBasicInput.
What it does NOT do
Node's punycode module also exposes toASCII / toUnicode, which operate on whole domain names, splitting on dots, only encoding the labels that need it, applying IDNA/UTS-46 mapping (the case-fold and disallowed-character overrides). This crate does not do any of that. Its scope is the raw, per-label Bootstring transform. The label_to_ascii / label_to_unicode helpers handle exactly one label and the xn-- prefix; they do not split on dots and apply no UTS-46 mapping. That layer is a separate consumer (the IDNA crate). Handed a full xn--...-bearing domain string, label_to_unicode treats it as one label.
It also does not normalize, does not enforce label length limits (RFC 1035's 63-octet cap is an IDNA/DNS concern, not a Bootstring one), and does not case-fold.
Comparison to the dependency it replaces
The crates.io punycode crate and Node's punycode module have years of production exposure, real-world IDN corpora, and downstream fuzzing behind them. That is the maturity baseline this alpha does not have. What this crate claims today, with witnesses, is narrower:
- Correctness against the spec vectors. Encode and decode round-trip the RFC 3492 §7.1 examples (the Chinese and Russian sample labels) plus common IDN references (
bücher/bcher-kva,münchen/mnchen-3ya,ü/tda) and a mixed set including CJK, Cyrillic, Greek, and emoji. - Memory safety and no-panic on decode, per the no-
unsafe, all-Resultshape plus the overflow test and fuzz target above. - Broad interop against a large hostile IDN corpus is unproven. The known-answer set is small and the fuzz campaign has not been run long. The mature dependency wins on breadth of exposure.
Limitations
The overflow guard is real, checked, and tested, and the codec is memory-safe with no reachable panic from JS-level input. That much you can lean on with a witness. What you cannot yet lean on: this is one small file with a handful of known-answer vectors, no external audit, and a fuzz target that exists but has not been run against a large corpus for long. It is a raw per-label transform, so anything that assumes full-domain IDNA/UTS-46 semantics (dot-splitting, case-folding, disallowed-character mapping) is out of scope here and lives above it. The mature crates.io dependency remains the choice where breadth of real-world IDN exposure matters more than reading the guard yourself.