Skip to main content

sley_core/
text.rs

1//! Canonical text codecs shared across crates: shell single-quote rendering
2//! (`sq_quote_buf` family, byte-parity ports of upstream git's quote.c for
3//! 2.55), percent-encoding/decoding, and small formatting helpers built on
4//! them. All crates must route quoting/percent work through this module so the
5//! codecs stay single-homed and oracle-verified.
6
7/// Punctuation git's `sq_quote_buf_pretty` leaves bare (quote.c `ok_punct`).
8const PRETTY_SAFE_PUNCT: &[u8] = b"+,-./:=@_^";
9
10const UPPER_HEX_DIGITS: &[u8; 16] = b"0123456789ABCDEF";
11
12#[inline]
13fn needs_bs_quote(byte: u8) -> bool {
14    byte == b'\'' || byte == b'!'
15}
16
17/// Port of quote.c `sq_quote_buf`: always wrap in single quotes, escaping each
18/// `'` or `!` as `'\''` / `'\!'`. The `!` escape guards against interactive
19/// shells' history expansion, matching upstream 2.55 (`need_bs_quote`).
20pub fn sq_quote_buf(out: &mut Vec<u8>, arg: &[u8]) {
21    out.push(b'\'');
22    for &byte in arg {
23        if needs_bs_quote(byte) {
24            out.extend_from_slice(b"'\\");
25            out.push(byte);
26            out.push(b'\'');
27        } else {
28            out.push(byte);
29        }
30    }
31    out.push(b'\'');
32}
33
34/// Port of quote.c `sq_quote_buf_pretty`: leave `arg` bare when it is non-empty
35/// and every byte is ASCII alphanumeric or one of `+,-./:=@_^`; otherwise fall
36/// back to [`sq_quote_buf`] semantics. An empty argument renders as `''`.
37pub fn sq_quote_buf_pretty(out: &mut Vec<u8>, arg: &[u8]) {
38    if !arg.is_empty()
39        && arg
40            .iter()
41            .all(|&byte| byte.is_ascii_alphanumeric() || PRETTY_SAFE_PUNCT.contains(&byte))
42    {
43        out.extend_from_slice(arg);
44        return;
45    }
46    sq_quote_buf(out, arg);
47}
48
49/// Convenience wrapper around [`sq_quote_buf`] for UTF-8 arguments.
50pub fn sq_quote(arg: &str) -> String {
51    let mut out = String::with_capacity(arg.len() + 2);
52    out.push('\'');
53    for ch in arg.chars() {
54        if ch == '\'' || ch == '!' {
55            out.push_str("'\\");
56            out.push(ch);
57            out.push('\'');
58        } else {
59            out.push(ch);
60        }
61    }
62    out.push('\'');
63    out
64}
65
66/// Convenience wrapper around [`sq_quote_buf_pretty`] for UTF-8 arguments.
67pub fn sq_quote_pretty(arg: &str) -> String {
68    if !arg.is_empty()
69        && arg.bytes().all(
70            |byte| byte.is_ascii_alphanumeric() || PRETTY_SAFE_PUNCT.contains(&byte),
71        )
72    {
73        return arg.to_string();
74    }
75    sq_quote(arg)
76}
77
78/// Port of quote.c `sq_quote_argv`: prefix each argument with a space and
79/// render it through full [`sq_quote`] semantics.
80pub fn sq_quote_argv(args: &[String]) -> String {
81    let mut out = String::new();
82    for arg in args {
83        out.push(' ');
84        out.push_str(&sq_quote(arg));
85    }
86    out
87}
88
89/// Space-prefixed argv rendering used by trace2 `start` lines
90/// (`sq_quote_argv_pretty`): each argument goes through
91/// [`sq_quote_pretty`], joined by single spaces.
92pub fn sq_quote_argv_pretty(args: &[String]) -> String {
93    let mut out = String::new();
94    for arg in args {
95        if !out.is_empty() {
96            out.push(' ');
97        }
98        out.push_str(&sq_quote_pretty(arg));
99    }
100    out
101}
102
103/// Append `byte` to `out` as two uppercase hex digits.
104pub fn hex_byte(out: &mut String, byte: u8) {
105    out.push(UPPER_HEX_DIGITS[(byte >> 4) as usize] as char);
106    out.push(UPPER_HEX_DIGITS[(byte & 0x0F) as usize] as char);
107}
108
109/// Safe-set mode table for [`percent_encode`], preserving the per-site
110/// accept/reject behavior of current call sites.
111#[derive(Clone, Copy, Debug, PartialEq, Eq)]
112pub enum PercentEncodeMode {
113    /// Pass through ASCII alphanumerics plus `_ . ~ / : -`.
114    Field,
115    /// [`PercentEncodeMode::Field`] plus `=`.
116    OptionalField,
117}
118
119impl PercentEncodeMode {
120    #[inline]
121    fn allows(self, byte: u8) -> bool {
122        match self {
123            Self::Field => {
124                byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'~' | b'/' | b':' | b'-')
125            }
126            Self::OptionalField => Self::Field.allows(byte) || byte == b'=',
127        }
128    }
129}
130
131/// Percent-encode `value` into `out`: safe bytes pass through verbatim, every
132/// other byte becomes an uppercase `%XX` escape.
133pub fn percent_encode(out: &mut String, value: &[u8], mode: PercentEncodeMode) {
134    for &byte in value {
135        if mode.allows(byte) {
136            out.push(byte as char);
137        } else {
138            out.push('%');
139            hex_byte(out, byte);
140        }
141    }
142}
143
144/// Failure modes of [`percent_decode`].
145#[derive(Clone, Copy, Debug, PartialEq, Eq)]
146pub enum PercentDecodeError {
147    /// A `%` without two following bytes.
148    TruncatedEscape,
149    /// A `%XX` escape whose digit is not a hexadecimal character.
150    InvalidHexDigit(u8),
151}
152
153fn hex_value(byte: u8) -> Option<u8> {
154    match byte {
155        b'0'..=b'9' => Some(byte - b'0'),
156        b'a'..=b'f' => Some(byte - b'a' + 10),
157        b'A'..=b'F' => Some(byte - b'A' + 10),
158        _ => None,
159    }
160}
161
162/// Strictly percent-decode `value`: every `%` must introduce a `%XX` escape
163/// with case-insensitive hex digits; all other bytes are copied verbatim.
164/// Returns raw bytes — callers own the UTF-8 validation and error wording.
165pub fn percent_decode(value: &[u8]) -> Result<Vec<u8>, PercentDecodeError> {
166    let mut out = Vec::with_capacity(value.len());
167    let mut index = 0;
168    while index < value.len() {
169        if value[index] != b'%' {
170            out.push(value[index]);
171            index += 1;
172            continue;
173        }
174        let Some(&high_digit) = value.get(index + 1) else {
175            return Err(PercentDecodeError::TruncatedEscape);
176        };
177        let Some(&low_digit) = value.get(index + 2) else {
178            return Err(PercentDecodeError::TruncatedEscape);
179        };
180        let high = hex_value(high_digit).ok_or(PercentDecodeError::InvalidHexDigit(high_digit))?;
181        let low = hex_value(low_digit).ok_or(PercentDecodeError::InvalidHexDigit(low_digit))?;
182        out.push((high << 4) | low);
183        index += 3;
184    }
185    Ok(out)
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    #[test]
193    fn empty_argument_renders_as_two_quotes() {
194        assert_eq!(sq_quote(""), "''");
195        assert_eq!(sq_quote_pretty(""), "''");
196        let mut buf = Vec::new();
197        sq_quote_buf(&mut buf, b"");
198        assert_eq!(buf, b"''");
199        sq_quote_buf_pretty(&mut buf, b"");
200        assert_eq!(buf, b"''''");
201    }
202
203    #[test]
204    fn sq_quote_always_wraps_and_escapes_quote_and_bang() {
205        assert_eq!(sq_quote("plain"), "'plain'");
206        assert_eq!(sq_quote("v!1"), "'v'\\!'1'");
207        assert_eq!(sq_quote("it's"), "'it'\\''s'");
208        assert_eq!(sq_quote("a'b!c"), "'a'\\''b'\\!'c'");
209        assert_eq!(sq_quote("back\\slash"), "'back\\slash'");
210        // Non-ASCII passes through untouched inside the quotes.
211        assert_eq!(sq_quote("café"), "'café'");
212    }
213
214    #[test]
215    fn pretty_leaves_safe_sets_bare() {
216        assert_eq!(sq_quote_pretty("plain"), "plain");
217        assert_eq!(sq_quote_pretty("aBc123"), "aBc123");
218        for punct in "+,-./:=@_^".chars() {
219            let token = punct.to_string();
220            assert_eq!(sq_quote_pretty(&token), token, "punct {punct} should stay bare");
221        }
222    }
223
224    #[test]
225    fn pretty_falls_back_to_full_quoting_for_unsafe_bytes() {
226        assert_eq!(sq_quote_pretty("a b"), "'a b'");
227        assert_eq!(sq_quote_pretty("v!1"), "'v'\\!'1'");
228        assert_eq!(sq_quote_pretty("it's"), "'it'\\''s'");
229        assert_eq!(sq_quote_pretty("tab\there"), "'tab\there'");
230        // One unsafe byte anywhere forces whole-arg quoting.
231        assert_eq!(sq_quote_pretty("safe!bang"), "'safe'\\!'bang'");
232        // Non-ASCII bytes are not in the safe set.
233        assert_eq!(sq_quote_pretty("café"), "'café'");
234    }
235
236    #[test]
237    fn buf_and_str_variants_agree() {
238        let to_string = |bytes: Vec<u8>| String::from_utf8(bytes).ok();
239        for arg in [
240            "", "plain", "v!1", "it's", "a b", "+,-./:=@_^", "café", "back\\slash",
241        ] {
242            let mut bytes = Vec::new();
243            sq_quote_buf_pretty(&mut bytes, arg.as_bytes());
244            assert_eq!(
245                to_string(bytes).as_deref(),
246                Some(sq_quote_pretty(arg).as_str()),
247                "pretty mismatch for {arg:?}"
248            );
249            let mut bytes = Vec::new();
250            sq_quote_buf(&mut bytes, arg.as_bytes());
251            assert_eq!(
252                to_string(bytes).as_deref(),
253                Some(sq_quote(arg).as_str()),
254                "buf mismatch for {arg:?}"
255            );
256        }
257    }
258
259    #[test]
260    fn argv_helpers_space_prefix_each_argument() {
261        assert_eq!(sq_quote_argv(&["git".into(), "log --oneline".into()]), " 'git' 'log --oneline'");
262        assert_eq!(
263            sq_quote_argv_pretty(&["git".into(), "log".into(), "v!1".into(), "".into()]),
264            "git log 'v'\\!'1' ''"
265        );
266        assert_eq!(sq_quote_argv(&[]), "");
267        assert_eq!(sq_quote_argv_pretty(&[]), "");
268    }
269
270    #[cfg(unix)]
271    #[test]
272    fn quoted_words_survive_sh_eval_round_trip() {
273        use std::process::Command;
274
275        // sq-quoted words are consumed by a single shell parse (this is how
276        // git splices them into shell command lines), so the quoted text is
277        // passed as part of one `sh -c` program — no extra eval layer.
278        for value in ["plain", "v!1", "it's", "a b", "back\\slash", "$HOME", "`id`"] {
279            let quoted = sq_quote(value);
280            let script = format!("printf %s {quoted}");
281            let output = Command::new("sh")
282                .arg("-c")
283                .arg(&script)
284                .output()
285                .expect("spawn sh");
286            assert!(output.status.success(), "sh failed for {value:?}");
287            assert_eq!(
288                String::from_utf8_lossy(&output.stdout),
289                value,
290                "round-trip failed for {value:?}"
291            );
292        }
293    }
294
295    #[test]
296    fn hex_byte_formats_uppercase_pairs() {
297        let mut out = String::new();
298        hex_byte(&mut out, 0x00);
299        hex_byte(&mut out, 0x0A);
300        hex_byte(&mut out, 0xF0);
301        hex_byte(&mut out, 0xFF);
302        assert_eq!(out, "000AF0FF");
303    }
304
305    #[test]
306    fn percent_encode_field_mode_table() {
307        let mut out = String::new();
308        percent_encode(&mut out, b"aZ0_.~/:-x", PercentEncodeMode::Field);
309        assert_eq!(out, "aZ0_.~/:-x");
310
311        out.clear();
312        percent_encode(&mut out, b" ", PercentEncodeMode::Field);
313        assert_eq!(out, "%20");
314
315        out.clear();
316        percent_encode(&mut out, b"=", PercentEncodeMode::Field);
317        assert_eq!(out, "%3D");
318
319        out.clear();
320        percent_encode(&mut out, b"=", PercentEncodeMode::OptionalField);
321        assert_eq!(out, "=");
322
323        out.clear();
324        percent_encode(&mut out, b"\xff\x01", PercentEncodeMode::Field);
325        assert_eq!(out, "%FF%01");
326
327        out.clear();
328        percent_encode(&mut out, b"a=b c/d", PercentEncodeMode::OptionalField);
329        assert_eq!(out, "a=b%20c/d");
330    }
331
332    #[test]
333    fn percent_decode_is_strict_and_case_insensitive() {
334        assert_eq!(
335            percent_decode(b"plain").as_deref(),
336            Ok(b"plain".as_slice())
337        );
338        assert_eq!(percent_decode(b"%41%62").as_deref(), Ok(b"Ab".as_slice()));
339        assert_eq!(percent_decode(b"a%20b").as_deref(), Ok(b"a b".as_slice()));
340        assert_eq!(
341            percent_decode(b"ab%"),
342            Err(PercentDecodeError::TruncatedEscape)
343        );
344        assert_eq!(
345            percent_decode(b"ab%A"),
346            Err(PercentDecodeError::TruncatedEscape)
347        );
348        assert_eq!(
349            percent_decode(b"ab%G1"),
350            Err(PercentDecodeError::InvalidHexDigit(b'G'))
351        );
352        assert_eq!(
353            percent_decode(b"ab%1G"),
354            Err(PercentDecodeError::InvalidHexDigit(b'G'))
355        );
356        // Raw bytes round-trip through encode/decode.
357        for value in [b"".as_slice(), b"hello world", b"\x00\xff%25"] {
358            let mut encoded = String::new();
359            percent_encode(&mut encoded, value, PercentEncodeMode::Field);
360            assert_eq!(percent_decode(encoded.as_bytes()).as_deref(), Ok(value));
361        }
362    }
363
364    #[cfg(unix)]
365    #[test]
366    fn trace2_start_line_matches_oracle_layout() {
367        // Oracle probe (git 2.55):
368        //   GIT_TRACE2=1 git log --oneline -1 'v!1'
369        //   -> start git log --oneline -1 'v'\!'1'
370        assert_eq!(
371            sq_quote_argv_pretty(&[
372                "git".to_string(),
373                "log".to_string(),
374                "--oneline".to_string(),
375                "-1".to_string(),
376                "v!1".to_string(),
377            ]),
378            "git log --oneline -1 'v'\\!'1'"
379        );
380        // Empty args render as '' on the start line:
381        //   GIT_TRACE2=1 git log -1 "" -> start git log -1 ''
382        assert_eq!(
383            sq_quote_argv_pretty(&[
384                "git".to_string(),
385                "log".to_string(),
386                "-1".to_string(),
387                "".to_string(),
388            ]),
389            "git log -1 ''"
390        );
391    }
392}