entropy-auth 2026.7.31

Authentication and authorization for Entropy Softworks server and API projects
//! Minimal JSON string serialisation helper.
//!
//! The crate's [`JsonValue`](super::JsonValue) only *parses* JSON. A few
//! modules ([`crate::jwt`], [`crate::oauth::server`]) need to *emit* small,
//! fixed-shape JSON documents (JWT payloads, RFC 7662 introspection
//! responses). Rather than pull in a serialiser or duplicate string
//! escaping at each site, those modules build the object structure directly
//! and call [`escape_json_string`] for the one non-trivial part: producing
//! a correctly escaped string literal.

use std::fmt::Write as _;

/// Renders `s` as a quoted, escaped RFC 8259 §7 JSON string literal,
/// including the surrounding double quotes.
///
/// Escapes the two mandatory characters (`"` and `\`), the conventional
/// short escapes for the common control characters, and any other control
/// character below `0x20` as a `\u00XX` sequence. All other code points
/// (including non-ASCII UTF-8) are emitted verbatim, which is valid JSON.
///
/// # Security
///
/// Escaping every control character is what keeps caller-supplied claim
/// values (subjects, usernames, scopes) from breaking out of the JSON
/// string context.
pub(crate) fn escape_json_string(s: &str) -> String {
    let mut out = String::with_capacity(s.len() + 2);
    out.push('"');
    for c in s.chars() {
        match c {
            '"' => out.push_str("\\\""),
            '\\' => out.push_str("\\\\"),
            '\n' => out.push_str("\\n"),
            '\r' => out.push_str("\\r"),
            '\t' => out.push_str("\\t"),
            '\u{08}' => out.push_str("\\b"),
            '\u{0c}' => out.push_str("\\f"),
            // SECURITY: remaining control characters must be escaped to
            // avoid breaking out of the JSON string context. `write!` to a
            // `String` is infallible.
            c if (c as u32) < 0x20 => {
                let _ = write!(out, "\\u{:04x}", c as u32);
            }
            c => out.push(c),
        }
    }
    out.push('"');
    out
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::json::JsonValue;

    #[test]
    fn plain_string() {
        assert_eq!(escape_json_string("hello"), r#""hello""#);
    }

    #[test]
    fn escapes_quote_and_backslash() {
        assert_eq!(escape_json_string("a\"b\\c"), r#""a\"b\\c""#);
    }

    #[test]
    fn escapes_short_control_chars() {
        assert_eq!(escape_json_string("\n\r\t"), r#""\n\r\t""#);
        assert_eq!(escape_json_string("\u{08}\u{0c}"), r#""\b\f""#);
    }

    #[test]
    fn escapes_other_control_chars_as_unicode() {
        assert_eq!(escape_json_string("\u{1}\u{1f}"), "\"\\u0001\\u001f\"");
    }

    #[test]
    fn preserves_non_ascii() {
        assert_eq!(escape_json_string("Sméagol 🧙"), "\"Sméagol 🧙\"");
    }

    #[test]
    fn output_parses_back_to_original() {
        for input in ["plain", "a\"b\\c", "tab\there", "\u{1}\u{1f}", "Sméagol 🧙"] {
            let json = format!("{{\"v\":{}}}", escape_json_string(input));
            let parsed = JsonValue::parse(&json).unwrap();
            assert_eq!(parsed.get_str("v"), Some(input));
        }
    }
}