1pub fn unit_to_label(unit: &str) -> String {
14 let base = unit.strip_suffix(".service").unwrap_or(unit);
15 match base.strip_prefix("ados-") {
16 Some(rest) => format!("co.ados.{rest}"),
17 None => base.to_string(),
18 }
19}
20
21pub fn current_uid() -> u32 {
24 nix::unistd::geteuid().as_raw()
25}
26
27fn xml_escape(s: &str) -> String {
29 s.replace('&', "&")
30 .replace('<', "<")
31 .replace('>', ">")
32}
33
34#[derive(Debug, Clone, Copy, Default)]
38pub struct PlistLogPaths<'a> {
39 pub stdout: Option<&'a str>,
41 pub stderr: Option<&'a str>,
43}
44
45pub fn render_plist(
53 label: &str,
54 program: &str,
55 args: &[String],
56 env: &[(String, String)],
57 keep_alive: bool,
58 logs: PlistLogPaths<'_>,
59) -> String {
60 let mut out = String::new();
61 out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
62 out.push_str(
63 "<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \
64 \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n",
65 );
66 out.push_str("<plist version=\"1.0\">\n");
67 out.push_str("<dict>\n");
68
69 out.push_str("\t<key>Label</key>\n");
70 out.push_str(&format!("\t<string>{}</string>\n", xml_escape(label)));
71
72 out.push_str("\t<key>ProgramArguments</key>\n");
73 out.push_str("\t<array>\n");
74 out.push_str(&format!("\t\t<string>{}</string>\n", xml_escape(program)));
75 for a in args {
76 out.push_str(&format!("\t\t<string>{}</string>\n", xml_escape(a)));
77 }
78 out.push_str("\t</array>\n");
79
80 if !env.is_empty() {
81 out.push_str("\t<key>EnvironmentVariables</key>\n");
82 out.push_str("\t<dict>\n");
83 for (k, v) in env {
84 out.push_str(&format!("\t\t<key>{}</key>\n", xml_escape(k)));
85 out.push_str(&format!("\t\t<string>{}</string>\n", xml_escape(v)));
86 }
87 out.push_str("\t</dict>\n");
88 }
89
90 if let Some(path) = logs.stdout {
91 out.push_str("\t<key>StandardOutPath</key>\n");
92 out.push_str(&format!("\t<string>{}</string>\n", xml_escape(path)));
93 }
94 if let Some(path) = logs.stderr {
95 out.push_str("\t<key>StandardErrorPath</key>\n");
96 out.push_str(&format!("\t<string>{}</string>\n", xml_escape(path)));
97 }
98
99 out.push_str("\t<key>RunAtLoad</key>\n");
100 out.push_str("\t<true/>\n");
101
102 if keep_alive {
103 out.push_str("\t<key>KeepAlive</key>\n");
104 out.push_str("\t<dict>\n");
105 out.push_str("\t\t<key>Crashed</key>\n");
106 out.push_str("\t\t<true/>\n");
107 out.push_str("\t</dict>\n");
108 }
109
110 out.push_str("</dict>\n");
111 out.push_str("</plist>\n");
112 out
113}
114
115#[cfg(test)]
116mod tests {
117 use super::*;
118
119 #[test]
120 fn unit_to_label_strips_suffix_and_maps_prefix() {
121 assert_eq!(unit_to_label("ados-control.service"), "co.ados.control");
122 assert_eq!(unit_to_label("ados-wfb"), "co.ados.wfb");
123 assert_eq!(unit_to_label("ados-wfb-rx.service"), "co.ados.wfb-rx");
125 assert_eq!(unit_to_label("mediamtx.service"), "mediamtx");
127 assert_eq!(unit_to_label("custom"), "custom");
128 }
129
130 #[test]
131 fn render_plist_emits_program_args_env_and_keepalive() {
132 let plist = render_plist(
133 "co.ados.control",
134 "/opt/ados/bin/ados-control",
135 &["--profile".to_string(), "compute".to_string()],
136 &[("ADOS_HOME".to_string(), "/var/ados".to_string())],
137 true,
138 PlistLogPaths::default(),
139 );
140 assert!(plist.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"));
142 assert!(plist.contains("<plist version=\"1.0\">"));
143 assert!(plist.trim_end().ends_with("</plist>"));
144 assert!(plist.contains("<key>Label</key>"));
146 assert!(plist.contains("<string>co.ados.control</string>"));
147 assert!(plist.contains("<key>ProgramArguments</key>"));
148 assert!(plist.contains("<string>/opt/ados/bin/ados-control</string>"));
149 assert!(plist.contains("<string>--profile</string>"));
150 assert!(plist.contains("<string>compute</string>"));
151 assert!(plist.contains("<key>EnvironmentVariables</key>"));
153 assert!(plist.contains("<key>ADOS_HOME</key>"));
154 assert!(plist.contains("<string>/var/ados</string>"));
155 assert!(plist.contains("<key>RunAtLoad</key>"));
157 assert!(plist.contains("<key>KeepAlive</key>"));
158 assert!(plist.contains("<key>Crashed</key>"));
159 }
160
161 #[test]
162 fn render_plist_omits_keepalive_and_env_when_unset() {
163 let plist = render_plist(
164 "co.ados.logd",
165 "/opt/ados/bin/ados-logd",
166 &[],
167 &[],
168 false,
169 PlistLogPaths::default(),
170 );
171 assert!(!plist.contains("KeepAlive"));
172 assert!(!plist.contains("EnvironmentVariables"));
173 assert!(!plist.contains("StandardOutPath"));
175 assert!(!plist.contains("StandardErrorPath"));
176 assert!(plist.contains("<string>/opt/ados/bin/ados-logd</string>"));
178 assert!(plist.contains("<key>RunAtLoad</key>"));
179 }
180
181 #[test]
182 fn render_plist_emits_log_redirection_when_given() {
183 let plist = render_plist(
184 "co.ados.control",
185 "/Users/op/.ados/bin/ados-control",
186 &[],
187 &[],
188 true,
189 PlistLogPaths {
190 stdout: Some("/Users/op/.ados/log/control.out.log"),
191 stderr: Some("/Users/op/.ados/log/control.err.log"),
192 },
193 );
194 assert!(plist.contains("<key>StandardOutPath</key>"));
195 assert!(plist.contains("<string>/Users/op/.ados/log/control.out.log</string>"));
196 assert!(plist.contains("<key>StandardErrorPath</key>"));
197 assert!(plist.contains("<string>/Users/op/.ados/log/control.err.log</string>"));
198 }
199
200 #[test]
201 fn render_plist_escapes_xml_special_characters() {
202 let plist = render_plist(
203 "co.ados.x",
204 "/bin/sh",
205 &["a < b & c > d".to_string()],
206 &[("K".to_string(), "v&<>".to_string())],
207 false,
208 PlistLogPaths::default(),
209 );
210 assert!(plist.contains("a < b & c > d"));
211 assert!(plist.contains("v&<>"));
212 assert!(!plist.contains("a < b & c > d"));
214 }
215}