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("<"),
115 '>' => out.push_str(">"),
116 '&' => out.push_str("&"),
117 '"' => out.push_str("""),
118 '\'' => out.push_str("'"),
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<b&c>d"e'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}