IDNA

rusty-js-idna is Cruft's IDNA layer, the step that folds and normalizes a Unicode hostname into the ASCII xn-- form DNS can resolve. It maps every code point through the UTS-46 table and validates each label (the bidi, joiner, hyphen, and length rules) so lookalike and mixed-script hosts are rejected, not canonicalized.

fetch("https://münchen.example") or new URL("https://①.example") just works. Somewhere underneath, that human-readable Unicode host got folded, normalized, and re-encoded into an ASCII xn--mnchen-3ya.example form that DNS can actually resolve. That step is IDNA (Internationalized Domain Names in Applications), and in the Rust ecosystem the idna crate is what does it. Every host you route on, allowlist, or connect to passes through a layer like this. This page is about the crate Cruft ships in its place.

Alpha, unaudited. rusty-js-idna is a crate (Cruft 0.0.10) standing in for the crates.io idna crate. It has not been externally audited and has near-zero interop or conformance-vector testing against the official UTS-46 / IDNA test files. Its correctness and security properties are unproven except where this page points at an in-tree test or enforcing code path. Do not rely on it for production host handling of adversarial input.

IDNA is more than punycode

ToASCII does more than run punycode on the host. Punycode (rusty-js-punycode) is the raw RFC 3492 codec: it turns one Unicode label into its xn-- form and back, no questions asked. IDNA is the layer above it that decides, per code point, what is even allowed to reach that codec, and how it must be spelled first. rusty-js-idna does that in a fixed pipeline: map every code point through the UTS-46 table (case-fold Ü to ü, map fullwidth dots to ., drop ignored format controls, reject disallowed code points), normalize the whole string to NFC, split on dots, validate each label, then convert each non-ASCII label to xn-- ACE via rusty_js_punycode::label_to_ascii.

The public surface is small and matches what a URL parser needs: to_ascii(domain) and to_unicode(domain), plus _with residual-table variants. rusty-js-url's domain_to_ascii delegates here rather than reimplementing any of it.

The reason the layering matters: the "just punycode it" model has no place to reject a homograph. Rejection is the whole security value of IDNA, and rejection lives in the map and validate steps.

The mapping table is real, and that is the strong half

There are two very different things IDNA does, and they have very different maturity here. The first is the mapping / disallowed decision: given a code point, is it valid, case-folded to something else, ignored, or forbidden entirely? This is a per-code-point lookup over the whole of Unicode.

rusty-js-idna ships a full Unicode 17.0.0 UTS-46 table, driving map_char_with. It is not a Latin-only stub: it case-folds arbitrary uppercase Unicode, maps the compatibility dots (\u{3002}, \u{FF0E}, \u{FF61}) to ., drops ignored format controls including the soft hyphen, and returns Disallowed for code points outside the valid set. The mapping core is correct; the earlier host-confusion concern was never in the mapping.

Memory safety and totality: the pipeline carries no unsafe, todo!, unimplemented!, or panic! on the input path. Every code point maps to a defined disposition; ACE and length failures return an IdnaError variant, not a panic.

The validity layer is where homographs actually get through

The second thing IDNA does is validate the label after mapping: does it obey the Bidi Rule for right-to-left scripts, the ContextJ/ContextO rules for joiners and middle-dots, CheckHyphens, the no-leading-combining-mark rule, and the DNS length limits? This is the layer that rejects a lookalike host the mapping would otherwise happily canonicalize. And this is exactly where rusty-js-idna was once weaker than the reference idna crate.

The earlier gap was concrete: bidi_class hardcoded a few Hebrew/Arabic ranges and defaulted every other code point to neutral, so the RFC 5893 Bidi Rule was silently skipped for any other RTL script; ZWJ/ZWNJ were accepted with no contextual rule; there was no CheckHyphens, no leading-combining-mark check, and no length limits; and NFC only composed Latin base-plus-mark pairs, so non-Latin un-composed sequences passed un-normalized. Because rusty-js-url delegates host canonicalization here, mixed-script and joiner-abuse hostnames that reference idna rejects were being accepted and canonicalized: a real homograph / host-confusion gap on the SSRF-relevant path.

Where that stands now

That gap is closed, and the fixes are enforced in-tree. Two things landed:

  • A default ToASCII validity layer (validate_label / validate_context) now enforces CheckHyphens (leading/trailing hyphen and the positions 3-4 -- rule, including fake-ACE), leading-combining-mark refusal (is_combining_mark), a conservative ContextJ that refuses ZWJ/ZWNJ joiners rather than accepting them outside a proven context, ContextO checks (middle dot, Greek lower numeral, Hebrew punctuation, Katakana middle dot with a Japanese/Han requirement, Arabic-Indic digit mixing), and the 63-octet label / 253-octet domain length limits. These surface as dedicated IdnaError variants: HyphenRuleViolation, LeadingCombiningMark, ContextRuleViolation, LabelTooLong, DomainTooLong.
  • The hardcoded bidi subset was replaced with a Unicode 17.0.0 generated DerivedBidiClass range table, so check_bidi_label applies RFC 5893 to every RTL script, not just Hebrew/Arabic. NFC now delegates to the shared Unicode 17.0.0 normalizer, so non-Latin canonical composition is real, not a Latin-only pair table.

These are enforced in code, exercising each refusal class end-to-end through to_ascii: forbidden hyphen positions, leading combining marks, ZWJ/ZWNJ joiners without a proven context, the ContextO middle-dot and digit-mixing rules, the label and domain length limits, the Bidi Rule across RTL scripts beyond Hebrew and Arabic, and NFC of non-Latin combining marks. The bidi table carries its own sortedness and range checks. A homograph that reference idna rejects now meets enforcing checks for each class rather than being canonicalized. The ToASCII/ToUnicode path is also wired for fuzzing, though a long campaign against a corpus has not been run.

Limitations

The mapping half of this crate is strong and the validity half went from "weaker than the reference" to "enforced with per-class coverage." The gaps that remain are real:

  • ContextJ is conservative, not complete. The joiner rules refuse ZWJ/ZWNJ outright because the UCD joining-type table is not wired in (validate_context). That is safe (it over-rejects rather than under-rejects), but it means a legitimate joiner-using label (some Indic and Persian scripts) that reference idna would accept is rejected here. This is a correctness difference in the reject-more direction, not a security gap.
  • No official conformance vectors run. The fixes are validated by chosen known-answer cases, not by the official UTS-46 IdnaTestV2.txt or a confusable/mixed-script corpus, and there is no interop comparison against the idna crate. So "matches the reference" is asserted case-by-case, not corpus-wide. Reference idna remains the maturity baseline: it has years of ecosystem exposure and the full contextual tables this crate approximates conservatively.
  • NFC and bidi correctness inherit the shared Unicode tables' version story. Those tables are Unicode 17.0.0, but the official normalization conformance test is not run against them. The IDNA-facing bidi table asserts its own ordering; the shared normalizer's guarantees are as strong as that table's.

TLS / hostname implication. IDNA sits directly under host canonicalization, so a ToASCII that disagrees with what a TLS certificate or an allowlist was checked against is a name-confusion bug, not a cosmetic one. The value of the fix is precisely that rusty-js-idna now rejects more of the adversarial input classes (joiner abuse, leading marks, over-long labels, cross-script bidi) rather than canonicalizing them into a host that then gets connected to. Rejection at this layer is the SSRF/homograph defense; the conservative bias is the right direction for that role.