Skip to main content

ados_protocol/
launchd.rs

1//! launchd job helpers shared by every macOS consumer: the unit-name to
2//! reverse-DNS label mapping, the GUI-domain uid, and the property-list renderer.
3//!
4//! Every crate that writes or addresses launchd jobs takes the label and the
5//! plist shape from here, so they cannot disagree on either. Everything in this
6//! module is pure (no `launchctl` calls); driving jobs stays with the callers.
7
8/// Map a systemd-style unit name to a launchd reverse-DNS label.
9///
10/// Strips a trailing `.service` and rewrites an `ados-` prefix to the `co.ados.`
11/// domain, so `ados-control.service` becomes `co.ados.control`. A name without
12/// the `ados-` prefix is used as-is (minus the suffix).
13pub 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
21/// Effective user id of this process, which names the launchd GUI domain
22/// (`gui/<uid>`) its jobs live in.
23pub fn current_uid() -> u32 {
24    nix::unistd::geteuid().as_raw()
25}
26
27/// XML-escape a string for inclusion in a plist `<string>` / `<key>` element.
28fn xml_escape(s: &str) -> String {
29    s.replace('&', "&amp;")
30        .replace('<', "&lt;")
31        .replace('>', "&gt;")
32}
33
34/// Optional job log redirection for a rendered plist. launchd defaults a job's
35/// stdout/stderr to `/dev/null`; pointing them at a file is what makes a
36/// background agent inspectable after the fact.
37#[derive(Debug, Clone, Copy, Default)]
38pub struct PlistLogPaths<'a> {
39    /// Absolute path for the job's stdout (`StandardOutPath`), or `None`.
40    pub stdout: Option<&'a str>,
41    /// Absolute path for the job's stderr (`StandardErrorPath`), or `None`.
42    pub stderr: Option<&'a str>,
43}
44
45/// Render a launchd property list for a managed service.
46///
47/// Emits `ProgramArguments` (the `program` as `argv[0]` followed by `args`),
48/// `EnvironmentVariables` (when any are given), `StandardOutPath` /
49/// `StandardErrorPath` (when a log path is given), `RunAtLoad` true, and — when
50/// `keep_alive` — a `KeepAlive` dict requesting a restart only on a crash
51/// (`Crashed` true). The output is a complete, well-formed plist document.
52pub 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        // A multi-segment service tail is preserved after the domain rewrite.
124        assert_eq!(unit_to_label("ados-wfb-rx.service"), "co.ados.wfb-rx");
125        // A name without the ados- prefix is used as-is (suffix still stripped).
126        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        // Document framing.
141        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        // Label + program argv[0] + args in order.
145        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        // Environment variables dict.
152        assert!(plist.contains("<key>EnvironmentVariables</key>"));
153        assert!(plist.contains("<key>ADOS_HOME</key>"));
154        assert!(plist.contains("<string>/var/ados</string>"));
155        // RunAtLoad true + KeepAlive {Crashed: true}.
156        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        // No log paths were given, so neither redirection key is emitted.
174        assert!(!plist.contains("StandardOutPath"));
175        assert!(!plist.contains("StandardErrorPath"));
176        // The program is still the sole ProgramArguments entry.
177        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 &lt; b &amp; c &gt; d"));
211        assert!(plist.contains("v&amp;&lt;&gt;"));
212        // No raw special characters leaked into the rendered argument value.
213        assert!(!plist.contains("a < b & c > d"));
214    }
215}