Skip to main content

asc_query/
text.rs

1//! The text form of every tool: one record per line, ` | ` between columns,
2//! strings of the program in JSON quotes.
3//!
4//! Strings come from the program under audit, so whatever could break a
5//! line or change how it reads is escaped: control characters, the line and
6//! paragraph separators, the bidirectional controls that reorder the text
7//! around them, and the invisible format characters (zero-width spaces and
8//! joiners, the byte order mark, tag characters). A row then shows exactly
9//! the code points it holds, and reads back with any JSON parser.
10
11use std::borrow::Cow;
12use std::fmt::Write as _;
13
14/// Whether `c` is written as a `\u` escape rather than as itself: a control
15/// character, a line or paragraph separator, or a format character
16/// (Unicode category Cf: bidirectional controls, zero-width characters,
17/// the byte order mark, tag characters, and the rest).
18pub fn must_escape(c: char) -> bool {
19    c.is_control()
20        || matches!(
21            c,
22            '\u{00ad}'
23                | '\u{0600}'..='\u{0605}'
24                | '\u{061c}'
25                | '\u{06dd}'
26                | '\u{070f}'
27                | '\u{0890}'..='\u{0891}'
28                | '\u{08e2}'
29                | '\u{180e}'
30                | '\u{200b}'..='\u{200f}'
31                | '\u{2028}'..='\u{202e}'
32                | '\u{2060}'..='\u{2064}'
33                | '\u{2066}'..='\u{206f}'
34                | '\u{feff}'
35                | '\u{fff9}'..='\u{fffb}'
36                | '\u{110bd}'
37                | '\u{110cd}'
38                | '\u{13430}'..='\u{1343f}'
39                | '\u{1bca0}'..='\u{1bca3}'
40                | '\u{1d173}'..='\u{1d17a}'
41                | '\u{e0001}'
42                | '\u{e0020}'..='\u{e007f}'
43        )
44}
45
46/// Append the `\u` escape of `c`: one per UTF-16 unit, as JSON writes a
47/// character beyond the BMP.
48pub fn push_unicode_escape(out: &mut String, c: char) {
49    for unit in c.encode_utf16(&mut [0; 2]) {
50        // Writing to a String cannot fail.
51        let _ = write!(out, "\\u{unit:04x}");
52    }
53}
54
55/// Append `s` in double quotes, escaped as JSON does (`\"`, `\\`, `\n`,
56/// `\t`, `\r`) and every other character [`must_escape`] names as `\uXXXX`,
57/// so it stays on one line and shows what it holds.
58pub fn quote_into(out: &mut String, s: &str) {
59    out.reserve(s.len() + 2);
60    out.push('"');
61    for c in s.chars() {
62        match c {
63            '"' => out.push_str("\\\""),
64            '\\' => out.push_str("\\\\"),
65            '\n' => out.push_str("\\n"),
66            '\t' => out.push_str("\\t"),
67            '\r' => out.push_str("\\r"),
68            c if must_escape(c) => push_unicode_escape(out, c),
69            c => out.push(c),
70        }
71    }
72    out.push('"');
73}
74
75/// `s` in double quotes ([`quote_into`]).
76pub fn quoted(s: &str) -> String {
77    let mut out = String::new();
78    quote_into(&mut out, s);
79    out
80}
81
82/// The value of a `key=value` pair: as it is, or [`quoted`] when it is empty
83/// or holds whitespace, a quote, a backslash, `=` or a character
84/// [`must_escape`] names, which would otherwise blur where the pair ends or
85/// what it says.
86pub fn value(s: &str) -> Cow<'_, str> {
87    let plain = !s.is_empty() && !s.chars().any(|c| c.is_whitespace() || must_escape(c) || matches!(c, '"' | '\\' | '='));
88    if plain { Cow::Borrowed(s) } else { Cow::Owned(quoted(s)) }
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94
95    #[test]
96    fn quoted_strings_stay_on_one_line_and_parse_as_json() {
97        let raw = "a\"b\\c\nd\te\r\u{1b}\u{7f}\u{85}é😀";
98        let q = quoted(raw);
99        assert_eq!(q, r#""a\"b\\c\nd\te\r\u001b\u007f\u0085é😀""#);
100        assert_eq!(serde_json::from_str::<String>(&q).unwrap(), raw);
101    }
102
103    #[test]
104    fn characters_that_disguise_a_row_are_escaped() {
105        // Right-to-left override, isolates, zero-width space and joiner, BOM,
106        // the line separator and a tag character.
107        let raw = "pay\u{202e}lmth.exe\u{2066}x\u{2069}\u{200b}\u{200d}\u{feff}\u{2028}\u{e0041}";
108        let q = quoted(raw);
109        assert_eq!(q, r#""pay\u202elmth.exe\u2066x\u2069\u200b\u200d\ufeff\u2028\udb40\udc41""#);
110        assert_eq!(serde_json::from_str::<String>(&q).unwrap(), raw);
111        assert!(q.is_ascii());
112        // Visible text of any script stays as it is.
113        assert_eq!(quoted("登录 مرحبا ñ"), "\"登录 مرحبا ñ\"");
114        for c in ['\u{2066}', '\u{2069}', '\u{202a}', '\u{200e}', '\u{061c}', '\u{00ad}', '\u{2060}'] {
115            assert!(must_escape(c), "{:04x}", u32::from(c));
116        }
117        for c in ['a', ' ', '\u{a0}', '\u{2010}', '\u{2030}', '中'] {
118            assert!(!must_escape(c), "{:04x}", u32::from(c));
119        }
120    }
121
122    #[test]
123    fn values_are_quoted_only_when_ambiguous() {
124        assert_eq!(value("com.demo.Main"), "com.demo.Main");
125        assert_eq!(value("signature|privileged"), "signature|privileged");
126        assert_eq!(value("1.0 beta"), r#""1.0 beta""#);
127        assert_eq!(value("a=b"), r#""a=b""#);
128        assert_eq!(value(""), r#""""#);
129        assert_eq!(value("a\u{200b}b"), r#""a\u200bb""#);
130    }
131}