Skip to main content

hatchery_node/service/
render.rs

1//! Pure generators for OS supervision descriptors. No I/O. Paths come from the
2//! caller (current_exe, home dirs, args) — never hardcoded product paths.
3
4/// Escape a value for a systemd unit Environment= line (double-quoted).
5pub fn systemd_escape_env(value: &str) -> String {
6    let mut out = String::from("\"");
7    for ch in value.chars() {
8        match ch {
9            '\\' | '"' => {
10                out.push('\\');
11                out.push(ch);
12            }
13            '\n' => out.push_str("\\n"),
14            c => out.push(c),
15        }
16    }
17    out.push('"');
18    out
19}
20
21/// Quote one argv element for ExecStart=.
22pub fn systemd_quote_arg(arg: &str) -> String {
23    if arg.is_empty()
24        || arg.contains(|c: char| c.is_whitespace() || "\"'\\$".contains(c))
25    {
26        systemd_escape_env(arg)
27    } else {
28        arg.to_owned()
29    }
30}
31
32pub struct SystemdUnit<'a> {
33    pub description: &'a str,
34    pub exe: &'a str,
35    pub args: &'a [String],
36    pub env: &'a [(String, String)],
37    /// When true, Supervised wrapper is the ExecStart (first arg run-supervised).
38    pub supervised: bool,
39}
40
41pub fn render_systemd_unit(u: &SystemdUnit<'_>) -> String {
42    let mut exec = String::new();
43    exec.push_str(&systemd_quote_arg(u.exe));
44    if u.supervised {
45        exec.push(' ');
46        exec.push_str("run-supervised");
47        exec.push(' ');
48        exec.push_str("--");
49    }
50    for a in u.args {
51        exec.push(' ');
52        exec.push_str(&systemd_quote_arg(a));
53    }
54    let mut body = String::new();
55    body.push_str("[Unit]\n");
56    body.push_str(&format!("Description={}\n", u.description));
57    body.push_str("After=network-online.target\n");
58    body.push_str("Wants=network-online.target\n\n");
59    body.push_str("[Service]\n");
60    body.push_str("Type=simple\n");
61    body.push_str("Restart=always\n");
62    body.push_str("RestartSec=5\n");
63    for (k, v) in u.env {
64        body.push_str("Environment=");
65        body.push_str(k);
66        body.push('=');
67        body.push_str(&systemd_escape_env(v));
68        body.push('\n');
69    }
70    body.push_str("ExecStart=");
71    body.push_str(&exec);
72    body.push('\n');
73    body.push_str("KillMode=control-group\n");
74    body.push_str("TimeoutStopSec=30\n\n");
75    body.push_str("[Install]\n");
76    body.push_str("WantedBy=default.target\n");
77    body
78}
79
80pub struct LaunchPlist<'a> {
81    pub label: &'a str,
82    pub exe: &'a str,
83    pub args: &'a [String],
84    pub env: &'a [(String, String)],
85    pub supervised: bool,
86}
87
88fn xml_escape(s: &str) -> String {
89    s.replace('&', "&amp;")
90        .replace('<', "&lt;")
91        .replace('>', "&gt;")
92        .replace('"', "&quot;")
93}
94
95pub fn render_launchd_plist(p: &LaunchPlist<'_>) -> String {
96    let mut prog = Vec::new();
97    prog.push(p.exe.to_owned());
98    if p.supervised {
99        prog.push("run-supervised".into());
100        prog.push("--".into());
101    }
102    prog.extend(p.args.iter().cloned());
103    let mut out = String::new();
104    out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
105    out.push_str(
106        "<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n",
107    );
108    out.push_str("<plist version=\"1.0\">\n<dict>\n");
109    out.push_str(&format!(
110        "  <key>Label</key>\n  <string>{}</string>\n",
111        xml_escape(p.label)
112    ));
113    out.push_str("  <key>ProgramArguments</key>\n  <array>\n");
114    for a in &prog {
115        out.push_str(&format!("    <string>{}</string>\n", xml_escape(a)));
116    }
117    out.push_str("  </array>\n");
118    if !p.env.is_empty() {
119        out.push_str("  <key>EnvironmentVariables</key>\n  <dict>\n");
120        for (k, v) in p.env {
121            out.push_str(&format!(
122                "    <key>{}</key>\n    <string>{}</string>\n",
123                xml_escape(k),
124                xml_escape(v)
125            ));
126        }
127        out.push_str("  </dict>\n");
128    }
129    out.push_str("  <key>RunAtLoad</key>\n  <true/>\n");
130    out.push_str("  <key>KeepAlive</key>\n  <true/>\n");
131    out.push_str("  <key>ThrottleInterval</key>\n  <integer>5</integer>\n");
132    out.push_str("</dict>\n</plist>\n");
133    out
134}
135
136pub struct SchtasksXml<'a> {
137    pub task_name: &'a str,
138    pub exe: &'a str,
139    pub args: &'a [String],
140    pub working_dir: &'a str,
141    pub author: &'a str,
142    /// When false, `exe` is gate4agent-nodew which inserts run-supervised itself.
143    pub supervised: bool,
144}
145
146pub fn render_schtasks_xml(t: &SchtasksXml<'_>) -> String {
147    // run-supervised covers exit codes Task Scheduler misses; nodew inserts it.
148    let mut arguments = String::new();
149    if t.supervised {
150        arguments.push_str("run-supervised --");
151    }
152    for a in t.args {
153        if !arguments.is_empty() {
154            arguments.push(' ');
155        }
156        if a.is_empty() || a.contains(char::is_whitespace) || a.contains('"') {
157            arguments.push('"');
158            arguments.push_str(&a.replace('"', "\\\""));
159            arguments.push('"');
160        } else {
161            arguments.push_str(a);
162        }
163    }
164    format!(
165        r#"<?xml version="1.0" encoding="UTF-16"?>
166<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
167  <RegistrationInfo>
168    <Author>{author}</Author>
169    <Description>gate4agent-node ({name})</Description>
170  </RegistrationInfo>
171  <Triggers>
172    <LogonTrigger>
173      <Enabled>true</Enabled>
174    </LogonTrigger>
175  </Triggers>
176  <Principals>
177    <Principal id="Author">
178      <LogonType>InteractiveToken</LogonType>
179      <RunLevel>LeastPrivilege</RunLevel>
180    </Principal>
181  </Principals>
182  <Settings>
183    <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
184    <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
185    <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
186    <AllowHardTerminate>true</AllowHardTerminate>
187    <StartWhenAvailable>true</StartWhenAvailable>
188    <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
189    <IdleSettings>
190      <StopOnIdleEnd>false</StopOnIdleEnd>
191      <RestartOnIdle>false</RestartOnIdle>
192    </IdleSettings>
193    <AllowStartOnDemand>true</AllowStartOnDemand>
194    <Enabled>true</Enabled>
195    <Hidden>true</Hidden>
196    <RunOnlyIfIdle>false</RunOnlyIfIdle>
197    <DisallowStartOnRemoteAppSession>false</DisallowStartOnRemoteAppSession>
198    <UseUnifiedSchedulingEngine>true</UseUnifiedSchedulingEngine>
199    <WakeToRun>false</WakeToRun>
200    <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
201    <Priority>7</Priority>
202    <RestartOnFailure>
203      <Interval>PT1M</Interval>
204      <Count>999</Count>
205    </RestartOnFailure>
206  </Settings>
207  <Actions Context="Author">
208    <Exec>
209      <Command>{exe}</Command>
210      <Arguments>{args}</Arguments>
211      <WorkingDirectory>{cwd}</WorkingDirectory>
212    </Exec>
213  </Actions>
214</Task>
215"#,
216        author = xml_escape(t.author),
217        name = xml_escape(t.task_name),
218        exe = xml_escape(t.exe),
219        args = xml_escape(&arguments),
220        cwd = xml_escape(t.working_dir),
221    )
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227    use std::fs;
228    use tempfile::TempDir;
229
230    #[test]
231    fn systemd_unit_roundtrip_in_tempdir() {
232        let dir = TempDir::new().unwrap();
233        let unit = render_systemd_unit(&SystemdUnit {
234            description: "node test",
235            exe: "/opt/bin/gate4agent-node",
236            args: &[
237                "--enrolled".into(),
238                "--enroll-state".into(),
239                "/var/lib/node/enroll".into(),
240            ],
241            env: &[("RUST_LOG".into(), "info".into())],
242            supervised: false,
243        });
244        assert!(unit.contains("Restart=always"));
245        assert!(unit.contains("RestartSec=5"));
246        assert!(unit.contains("ExecStart=/opt/bin/gate4agent-node --enrolled"));
247        assert!(unit.contains("Environment=RUST_LOG=\"info\""));
248        assert!(!unit.to_ascii_lowercase().contains("hatchery-box"));
249        let path = dir.path().join("node.service");
250        fs::write(&path, &unit).unwrap();
251        assert_eq!(fs::read_to_string(path).unwrap(), unit);
252    }
253
254    #[test]
255    fn systemd_supervised_inserts_wrapper() {
256        let unit = render_systemd_unit(&SystemdUnit {
257            description: "n",
258            exe: "/x/gate4agent-node",
259            args: &["--enrolled".into()],
260            env: &[],
261            supervised: true,
262        });
263        assert!(unit.contains("run-supervised -- --enrolled"));
264    }
265
266    #[test]
267    fn launchd_plist_keepalive() {
268        let dir = TempDir::new().unwrap();
269        let plist = render_launchd_plist(&LaunchPlist {
270            label: "com.example.node",
271            exe: "/usr/local/bin/gate4agent-node",
272            args: &["--enrolled".into()],
273            env: &[("GATE4AGENT_NODE_TOKEN".into(), "secret".into())],
274            supervised: true,
275        });
276        assert!(plist.contains("<key>KeepAlive</key>"));
277        assert!(plist.contains("<key>RunAtLoad</key>"));
278        assert!(plist.contains("run-supervised"));
279        assert!(plist.contains("<string>secret</string>"));
280        let path = dir.path().join("agent.plist");
281        fs::write(&path, &plist).unwrap();
282        assert!(fs::read_to_string(path).unwrap().contains("com.example.node"));
283    }
284
285    #[test]
286    fn schtasks_xml_hidden_and_supervised() {
287        let dir = TempDir::new().unwrap();
288        let xml = render_schtasks_xml(&SchtasksXml {
289            task_name: "gate4agent-node",
290            exe: r"C:\Users\x\.cargo\bin\gate4agent-node.exe",
291            args: &["--enrolled".into(), "--enroll-state".into(), r"C:\Users\x\state".into()],
292            working_dir: r"C:\Users\x",
293            author: "gate4agent-node",
294            supervised: true,
295        });
296        assert!(xml.contains("<Hidden>true</Hidden>"));
297        assert!(xml.contains("ExecutionTimeLimit>PT0S"));
298        assert!(xml.contains("RestartOnFailure"));
299        assert!(xml.contains("run-supervised --"));
300        assert!(xml.contains("LogonTrigger"));
301        let path = dir.path().join("task.xml");
302        fs::write(&path, &xml).unwrap();
303        assert!(fs::metadata(path).unwrap().len() > 100);
304    }
305
306    #[test]
307    fn env_escaping_quotes_and_backslash() {
308        assert_eq!(systemd_escape_env(r#"a"b\c"#), r#""a\"b\\c""#);
309    }
310}