1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
//! 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";
/// 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.
pub(crate) fn safe_scalar(s: &str) -> String {
// Empty must be quoted — bare empty is invalid YAML.
if s.is_empty() {
return "\"\"".to_string();
}
// 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);
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| 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) {
format!("{s:?}")
} else {
s.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
// 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"));
}
}