Skip to main content

composer_php_json/
lib.rs

1//! PHP-compatible JSON encoder. Byte-exact output for two PHP
2//! `json_encode` flag combinations Composer relies on:
3//!
4//! - [`Mode::Hash`] — `json_encode($d, 0)`. The byte stream MD5'd into
5//!   `composer.lock`'s `content-hash` (Composer's `Locker::getContentHash`).
6//!   Compact, forward slashes escaped to `\/`, every code point ≥ 0x80
7//!   escaped to `\uXXXX` lowercase, surrogate pairs for code points
8//!   > 0xFFFF. U+2028 / U+2029 fall under the same rule.
9//!
10//! - [`Mode::Pretty`] — `json_encode($d, JSON_PRETTY_PRINT |
11//!   JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)`. The flag set
12//!   `Composer\Json\JsonFile::encode` defaults to when writing
13//!   composer.json / composer.lock. 4-space indent, raw `/`, raw UTF-8
14//!   for non-ASCII — except U+2028 / U+2029, which Composer keeps
15//!   escaped (it doesn't pass `JSON_UNESCAPED_LINE_TERMINATORS`).
16//!
17//! Shared rules (both modes):
18//!
19//! - `"` → `\"`, `\` → `\\`.
20//! - Named escapes for 0x08 → `\b`, 0x09 → `\t`, 0x0A → `\n`,
21//!   0x0C → `\f`, 0x0D → `\r`.
22//! - Other C0 control bytes (0x00..0x07, 0x0B, 0x0E..0x1F) →
23//!   `\u00XX` lowercase.
24//! - 0x7F (DEL) emitted raw — PHP's escape bitmap doesn't flag it.
25//! - Object keys take the same string-escape rules as string values.
26//! - Numbers: integers via plain decimal; floats via Rust's shortest-
27//!   roundtrip `f64::to_string`, which matches PHP's `zend_gcvt` at
28//!   `serialize_precision = -1` (the post-7.1 default). Both PHP and
29//!   Rust render integer-valued floats without a fractional part
30//!   (`1.0` → `"1"`).
31//!
32//! Object key order is preserved as-is; callers needing Composer's
33//! content-hash must `ksort` the top level before encoding, as
34//! `Locker::getContentHash` does. Part of the `composer-rs` workspace;
35//! extracted from `bougie-php-json`.
36
37use serde_json::Value;
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum Mode {
41    /// `json_encode($d, 0)`. Composer's content-hash byte stream.
42    Hash,
43    /// `json_encode($d, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES |
44    /// JSON_UNESCAPED_UNICODE)`. Composer's file-write encoding.
45    Pretty,
46}
47
48/// Encode a `serde_json::Value` to bytes matching PHP's `json_encode`
49/// for the given flag combination.
50pub fn encode(value: &Value, mode: Mode) -> Vec<u8> {
51    let mut out = Vec::with_capacity(256);
52    write_value(&mut out, value, mode, 0);
53    out
54}
55
56fn write_value(out: &mut Vec<u8>, v: &Value, mode: Mode, depth: usize) {
57    match v {
58        Value::Null => out.extend_from_slice(b"null"),
59        Value::Bool(true) => out.extend_from_slice(b"true"),
60        Value::Bool(false) => out.extend_from_slice(b"false"),
61        Value::Number(n) => write_number(out, n),
62        Value::String(s) => write_string(out, s, mode),
63        Value::Array(a) => write_array(out, a, mode, depth),
64        Value::Object(o) => write_object(out, o, mode, depth),
65    }
66}
67
68fn write_string(out: &mut Vec<u8>, s: &str, mode: Mode) {
69    out.push(b'"');
70    for c in s.chars() {
71        write_char_escaped(out, c, mode);
72    }
73    out.push(b'"');
74}
75
76fn write_char_escaped(out: &mut Vec<u8>, c: char, mode: Mode) {
77    let cp = c as u32;
78    match c {
79        '"' => out.extend_from_slice(b"\\\""),
80        '\\' => out.extend_from_slice(b"\\\\"),
81        '/' => match mode {
82            Mode::Hash => out.extend_from_slice(b"\\/"),
83            Mode::Pretty => out.push(b'/'),
84        },
85        '\u{08}' => out.extend_from_slice(b"\\b"),
86        '\u{09}' => out.extend_from_slice(b"\\t"),
87        '\u{0a}' => out.extend_from_slice(b"\\n"),
88        '\u{0c}' => out.extend_from_slice(b"\\f"),
89        '\u{0d}' => out.extend_from_slice(b"\\r"),
90        _ if cp < 0x20 => write_unicode_escape(out, cp),
91        // The guard `cp < 0x80` makes this cast lossless.
92        _ if cp < 0x80 => out.push(u8::try_from(cp).expect("ascii by construction")),
93        _ => match mode {
94            Mode::Hash => write_unicode_escape_full(out, cp),
95            Mode::Pretty => match cp {
96                // PHP escapes U+2028 / U+2029 even with
97                // JSON_UNESCAPED_UNICODE, unless
98                // JSON_UNESCAPED_LINE_TERMINATORS is also set —
99                // Composer doesn't pass it.
100                0x2028 | 0x2029 => write_unicode_escape(out, cp),
101                _ => {
102                    let mut buf = [0u8; 4];
103                    out.extend_from_slice(c.encode_utf8(&mut buf).as_bytes());
104                }
105            },
106        },
107    }
108}
109
110/// Emit a single BMP code point as `\uXXXX` with lowercase hex.
111fn write_unicode_escape(out: &mut Vec<u8>, code: u32) {
112    const HEX: &[u8; 16] = b"0123456789abcdef";
113    out.extend_from_slice(b"\\u");
114    out.push(HEX[((code >> 12) & 0xf) as usize]);
115    out.push(HEX[((code >> 8) & 0xf) as usize]);
116    out.push(HEX[((code >> 4) & 0xf) as usize]);
117    out.push(HEX[(code & 0xf) as usize]);
118}
119
120/// Emit a code point as one `\uXXXX` (BMP) or a UTF-16 surrogate pair
121/// (`\uHHHH\uLLLL`) for code points > 0xFFFF. Matches PHP's encoder:
122/// `us -= 0x10000; high = (us >> 10) | 0xd800; low = (us & 0x3ff) | 0xdc00`.
123fn write_unicode_escape_full(out: &mut Vec<u8>, code: u32) {
124    if code <= 0xFFFF {
125        write_unicode_escape(out, code);
126    } else {
127        let adjusted = code - 0x10000;
128        let high = 0xd800 + (adjusted >> 10);
129        let low = 0xdc00 + (adjusted & 0x3ff);
130        write_unicode_escape(out, high);
131        write_unicode_escape(out, low);
132    }
133}
134
135fn write_number(out: &mut Vec<u8>, n: &serde_json::Number) {
136    if let Some(i) = n.as_i64() {
137        out.extend_from_slice(i.to_string().as_bytes());
138    } else if let Some(u) = n.as_u64() {
139        out.extend_from_slice(u.to_string().as_bytes());
140    } else if let Some(f) = n.as_f64() {
141        // PHP's zend_gcvt at serialize_precision=-1 yields the shortest
142        // round-trip form. Rust's f64 Display does the same.
143        out.extend_from_slice(f.to_string().as_bytes());
144    } else {
145        // serde_json::Number always carries one of the three above when
146        // constructed from valid JSON; falling through is unreachable
147        // in practice. Be defensive rather than panic.
148        out.extend_from_slice(b"0");
149    }
150}
151
152fn write_array(out: &mut Vec<u8>, a: &[Value], mode: Mode, depth: usize) {
153    if a.is_empty() {
154        out.extend_from_slice(b"[]");
155        return;
156    }
157    match mode {
158        Mode::Hash => {
159            out.push(b'[');
160            for (i, v) in a.iter().enumerate() {
161                if i > 0 {
162                    out.push(b',');
163                }
164                write_value(out, v, mode, depth + 1);
165            }
166            out.push(b']');
167        }
168        Mode::Pretty => {
169            out.push(b'[');
170            for (i, v) in a.iter().enumerate() {
171                if i > 0 {
172                    out.push(b',');
173                }
174                out.push(b'\n');
175                indent(out, depth + 1);
176                write_value(out, v, mode, depth + 1);
177            }
178            out.push(b'\n');
179            indent(out, depth);
180            out.push(b']');
181        }
182    }
183}
184
185fn write_object(out: &mut Vec<u8>, o: &serde_json::Map<String, Value>, mode: Mode, depth: usize) {
186    if o.is_empty() {
187        // Working from `serde_json::Value`, arrays and objects are
188        // distinct types, so an empty Map is unambiguously `{}`.
189        out.extend_from_slice(b"{}");
190        return;
191    }
192    match mode {
193        Mode::Hash => {
194            out.push(b'{');
195            for (i, (k, v)) in o.iter().enumerate() {
196                if i > 0 {
197                    out.push(b',');
198                }
199                write_string(out, k, mode);
200                out.push(b':');
201                write_value(out, v, mode, depth + 1);
202            }
203            out.push(b'}');
204        }
205        Mode::Pretty => {
206            out.push(b'{');
207            for (i, (k, v)) in o.iter().enumerate() {
208                if i > 0 {
209                    out.push(b',');
210                }
211                out.push(b'\n');
212                indent(out, depth + 1);
213                write_string(out, k, mode);
214                out.extend_from_slice(b": ");
215                write_value(out, v, mode, depth + 1);
216            }
217            out.push(b'\n');
218            indent(out, depth);
219            out.push(b'}');
220        }
221    }
222}
223
224fn indent(out: &mut Vec<u8>, depth: usize) {
225    for _ in 0..depth {
226        out.extend_from_slice(b"    ");
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233    use serde_json::json;
234
235    fn enc(v: &Value, mode: Mode) -> String {
236        String::from_utf8(encode(v, mode)).expect("UTF-8 output")
237    }
238
239    // ---- string escape rules ------------------------------------------------
240
241    #[test]
242    fn hash_escapes_forward_slash() {
243        // PHP: json_encode("a/b", 0) === "\"a\\/b\""
244        assert_eq!(enc(&json!("a/b"), Mode::Hash), "\"a\\/b\"");
245    }
246
247    #[test]
248    fn pretty_leaves_forward_slash_raw() {
249        // JSON_UNESCAPED_SLASHES is set for the file-write encoding.
250        assert_eq!(enc(&json!("a/b"), Mode::Pretty), "\"a/b\"");
251    }
252
253    #[test]
254    fn both_modes_escape_double_quote_and_backslash() {
255        assert_eq!(enc(&json!("\""), Mode::Hash), "\"\\\"\"");
256        assert_eq!(enc(&json!("\""), Mode::Pretty), "\"\\\"\"");
257        assert_eq!(enc(&json!("\\"), Mode::Hash), "\"\\\\\"");
258        assert_eq!(enc(&json!("\\"), Mode::Pretty), "\"\\\\\"");
259    }
260
261    #[test]
262    fn named_c0_escapes() {
263        // PHP names 0x08 0x09 0x0a 0x0c 0x0d; everything else < 0x20
264        // takes the generic \u00XX form.
265        assert_eq!(enc(&json!("\u{08}"), Mode::Hash), "\"\\b\"");
266        assert_eq!(enc(&json!("\u{09}"), Mode::Hash), "\"\\t\"");
267        assert_eq!(enc(&json!("\u{0a}"), Mode::Hash), "\"\\n\"");
268        assert_eq!(enc(&json!("\u{0c}"), Mode::Hash), "\"\\f\"");
269        assert_eq!(enc(&json!("\u{0d}"), Mode::Hash), "\"\\r\"");
270        assert_eq!(enc(&json!("\u{01}"), Mode::Hash), "\"\\u0001\"");
271        assert_eq!(enc(&json!("\u{0b}"), Mode::Hash), "\"\\u000b\"");
272        assert_eq!(enc(&json!("\u{1f}"), Mode::Hash), "\"\\u001f\"");
273    }
274
275    #[test]
276    fn del_0x7f_is_raw() {
277        // PHP's escape bitmap leaves 0x60..0x7F clear, so DEL is raw.
278        let out = encode(&json!("\u{7f}"), Mode::Hash);
279        assert_eq!(out, vec![b'"', 0x7f, b'"']);
280    }
281
282    #[test]
283    fn hash_escapes_non_ascii_bmp_lowercase_hex() {
284        // U+00E9 (é) → é with lowercase digits.
285        assert_eq!(enc(&json!("é"), Mode::Hash), "\"\\u00e9\"");
286        // U+201C (left double quote)
287        assert_eq!(enc(&json!("\u{201c}"), Mode::Hash), "\"\\u201c\"");
288    }
289
290    #[test]
291    fn hash_emits_surrogate_pair_for_supplementary_plane() {
292        // U+1F4A9 ("pile of poo") — common load-bearing supplementary
293        // code point. Adjusted = 0x0F4A9; high = 0xd83d, low = 0xdca9.
294        assert_eq!(enc(&json!("\u{1f4a9}"), Mode::Hash), "\"\\ud83d\\udca9\"");
295    }
296
297    #[test]
298    fn pretty_emits_non_ascii_raw_utf8() {
299        // JSON_UNESCAPED_UNICODE keeps non-ASCII as raw UTF-8 bytes…
300        let out = encode(&json!("é"), Mode::Pretty);
301        assert_eq!(out, vec![b'"', 0xc3, 0xa9, b'"']);
302    }
303
304    #[test]
305    fn pretty_still_escapes_line_terminators() {
306        // …except U+2028 / U+2029, which Composer keeps escaped because
307        // JsonFile::encode doesn't pass JSON_UNESCAPED_LINE_TERMINATORS.
308        assert_eq!(enc(&json!("\u{2028}"), Mode::Pretty), "\"\\u2028\"");
309        assert_eq!(enc(&json!("\u{2029}"), Mode::Pretty), "\"\\u2029\"");
310    }
311
312    // ---- structural shapes --------------------------------------------------
313
314    #[test]
315    fn empty_collections() {
316        assert_eq!(enc(&json!([]), Mode::Hash), "[]");
317        assert_eq!(enc(&json!({}), Mode::Hash), "{}");
318        assert_eq!(enc(&json!([]), Mode::Pretty), "[]");
319        assert_eq!(enc(&json!({}), Mode::Pretty), "{}");
320    }
321
322    #[test]
323    fn hash_compact_no_whitespace() {
324        let v = json!({"name": "acme/widget", "require": {"php": "^8.3"}});
325        assert_eq!(
326            enc(&v, Mode::Hash),
327            "{\"name\":\"acme\\/widget\",\"require\":{\"php\":\"^8.3\"}}"
328        );
329    }
330
331    #[test]
332    fn hash_preserves_nested_key_order() {
333        // serde_json with preserve_order keeps insertion order; the
334        // encoder must not re-sort nested keys (only the top level
335        // gets ksort'd, and that happens upstream in `content_hash`).
336        let v = json!({"require": {"php": "^8.3", "ext-redis": "*"}});
337        let out = enc(&v, Mode::Hash);
338        assert!(out.contains("\"php\":\"^8.3\",\"ext-redis\":\"*\""));
339    }
340
341    #[test]
342    fn pretty_uses_four_space_indent() {
343        let v = json!({"a": 1, "b": [2, 3]});
344        // Composer's JsonFile defaults to 4-space INDENT_DEFAULT.
345        let expected = "{\n    \"a\": 1,\n    \"b\": [\n        2,\n        3\n    ]\n}";
346        assert_eq!(enc(&v, Mode::Pretty), expected);
347    }
348
349    // ---- numbers ------------------------------------------------------------
350
351    #[test]
352    fn integers_plain_decimal() {
353        assert_eq!(enc(&json!(0), Mode::Hash), "0");
354        assert_eq!(enc(&json!(-7), Mode::Hash), "-7");
355        assert_eq!(enc(&json!(1_000_000_000_i64), Mode::Hash), "1000000000");
356    }
357
358    #[test]
359    fn floats_shortest_roundtrip_with_lowercase_e() {
360        // 0.1 round-trips to "0.1" in both PHP and Rust.
361        assert_eq!(enc(&json!(0.1_f64), Mode::Hash), "0.1");
362        // Integer-valued floats lose the fractional part — both PHP
363        // (without JSON_PRESERVE_ZERO_FRACTION) and Rust default to
364        // this. serde_json may parse `1.0` back as Number::F64(1.0)
365        // which displays as "1".
366        let one: Value = serde_json::from_str("1.0").unwrap();
367        assert_eq!(enc(&one, Mode::Hash), "1");
368    }
369
370    // ---- null / bool --------------------------------------------------------
371
372    #[test]
373    fn null_true_false() {
374        assert_eq!(enc(&Value::Null, Mode::Hash), "null");
375        assert_eq!(enc(&json!(true), Mode::Hash), "true");
376        assert_eq!(enc(&json!(false), Mode::Hash), "false");
377    }
378}