tirith 0.4.0

Terminal security - catches homograph attacks, pipe-to-shell, ANSI injection
//! Shared YAML scalar / inline-comment helpers for every `tirith` subcommand
//! that writes YAML scaffolds (`mcp policy init`, `agent policy init`, …).
//! Centralized so the YAML safety rules (incl. the DEL-escape fix) live in one
//! place rather than being copied in `cli/mcp.rs` and `cli/agent.rs`.
//!
//! Safety contract:
//! * [`safe_scalar`] returns YAML that round-trips byte-for-byte through
//!   `serde_yaml` (every reserved indicator, C0 control byte, DEL, empty string,
//!   and multi-byte UTF-8 quoted/escaped). The full per-character contract is
//!   pinned by `cli/mcp.rs::yaml_safe_scalar_round_trips_through_yaml_parser`; a
//!   smoke set runs here too.
//! * [`safe_inline_comment`] is for `#`-comment suffixes: any control byte
//!   (line-breakers / ANSI escapes) renders the whole string in `Debug` form.
//!
//! Both are `pub(crate)`, not part of the public library surface.

/// Bytes that force a YAML scalar to be quoted: YAML's reserved indicator set
/// (`:#-?,[]{}&*!|>'"%@` plus backtick) and whitespace (space, tab). Control
/// bytes (`< 0x20`, `0x7f` DEL) are checked separately in [`safe_scalar`].
pub(crate) const YAML_NEEDS_QUOTING_BYTES: &[u8] = b":#-?,[]{}&*!|>'\"%@` \t";

/// repo-0442: Unicode code points that must never appear verbatim in a YAML
/// scalar or comment: C1 controls (U+0085 NEL terminates a YAML line, U+009B
/// CSI reaches the terminal), line/paragraph separators, bidi controls,
/// zero-width and BOM characters. Byte-level checks (`< 0x20`, DEL) miss all
/// of these because they are multi-byte UTF-8.
fn is_risky_unicode(ch: char) -> bool {
    ch.is_control()
        || matches!(
            ch,
            '\u{2028}' | '\u{2029}'
                | '\u{202A}'..='\u{202E}'
                | '\u{2066}'..='\u{2069}'
                | '\u{200B}'..='\u{200D}'
                | '\u{FEFF}'
        )
}

/// Escape every [`is_risky_unicode`] character left literal in an already
/// quoted/escaped string as `\u{XXXX}` (YAML 1.2 accepts the escape in
/// double-quoted scalars; in a Debug-rendered comment it stays inert ASCII).
fn escape_risky_unicode(s: &str) -> String {
    if !s.chars().any(is_risky_unicode) {
        return s.to_string();
    }
    let mut out = String::with_capacity(s.len());
    for ch in s.chars() {
        if is_risky_unicode(ch) {
            out.push_str(&format!("\\u{{{:04X}}}", ch as u32));
        } else {
            out.push(ch);
        }
    }
    out
}

/// Render a scalar (server / tool / matcher name) for a YAML document. Returns
/// the input unmodified when safe as a bare scalar; otherwise quotes and
/// JSON-escapes it.
///
/// LOAD-BEARING for safety: scaffolds carry names from arbitrary config files,
/// and a name with `:` / `#` / a newline / an ANSI escape would otherwise split
/// the key, comment out the value, break the document, or reach the terminal on
/// `cat`. The quoted/escaped form is unambiguous.
/// repo-0234: `true` when a bare emission would be parsed by YAML as
/// null/bool/number instead of the string it is.
fn yaml_implicit_nonstring(s: &str) -> bool {
    let lower = s.to_ascii_lowercase();
    if matches!(
        lower.as_str(),
        "null"
            | "~"
            | "true"
            | "false"
            | "yes"
            | "no"
            | "on"
            | "off"
            | ".nan"
            | ".inf"
            | "-.inf"
            | "+.inf"
    ) {
        return true;
    }
    // Integer / float spellings.
    if s.parse::<i64>().is_ok() || s.parse::<f64>().is_ok() {
        return true;
    }
    false
}

pub(crate) fn safe_scalar(s: &str) -> String {
    // Empty must be quoted — bare empty is invalid YAML.
    if s.is_empty() {
        return "\"\"".to_string();
    }
    // repo-0234: a bare scalar that YAML would parse as null/bool/number does
    // NOT round-trip as a string — quote those spellings.
    if yaml_implicit_nonstring(s) {
        return serde_json::to_string(s)
            .map(|json| escape_risky_unicode(&json))
            .unwrap_or_else(|_| format!("\"{}\"", s.escape_debug()));
    }
    // Bare-safe iff every byte is printable ASCII non-special. Control bytes are
    // checked separately so a future indicator change can't drop the guards.
    let needs_quoting = s
        .bytes()
        .any(|b| YAML_NEEDS_QUOTING_BYTES.contains(&b) || b < 0x20 || b == 0x7f)
        || s.chars().any(is_risky_unicode);
    if !needs_quoting {
        return s.to_string();
    }
    // JSON escaping (a subset of YAML's double-quoted form) handles every C0
    // byte. Post-process DEL: JSON leaves it literal, but YAML 1.2 §5.7 rejects
    // a literal DEL in a quoted scalar; replace with``
    // (pinned by `yaml_safe_scalar_round_trips_del` in `cli/mcp.rs`).
    serde_json::to_string(s)
        .map(|json| escape_risky_unicode(&json.replace('\u{7f}', "\\u007F")))
        .unwrap_or_else(|_| format!("\"{}\"", s.escape_debug()))
}

/// Render a string for an inline `#`-comment suffix. The risks are line-breakers
/// (`\n`, `\r`) and ANSI escapes, so any control byte triggers `Debug` rendering
/// (printable bytes only).
pub(crate) fn safe_inline_comment(s: &str) -> String {
    // No control bytes → as-is; otherwise debug-escape.
    if s.bytes().any(|b| b < 0x20 || b == 0x7f) || s.chars().any(is_risky_unicode) {
        // Debug-escape handles C0/DEL; the Unicode pass catches what it leaves
        // literal (C1, U+2028/2029, bidi, zero-width).
        escape_risky_unicode(&format!("{s:?}"))
    } else {
        s.to_string()
    }
}

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

    #[test]
    fn risky_unicode_is_escaped_in_scalars_and_comments() {
        // repo-0442: C1 NEL/CSI, line separators, bidi and zero-width must
        // never survive verbatim into a YAML scaffold.
        for ch in [
            '\u{85}', '\u{9b}', '\u{2028}', '\u{2029}', '\u{202E}', '\u{200B}', '\u{FEFF}',
        ] {
            let raw = format!("server{ch}name");
            let scalar = safe_scalar(&raw);
            assert!(
                scalar.starts_with('"'),
                "{raw:?} must be quoted: {scalar:?}"
            );
            assert!(!scalar.contains(ch), "{raw:?} leaked verbatim: {scalar:?}");
            let comment = safe_inline_comment(&raw);
            assert!(
                !comment.contains(ch),
                "{raw:?} leaked into comment: {comment:?}"
            );
        }
        // A bare safe name is unchanged (note: '-' is a YAML indicator byte
        // and intentionally forces quoting, so use a bare-alphanumeric name).
        assert_eq!(safe_scalar("plainserver"), "plainserver");
    }

    // Full round-trip behavior is pinned by the call-site modules (`cli/mcp.rs`,
    // `cli/agent.rs`); these are a load-bearing smoke subset.

    #[test]
    fn safe_scalar_empty_becomes_quoted() {
        assert_eq!(safe_scalar(""), "\"\"");
    }

    #[test]
    fn safe_scalar_plain_identifier_is_bare() {
        assert_eq!(safe_scalar("abc"), "abc");
        assert_eq!(safe_scalar("v1_2_3"), "v1_2_3");
    }

    #[test]
    fn safe_scalar_quotes_yaml_indicator_byte() {
        for &b in YAML_NEEDS_QUOTING_BYTES {
            let s = format!("a{}b", b as char);
            let out = safe_scalar(&s);
            assert!(
                out.starts_with('"') && out.ends_with('"'),
                "byte 0x{b:02x} ({:?}) must force quoting: got {out:?}",
                b as char,
            );
        }
    }

    #[test]
    fn safe_scalar_quotes_control_bytes() {
        // C0 control + DEL.
        for b in 0u8..0x20 {
            let s = format!("a{}b", b as char);
            assert!(safe_scalar(&s).starts_with('"'));
        }
        assert!(safe_scalar("a\x7fb").starts_with('"'));
    }

    #[test]
    fn safe_scalar_escapes_del_for_yaml_roundtrip() {
        // DEL must be escaped, not a raw byte (YAML 1.2 §5.7 disallows a raw DEL
        // in a quoted scalar). Escaped to``.
        let scalar = safe_scalar("\x7f");
        assert!(
            !scalar.contains('\u{7f}'),
            "raw DEL must not appear: {scalar:?}"
        );
        assert!(
            scalar.contains("\\u007F"),
            "DEL must be escaped: {scalar:?}"
        );
        // And the round-trip through serde_yaml recovers the original.
        let doc = format!("k: {scalar}\n");
        let parsed: serde_yaml::Value = serde_yaml::from_str(&doc).expect("DEL round-trip parses");
        assert_eq!(parsed.get("k").and_then(|v| v.as_str()), Some("\x7f"));
    }

    #[test]
    fn safe_inline_comment_passes_safe_strings_unchanged() {
        assert_eq!(safe_inline_comment("/etc/foo.json"), "/etc/foo.json");
        assert_eq!(safe_inline_comment(".mcp.json"), ".mcp.json");
    }

    #[test]
    fn safe_inline_comment_escapes_control_bytes() {
        let out = safe_inline_comment("evil\nname");
        // Debug form quotes the entire string and escapes the newline.
        assert!(out.starts_with('"') && out.ends_with('"'), "got {out:?}");
        assert!(out.contains("\\n"));
    }
}