Skip to main content

isoloom_spec/
shell.rs

1//! Shell quoting for the scripts Isoloom writes and runs.
2
3/// `s` as one single-quoted POSIX sh word: `it's` becomes `'it'\''s'`, `""` becomes `''`.
4#[must_use]
5pub fn quote(s: &str) -> String {
6    format!("'{}'", s.replace('\'', "'\\''"))
7}
8
9/// Standard base64 (RFC 4648), padded, on one line: how a value of any bytes crosses a
10/// script's standard input as one line of text.
11#[must_use]
12pub fn base64(bytes: &[u8]) -> String {
13    let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
14    for chunk in bytes.chunks(3) {
15        let [a, b, c] = [0, 1, 2].map(|i| chunk.get(i).copied().unwrap_or(0));
16        let sextets = [a >> 2, ((a & 0b11) << 4) | (b >> 4), ((b & 0b1111) << 2) | (c >> 6), c & 0b11_1111];
17        // `n` bytes give `n + 1` symbols; padding fills the four.
18        for (i, sextet) in sextets.into_iter().enumerate() {
19            out.push(if i <= chunk.len() { symbol(sextet) } else { '=' });
20        }
21    }
22    out
23}
24
25/// The base64 symbol of six bits.
26fn symbol(sextet: u8) -> char {
27    char::from(match sextet {
28        0..=25 => b'A' + sextet,
29        26..=51 => b'a' + (sextet - 26),
30        52..=61 => b'0' + (sextet - 52),
31        62 => b'+',
32        _ => b'/',
33    })
34}
35
36#[cfg(test)]
37mod tests {
38    use super::{base64, quote};
39
40    #[test]
41    fn base64_is_the_standard_one() {
42        for (raw, enc) in [
43            ("", ""),
44            ("a", "YQ=="),
45            ("ab", "YWI="),
46            ("abc", "YWJj"),
47            ("abcd", "YWJjZA=="),
48            ("abcde", "YWJjZGU="),
49            ("abcdef", "YWJjZGVm"),
50        ] {
51            assert_eq!(base64(raw.as_bytes()), enc);
52        }
53        assert_eq!(base64(&[0xff, 0xfe, 0x00]), "//4A");
54    }
55
56    #[test]
57    fn base64_uses_every_symbol_of_its_alphabet() {
58        let bytes = [
59            0x00, 0x10, 0x83, 0x10, 0x51, 0x87, 0x20, 0x92, 0x8b, 0x30, 0xd3, 0x8f, 0x41, 0x14, 0x93, 0x51, 0x55, 0x97, 0x61, 0x96, 0x9b, 0x71, 0xd7, 0x9f,
60            0x82, 0x18, 0xa3, 0x92, 0x59, 0xa7, 0xa2, 0x9a, 0xab, 0xb2, 0xdb, 0xaf, 0xc3, 0x1c, 0xb3, 0xd3, 0x5d, 0xb7, 0xe3, 0x9e, 0xbb, 0xf3, 0xdf, 0xbf,
61        ];
62        assert_eq!(base64(&bytes), "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/");
63    }
64
65    proptest::proptest! {
66        #[test]
67        fn base64_is_four_symbols_per_three_bytes_padded_at_the_end(bytes in proptest::collection::vec(proptest::prelude::any::<u8>(), 0..64)) {
68            let encoded = base64(&bytes);
69            proptest::prop_assert_eq!(encoded.len(), bytes.len().div_ceil(3) * 4);
70            let padding = encoded.chars().rev().take_while(|c| *c == '=').count();
71            proptest::prop_assert_eq!(padding, (3 - bytes.len() % 3) % 3);
72            proptest::prop_assert!(encoded.trim_end_matches('=').chars().all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/'));
73        }
74    }
75
76    #[test]
77    fn plain_word_is_wrapped() {
78        assert_eq!(quote("abc"), "'abc'");
79    }
80
81    #[test]
82    fn empty_string_is_an_empty_word() {
83        assert_eq!(quote(""), "''");
84    }
85
86    #[test]
87    fn single_quotes_are_closed_escaped_and_reopened() {
88        assert_eq!(quote("it's"), "'it'\\''s'");
89        assert_eq!(quote("'"), "''\\'''");
90    }
91
92    #[test]
93    fn shell_metacharacters_stay_literal() {
94        assert_eq!(quote("$(rm -rf /) `x` \"y\" \\n"), "'$(rm -rf /) `x` \"y\" \\n'");
95    }
96}