Skip to main content

apollo/
escape.rs

1//! Escaping for the three text formats apollo generates from paths it does not
2//! control: launchd plists, systemd units, and a generated shell script.
3//!
4//! A workspace path is whatever directory the user pointed apollo at, and on
5//! Unix a directory name may legally contain `<`, `"`, `$`, a space, or a
6//! newline. Rejecting those characters outright is wrong — a macOS path with a
7//! space in it is entirely ordinary — so each value is escaped for the format
8//! it lands in. Only a newline, which no format can represent inside a value,
9//! is refused.
10
11/// Escape a value for use as XML character data, as in a plist `<string>`.
12pub fn xml_text(value: &str) -> String {
13    let mut out = String::with_capacity(value.len());
14    for ch in value.chars() {
15        match ch {
16            '&' => out.push_str("&amp;"),
17            '<' => out.push_str("&lt;"),
18            '>' => out.push_str("&gt;"),
19            '"' => out.push_str("&quot;"),
20            '\'' => out.push_str("&apos;"),
21            _ => out.push(ch),
22        }
23    }
24    out
25}
26
27/// Quote a value as one argument in a systemd unit directive.
28///
29/// systemd splits a command line on whitespace unless the word is quoted, and
30/// inside a double-quoted word it honours C-style backslash escapes — so both
31/// `\` and `"` must be escaped. Specifier expansion (`%h`, `%i`, …) happens
32/// regardless of quoting, so `%` is doubled to keep it literal.
33pub fn systemd_argument(value: &str) -> anyhow::Result<String> {
34    reject_newlines(value, "systemd unit")?;
35    let mut out = String::with_capacity(value.len() + 2);
36    out.push('"');
37    for ch in value.chars() {
38        match ch {
39            '\\' | '"' => {
40                out.push('\\');
41                out.push(ch);
42            }
43            '%' => out.push_str("%%"),
44            _ => out.push(ch),
45        }
46    }
47    out.push('"');
48    Ok(out)
49}
50
51/// Quote a value for a POSIX shell.
52///
53/// Single quotes suppress every expansion, so only the closing quote itself
54/// needs handling.
55pub fn shell_argument(value: &str) -> String {
56    format!("'{}'", value.replace('\'', r"'\''"))
57}
58
59fn reject_newlines(value: &str, format: &str) -> anyhow::Result<()> {
60    if value.contains(['\n', '\r']) {
61        anyhow::bail!(
62            "refusing to write a {}: the path contains a line break, which no unit file can \
63             represent. Move apollo to a path without one.",
64            format
65        );
66    }
67    Ok(())
68}
69
70#[cfg(test)]
71mod tests {
72    use super::*;
73
74    /// A directory named to close a `<string>` element and append a new key is
75    /// legal on Unix, and would otherwise gain boot-persistent execution.
76    #[test]
77    fn xml_neutralises_a_hostile_path() {
78        let hostile = "/tmp/ws</string><key>RunAtLoad</key><true/><string>";
79        let escaped = xml_text(hostile);
80        assert!(!escaped.contains('<'), "{escaped}");
81        assert!(!escaped.contains('>'), "{escaped}");
82        assert!(escaped.contains("&lt;/string&gt;"), "{escaped}");
83    }
84
85    #[test]
86    fn xml_escapes_ampersands_first_so_output_is_not_double_decoded() {
87        assert_eq!(xml_text("a & b"), "a &amp; b");
88        assert_eq!(xml_text("&lt;"), "&amp;lt;");
89    }
90
91    #[test]
92    fn xml_leaves_an_ordinary_path_with_a_space_alone() {
93        assert_eq!(
94            xml_text("/Users/me/Library/Application Support/ws"),
95            "/Users/me/Library/Application Support/ws"
96        );
97    }
98
99    #[test]
100    fn systemd_quoting_keeps_a_hostile_path_in_one_argument() {
101        let hostile = "/tmp/ws\nExecStartPost=/bin/sh -c evil";
102        assert!(
103            systemd_argument(hostile).is_err(),
104            "newline must be refused"
105        );
106
107        let quoted = systemd_argument("/tmp/ws \" --evil").unwrap();
108        assert_eq!(quoted, r#""/tmp/ws \" --evil""#);
109        let backslash = systemd_argument(r"/tmp/ws\x").unwrap();
110        assert_eq!(backslash, r#""/tmp/ws\\x""#);
111    }
112
113    /// Specifiers expand inside quotes too, so `%h` must not become the home
114    /// directory.
115    #[test]
116    fn systemd_quoting_keeps_specifiers_literal() {
117        assert_eq!(
118            systemd_argument("%h/evil.json").unwrap(),
119            r#""%%h/evil.json""#
120        );
121    }
122
123    #[test]
124    fn systemd_quoting_accepts_an_ordinary_path_with_a_space() {
125        assert_eq!(
126            systemd_argument("/home/me/my workspace").unwrap(),
127            r#""/home/me/my workspace""#
128        );
129    }
130
131    #[test]
132    fn shell_quoting_survives_quotes_and_expansions() {
133        assert_eq!(shell_argument("/tmp/my ws"), "'/tmp/my ws'");
134        assert_eq!(shell_argument("/tmp/$(id)"), "'/tmp/$(id)'");
135        assert_eq!(shell_argument("/tmp/it's"), r"'/tmp/it'\''s'");
136    }
137}