1pub 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("&"),
17 '<' => out.push_str("<"),
18 '>' => out.push_str(">"),
19 '"' => out.push_str("""),
20 '\'' => out.push_str("'"),
21 _ => out.push(ch),
22 }
23 }
24 out
25}
26
27pub 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
51pub 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 #[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("</string>"), "{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 & b");
88 assert_eq!(xml_text("<"), "&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 #[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}