bynk_project/json.rs
1//! The one JSON string escaper for every manifest `bynk-emit` hand-renders.
2//!
3//! Several build outputs are JSON that `bynk-emit` writes by hand rather than
4//! via serde (`bynk-contracts.json`, `bynk-secrets.json`, `package.json`, the
5//! source map): the shapes are a handful of fields each, and staying
6//! serde-free keeps the dependency footprint honest. What they must share is
7//! the escaping — every one of them interpolates text that originated in
8//! Bynk source (a secret name from a `StrLit`, an adapter's npm package
9//! name, a file path), so as far as the renderer is concerned the input is
10//! arbitrary.
11//!
12//! This lived as four near-copies, one per rendering module. Three agreed;
13//! the `package.json` one escaped only `"` and `\`, so an adapter-declared
14//! package name or version range carrying a control character emitted a
15//! literal control character inside a JSON string — a parse error, i.e. an
16//! invalid `package.json` in the build output. One definition means a fix
17//! like that cannot land in three places and miss the fourth.
18//!
19//! P4.0 (#1113, [DECISION C]): moved here from `bynk-emit/src/json.rs` so
20//! `bynk-project`'s own `paths::render_package_json` (its sole in-crate use)
21//! doesn't need a dependency back on `bynk-emit`; `bynk-emit`'s four other
22//! call sites (`secrets.rs`, `contracts.rs`, `source_map.rs`, `emit.rs`) now
23//! depend on this copy instead of a crate-local one, one definition either
24//! way.
25
26use std::fmt::Write as _;
27
28/// Render `s` as a double-quoted JSON string literal, escaping the two
29/// structural characters (`"` and `\`) and the whole C0 control range.
30///
31/// The C0 range matters and is easy to forget: RFC 8259 forbids an unescaped
32/// character below U+0020 inside a string, so a stray newline or NUL is the
33/// difference between a manifest that parses and one that merely usually
34/// parses. Characters at or above U+0020 pass through as-is — the output is
35/// UTF-8 JSON, so there is no reason to `\u`-escape non-ASCII.
36pub fn json_string(s: &str) -> String {
37 let mut out = String::with_capacity(s.len() + 2);
38 out.push('"');
39 for c in s.chars() {
40 match c {
41 '"' => out.push_str("\\\""),
42 '\\' => out.push_str("\\\\"),
43 '\n' => out.push_str("\\n"),
44 '\r' => out.push_str("\\r"),
45 '\t' => out.push_str("\\t"),
46 // The rest of C0 has no short escape; `\u00xx` is the only form.
47 // `write!` into the buffer rather than `push_str(&format!(…))` (what
48 // two of the four copies this replaces did) — no `String` allocated
49 // per control character.
50 c if (c as u32) < 0x20 => {
51 let _ = write!(out, "\\u{:04x}", c as u32);
52 }
53 c => out.push(c),
54 }
55 }
56 out.push('"');
57 out
58}
59
60#[cfg(test)]
61mod tests {
62 use super::*;
63
64 #[test]
65 fn plain_text_is_merely_quoted() {
66 assert_eq!(json_string("AUTH_JWT_SECRET"), "\"AUTH_JWT_SECRET\"");
67 assert_eq!(json_string(""), "\"\"");
68 }
69
70 #[test]
71 fn structural_characters_are_escaped() {
72 assert_eq!(json_string("a\"b"), "\"a\\\"b\"");
73 assert_eq!(json_string("a\\b"), "\"a\\\\b\"");
74 }
75
76 #[test]
77 fn the_control_range_is_escaped() {
78 assert_eq!(json_string("a\nb"), "\"a\\nb\"");
79 assert_eq!(json_string("a\rb"), "\"a\\rb\"");
80 assert_eq!(json_string("a\tb"), "\"a\\tb\"");
81 assert_eq!(json_string("a\u{1}b"), "\"a\\u0001b\"");
82 assert_eq!(json_string("a\u{0}b"), "\"a\\u0000b\"");
83 assert_eq!(json_string("a\u{1f}b"), "\"a\\u001fb\"");
84 }
85
86 /// U+0020 is the first character JSON allows unescaped; the boundary is
87 /// exactly where the C0 arm stops.
88 #[test]
89 fn space_and_above_pass_through() {
90 assert_eq!(json_string("a b"), "\"a b\"");
91 assert_eq!(json_string("a\u{7f}b"), "\"a\u{7f}b\"");
92 assert_eq!(json_string("héllo ☃"), "\"héllo ☃\"");
93 }
94}