Skip to main content

zerobox_utils_string/
json.rs

1//! JSON serialization helpers for output that must remain parseable as JSON
2//! while staying safe for ASCII-only transports.
3
4use std::io;
5
6use serde::Serialize;
7
8struct AsciiJsonFormatter;
9
10impl serde_json::ser::Formatter for AsciiJsonFormatter {
11    // serde_json has no ensure_ascii flag; this formatter keeps its serializer
12    // in charge and only escapes non-ASCII string fragments.
13    fn write_string_fragment<W>(&mut self, writer: &mut W, fragment: &str) -> io::Result<()>
14    where
15        W: ?Sized + io::Write,
16    {
17        let mut start = 0;
18        for (index, ch) in fragment.char_indices() {
19            if ch.is_ascii() {
20                continue;
21            }
22
23            if start < index {
24                writer.write_all(&fragment.as_bytes()[start..index])?;
25            }
26
27            let mut utf16 = [0; 2];
28            for code_unit in ch.encode_utf16(&mut utf16) {
29                write!(writer, "\\u{code_unit:04x}")?;
30            }
31            start = index + ch.len_utf8();
32        }
33
34        if start < fragment.len() {
35            writer.write_all(&fragment.as_bytes()[start..])?;
36        }
37
38        Ok(())
39    }
40}
41
42/// Serialize JSON while escaping non-ASCII string content as `\uXXXX`.
43///
44/// This is useful when JSON needs to remain parseable as JSON but must be
45/// carried through ASCII-safe transports such as HTTP headers.
46pub fn to_ascii_json_string<T>(value: &T) -> serde_json::Result<String>
47where
48    T: Serialize + ?Sized,
49{
50    let mut bytes = Vec::new();
51    let mut serializer = serde_json::Serializer::with_formatter(&mut bytes, AsciiJsonFormatter);
52    value.serialize(&mut serializer)?;
53    String::from_utf8(bytes)
54        .map_err(|err| serde_json::Error::io(io::Error::new(io::ErrorKind::InvalidData, err)))
55}
56
57#[cfg(test)]
58mod tests {
59    use std::collections::BTreeMap;
60
61    use pretty_assertions::assert_eq;
62    use serde::Serialize;
63    use serde::ser::SerializeStruct;
64    use serde_json::Value;
65    use serde_json::json;
66
67    use super::to_ascii_json_string;
68
69    #[test]
70    fn to_ascii_json_string_escapes_non_ascii_strings() {
71        struct TestPayload;
72
73        impl Serialize for TestPayload {
74            fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
75            where
76                S: serde::Serializer,
77            {
78                let workspaces = BTreeMap::from([("/tmp/東京", TestWorkspace)]);
79                let mut state = serializer.serialize_struct("TestPayload", 1)?;
80                state.serialize_field("workspaces", &workspaces)?;
81                state.end()
82            }
83        }
84
85        struct TestWorkspace;
86
87        impl Serialize for TestWorkspace {
88            fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
89            where
90                S: serde::Serializer,
91            {
92                let mut state = serializer.serialize_struct("TestWorkspace", 2)?;
93                state.serialize_field("label", "Agentlarım")?;
94                state.serialize_field("emoji", "🚀")?;
95                state.end()
96            }
97        }
98
99        let value = TestPayload;
100        let expected_value = json!({
101            "workspaces": {
102                "/tmp/東京": {
103                    "label": "Agentlarım",
104                    "emoji": "🚀"
105                }
106            }
107        });
108
109        let serialized = to_ascii_json_string(&value).expect("serialize ascii json");
110
111        assert_eq!(
112            serialized,
113            r#"{"workspaces":{"/tmp/\u6771\u4eac":{"label":"Agentlar\u0131m","emoji":"\ud83d\ude80"}}}"#
114        );
115        assert!(serialized.is_ascii());
116        assert!(!serialized.contains("東京"));
117        assert!(!serialized.contains("Agentlarım"));
118        assert!(!serialized.contains("🚀"));
119        let parsed: Value = serde_json::from_str(&serialized).expect("serialized json");
120        assert_eq!(parsed, expected_value);
121    }
122}