Skip to main content

nodejs/stdlib/
punycode.rs

1//! Node `punycode` module — a faithful implementation of the RFC 3492 Bootstring
2//! algorithm with the Punycode parameter set. The module is deprecated in Node
3//! but still present; the codec is pure and deterministic (no host state beyond
4//! allocating the returned string/array), so it round-trips independently of any
5//! network or locale.
6//!
7//! Surface:
8//!   * `encode(str)` / `decode(str)` — the raw label codec (no `xn--` prefix).
9//!   * `toASCII(domain)` / `toUnicode(domain)` — per dot-separated label, with the
10//!     `xn--` ACE prefix convention.
11//!   * `ucs2Decode`/`ucs2Encode` — the code-point split/join. Node exposes these as
12//!     `punycode.ucs2.decode` / `.encode`; that nested object is built by
13//!     `constant("ucs2")`.
14//!
15//! Bootstring parameters (RFC 3492 §5): base 36, tmin 1, tmax 26, skew 38,
16//! damp 700, initial_bias 72, initial_n 128, delimiter `-`.
17//!
18//! RFC-3492 sample verifications reasoned through against this implementation.
19//! For "mañana": basic run "maana" (b=5) → "maana-"; the single non-basic ñ
20//! (U+00F1=241) with delta=678 emits digits 15→'p', 19→'t', 0→'a' ⇒ "maana-pta".
21//! Hence toASCII("mañana.com") === "xn--maana-pta.com" and the inverse
22//! toUnicode round-trips. Likewise toASCII("bücher") === "xn--bcher-kva".
23
24use crate::host::{with_host, JsObj};
25use fusevm::Value;
26use indexmap::IndexMap;
27
28// ── Bootstring parameters ────────────────────────────────────────────────────
29const BASE: u32 = 36;
30const TMIN: u32 = 1;
31const TMAX: u32 = 26;
32const SKEW: u32 = 38;
33const DAMP: u32 = 700;
34const INITIAL_BIAS: u32 = 72;
35const INITIAL_N: u32 = 128;
36
37/// The dot separators Node accepts (ASCII `.` plus the ideographic/full-width/
38/// halfwidth dots); all normalize to `.` in the output.
39const DOTS: &[char] = &['\u{2E}', '\u{3002}', '\u{FF0E}', '\u{FF61}'];
40
41pub const METHODS: &[&str] = &[
42    "encode",
43    "decode",
44    "toASCII",
45    "toUnicode",
46    "ucs2Decode",
47    "ucs2Encode",
48];
49
50pub fn call(method: &str, args: &[Value]) -> Option<Result<Value, String>> {
51    let input = super::arg_str(args, 0);
52    Some(match method {
53        "encode" => encode_js(&input),
54        "decode" => decode_js(&input),
55        "toASCII" => Ok(with_host(|h| h.new_str(to_ascii(&input)))),
56        "toUnicode" => Ok(with_host(|h| h.new_str(to_unicode(&input)))),
57        "ucs2Decode" => Ok(ucs2_decode_val(&input)),
58        "ucs2Encode" => Ok(ucs2_encode_val(args.first())),
59        _ => return None,
60    })
61}
62
63/// `punycode.ucs2` nested object and `punycode.version`, served through
64/// `stdlib::constant` (needs the parent `"punycode" => punycode::constant(name)`
65/// arm). Its `decode`/`encode` are the routed `ucs2Decode`/`ucs2Encode` methods.
66pub fn constant(name: &str) -> Option<Value> {
67    match name {
68        "ucs2" => Some(with_host(|h| {
69            let mut m = IndexMap::new();
70            m.insert(
71                "decode".into(),
72                h.alloc(JsObj::Builtin("punycode.ucs2Decode".into())),
73            );
74            m.insert(
75                "encode".into(),
76                h.alloc(JsObj::Builtin("punycode.ucs2Encode".into())),
77            );
78            h.new_object(m)
79        })),
80        "version" => Some(with_host(|h| h.new_str("2.3.1"))),
81        _ => None,
82    }
83}
84
85// ── JS-facing wrappers ───────────────────────────────────────────────────────
86
87fn encode_js(s: &str) -> Result<Value, String> {
88    let cps: Vec<u32> = s.chars().map(|c| c as u32).collect();
89    match encode(&cps) {
90        Ok(out) => Ok(with_host(|h| h.new_str(out))),
91        Err(e) => Err(crate::host::range_error(&e)),
92    }
93}
94
95fn decode_js(s: &str) -> Result<Value, String> {
96    match decode(s) {
97        Ok(cps) => {
98            let out: String = cps.iter().filter_map(|&c| char::from_u32(c)).collect();
99            Ok(with_host(|h| h.new_str(out)))
100        }
101        Err(e) => Err(crate::host::range_error(&e)),
102    }
103}
104
105/// `punycode.ucs2.decode(str)` → array of code-point numbers. node-js strings are
106/// Rust `String` (full Unicode scalars), so `chars()` already yields code points.
107fn ucs2_decode_val(s: &str) -> Value {
108    with_host(|h| {
109        let items: Vec<Value> = s.chars().map(|c| Value::Float(c as u32 as f64)).collect();
110        h.new_array(items)
111    })
112}
113
114/// `punycode.ucs2.encode(codePoints)` → string.
115fn ucs2_encode_val(arg: Option<&Value>) -> Value {
116    let cps: Vec<u32> = match arg {
117        Some(v) => with_host(|h| match h.get(v) {
118            Some(JsObj::Array(items)) => items.iter().map(|x| h.to_number(x) as u32).collect(),
119            _ => Vec::new(),
120        }),
121        None => Vec::new(),
122    };
123    let out: String = cps.iter().filter_map(|&c| char::from_u32(c)).collect();
124    with_host(|h| h.new_str(out))
125}
126
127// ── domain-level ToASCII / ToUnicode ─────────────────────────────────────────
128
129fn map_labels(domain: &str, f: impl Fn(&str) -> String) -> String {
130    domain
131        .split(|c| DOTS.contains(&c))
132        .map(f)
133        .collect::<Vec<_>>()
134        .join(".")
135}
136
137fn to_ascii(domain: &str) -> String {
138    map_labels(domain, |label| {
139        // Only labels carrying non-ASCII get the `xn--` ACE form.
140        if label.chars().any(|c| (c as u32) >= 0x80) {
141            let cps: Vec<u32> = label.chars().map(|c| c as u32).collect();
142            match encode(&cps) {
143                Ok(enc) => format!("xn--{enc}"),
144                Err(_) => label.to_string(),
145            }
146        } else {
147            label.to_string()
148        }
149    })
150}
151
152fn to_unicode(domain: &str) -> String {
153    map_labels(domain, |label| {
154        // Case-insensitive `xn--` prefix ⇒ decode the remainder.
155        let lower = label.to_lowercase();
156        match lower.strip_prefix("xn--") {
157            Some(rest) => match decode(rest) {
158                Ok(cps) => cps.iter().filter_map(|&c| char::from_u32(c)).collect(),
159                Err(_) => label.to_string(),
160            },
161            None => label.to_string(),
162        }
163    })
164}
165
166// ── core Bootstring codec (RFC 3492) ─────────────────────────────────────────
167
168/// Bias adaptation (RFC 3492 §6.1).
169fn adapt(mut delta: u32, num_points: u32, first_time: bool) -> u32 {
170    delta = if first_time { delta / DAMP } else { delta / 2 };
171    delta += delta / num_points;
172    let mut k = 0;
173    while delta > ((BASE - TMIN) * TMAX) / 2 {
174        delta /= BASE - TMIN;
175        k += BASE;
176    }
177    k + (((BASE - TMIN + 1) * delta) / (delta + SKEW))
178}
179
180/// A Bootstring digit (0..36) → its basic code point: 0..25 → 'a'..'z',
181/// 26..35 → '0'..'9'.
182fn digit_to_basic(d: u32) -> char {
183    if d < 26 {
184        (b'a' + d as u8) as char
185    } else {
186        (b'0' + (d - 26) as u8) as char
187    }
188}
189
190/// A basic code point → its Bootstring digit value (case-insensitive letters).
191fn basic_to_digit(c: char) -> Result<u32, String> {
192    match c {
193        'a'..='z' => Ok(c as u32 - 'a' as u32),
194        'A'..='Z' => Ok(c as u32 - 'A' as u32),
195        '0'..='9' => Ok(c as u32 - '0' as u32 + 26),
196        _ => Err(format!("Invalid input: {c}")),
197    }
198}
199
200/// Encode a slice of Unicode code points into a Punycode string (no `xn--`).
201fn encode(input: &[u32]) -> Result<String, String> {
202    let mut output = String::new();
203
204    // 1. Copy all basic (ASCII) code points, in order, to the output.
205    let mut b: u32 = 0;
206    for &c in input {
207        if c < 0x80 {
208            output.push(c as u8 as char);
209            b += 1;
210        }
211    }
212    // 2. Delimiter after the basic run (only if there were basic code points).
213    let mut h = b;
214    if b > 0 {
215        output.push('-');
216    }
217
218    let input_len = input.len() as u32;
219    let mut n = INITIAL_N;
220    let mut delta: u32 = 0;
221    let mut bias = INITIAL_BIAS;
222
223    while h < input_len {
224        // 3. Smallest code point >= n not yet handled.
225        let mut m = u32::MAX;
226        for &c in input {
227            if c >= n && c < m {
228                m = c;
229            }
230        }
231        // delta += (m - n) * (h + 1); guarded against overflow.
232        delta = delta
233            .checked_add((m - n).checked_mul(h + 1).ok_or("overflow")?)
234            .ok_or("overflow")?;
235        n = m;
236
237        for &c in input {
238            if c < n {
239                delta = delta.checked_add(1).ok_or("overflow")?;
240            }
241            if c == n {
242                // Represent delta as a generalized variable-length integer.
243                let mut q = delta;
244                let mut k = BASE;
245                loop {
246                    let t = threshold(k, bias);
247                    if q < t {
248                        break;
249                    }
250                    let digit = t + ((q - t) % (BASE - t));
251                    output.push(digit_to_basic(digit));
252                    q = (q - t) / (BASE - t);
253                    k += BASE;
254                }
255                output.push(digit_to_basic(q));
256                bias = adapt(delta, h + 1, h == b);
257                delta = 0;
258                h += 1;
259            }
260        }
261        delta += 1;
262        n += 1;
263    }
264    Ok(output)
265}
266
267/// Decode a Punycode string (no `xn--`) into Unicode code points.
268fn decode(input: &str) -> Result<Vec<u32>, String> {
269    let chars: Vec<char> = input.chars().collect();
270    let mut output: Vec<u32> = Vec::new();
271
272    // 1. Consume basic code points before the last delimiter (if any).
273    let mut idx = match input.rfind('-') {
274        Some(pos) => {
275            // `pos` is a byte index; it equals the char index here because every
276            // char up to and including the delimiter is ASCII (1 byte).
277            for &c in &chars[..pos] {
278                if (c as u32) >= 0x80 {
279                    return Err("Illegal basic code point".into());
280                }
281                output.push(c as u32);
282            }
283            pos + 1
284        }
285        None => 0,
286    };
287
288    let mut n = INITIAL_N;
289    let mut i: u32 = 0;
290    let mut bias = INITIAL_BIAS;
291    let len = chars.len();
292
293    while idx < len {
294        let oldi = i;
295        let mut w: u32 = 1;
296        let mut k = BASE;
297        loop {
298            if idx >= len {
299                return Err("Invalid input".into());
300            }
301            let digit = basic_to_digit(chars[idx])?;
302            idx += 1;
303            i = i
304                .checked_add(digit.checked_mul(w).ok_or("overflow")?)
305                .ok_or("overflow")?;
306            let t = threshold(k, bias);
307            if digit < t {
308                break;
309            }
310            w = w.checked_mul(BASE - t).ok_or("overflow")?;
311            k += BASE;
312        }
313        let out_len = output.len() as u32 + 1;
314        bias = adapt(i - oldi, out_len, oldi == 0);
315        n = n.checked_add(i / out_len).ok_or("overflow")?;
316        i %= out_len;
317        // Insert code point n at position i.
318        output.insert(i as usize, n);
319        i += 1;
320    }
321    Ok(output)
322}
323
324/// Per-position threshold `t(k)` (RFC 3492): clamped to `[tmin, tmax]` around the
325/// current bias.
326fn threshold(k: u32, bias: u32) -> u32 {
327    if k <= bias {
328        TMIN
329    } else if k >= bias + TMAX {
330        TMAX
331    } else {
332        k - bias
333    }
334}