Skip to main content

running_process_platform_internal/platform/
autostart.rs

1//! Registering a program to start when the user logs in.
2//!
3//! Every host has a mechanism for this and no two agree on what it is called,
4//! where it is stored, or what it is written in: a systemd user unit, a
5//! launchd agent plist, a Task Scheduler ONLOGON task. A caller has one
6//! question -- start this program at login, and stop doing so -- and answering
7//! it should not require knowing which of the three is in play.
8//!
9//! What stays with the caller is *enrolment*: whether to register at all, what
10//! the thing is called, and which program runs. Those arrive in
11//! [`AutostartProgram`].
12
13use std::io;
14use std::path::PathBuf;
15
16pub use crate::{
17    autostart_register as register, autostart_render_registration as render_registration,
18    autostart_unregister as unregister,
19};
20
21/// What a caller wants started at login.
22///
23/// The two names are separate because the hosts disagree about what a name is:
24/// launchd requires a reverse-DNS label, systemd and Task Scheduler want a
25/// plain stem. Deriving one from the other would be a guess, so the caller
26/// states both.
27#[derive(Debug, Clone, Copy)]
28pub struct AutostartProgram<'a> {
29    /// Plain stem: the systemd unit name and the Task Scheduler task name.
30    pub identifier: &'a str,
31    /// Reverse-DNS label, as launchd requires.
32    pub label: &'a str,
33    /// One line describing the program, for hosts that record one.
34    pub description: &'a str,
35    /// The program to run.
36    pub program: &'a std::path::Path,
37    /// The argument that starts it.
38    pub start_argument: &'a str,
39    /// The argument that stops it, for hosts that ask for one.
40    pub stop_argument: &'a str,
41}
42
43/// Why a registration did not happen.
44///
45/// The three cases are kept apart because an operator does something different
46/// about each: fix the environment, fix the filesystem, or arm the init system
47/// by hand.
48#[derive(Debug)]
49pub enum AutostartError {
50    /// The host would not say where the registration belongs -- typically a
51    /// missing `HOME` or `XDG_CONFIG_HOME`.
52    Resolve(String),
53    /// Writing or removing the registration failed.
54    Io(io::Error),
55    /// The init system itself refused, or could not be invoked.
56    InitSystem(String),
57}
58
59impl std::fmt::Display for AutostartError {
60    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61        match self {
62            Self::Resolve(detail) => write!(f, "could not resolve autostart location: {detail}"),
63            Self::Io(error) => write!(f, "autostart file operation failed: {error}"),
64            Self::InitSystem(detail) => write!(f, "init system rejected autostart: {detail}"),
65        }
66    }
67}
68
69impl std::error::Error for AutostartError {}
70
71impl From<io::Error> for AutostartError {
72    fn from(error: io::Error) -> Self {
73        Self::Io(error)
74    }
75}
76
77/// Where a registration was written.
78pub type Registration = PathBuf;
79
80/// Wrap a string in POSIX single quotes, escaping embedded single quotes with
81/// the standard `'\''` dance.
82///
83/// Lives in the neutral leaf rather than the Linux tree so its tests run on
84/// every host: the escaping rule is the kind of thing that is wrong once and
85/// then wrong everywhere it is copied, and a path with a quote in it is not
86/// the moment to find that out.
87// Only the systemd implementation calls this, so the other hosts see it as
88// dead code. It stays compiled on all of them anyway -- that is what keeps
89// the tests below running everywhere rather than on one host.
90#[allow(dead_code)]
91pub(crate) fn shell_quote_single(value: &str) -> String {
92    let mut out = String::with_capacity(value.len() + 2);
93    out.push('\'');
94    for ch in value.chars() {
95        if ch == '\'' {
96            // Close quote, escaped literal single, re-open quote.
97            out.push_str("'\\''");
98        } else {
99            out.push(ch);
100        }
101    }
102    out.push('\'');
103    out
104}
105
106/// Escape a string for an XML text node.
107///
108/// Neutral for the same reason as [`shell_quote_single`].
109#[allow(dead_code)]
110pub(crate) fn xml_escape(value: &str) -> String {
111    let mut out = String::with_capacity(value.len());
112    for ch in value.chars() {
113        match ch {
114            '<' => out.push_str("&lt;"),
115            '>' => out.push_str("&gt;"),
116            '&' => out.push_str("&amp;"),
117            '"' => out.push_str("&quot;"),
118            '\'' => out.push_str("&apos;"),
119            other => out.push(other),
120        }
121    }
122    out
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    #[test]
130    fn shell_quote_wraps_simple_path() {
131        assert_eq!(shell_quote_single("/usr/bin/foo"), "'/usr/bin/foo'");
132    }
133
134    #[test]
135    fn shell_quote_escapes_embedded_single_quote() {
136        assert_eq!(shell_quote_single("o'malley"), "'o'\\''malley'");
137    }
138
139    #[test]
140    fn xml_escape_handles_metacharacters() {
141        assert_eq!(
142            xml_escape("a<b&c>d\"e'f"),
143            "a&lt;b&amp;c&gt;d&quot;e&apos;f"
144        );
145    }
146
147    /// Whatever this host writes, it writes the program path into it, and the
148    /// caller's identifier is recoverable from what comes back.
149    #[test]
150    fn a_registration_names_the_program_it_starts() {
151        let program = std::path::Path::new("/opt/example/bin/exampled");
152        let rendered = render_registration(&AutostartProgram {
153            identifier: "example-daemon",
154            label: "com.example.daemon",
155            description: "example supervisor",
156            program,
157            start_argument: "start",
158            stop_argument: "stop",
159        });
160        assert!(
161            rendered.contains("exampled"),
162            "registration must name the program: {rendered}"
163        );
164        assert!(
165            rendered.contains("start"),
166            "registration must say how to start it: {rendered}"
167        );
168    }
169}