use anyhow::{Result, bail};
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum Role {
Up,
Host,
Collector,
}
impl Role {
pub const ALL: [Role; 3] = [Role::Up, Role::Host, Role::Collector];
pub fn name(self) -> &'static str {
match self {
Self::Up => "up",
Self::Host => "host",
Self::Collector => "collector",
}
}
pub fn label(self) -> String {
format!("dev.hennery.{}", self.name())
}
pub fn unit(self) -> &'static str {
match self {
Self::Up => "hennery.service",
Self::Host => "hennery-host.service",
Self::Collector => "hennery-collector.service",
}
}
fn subcommand(self) -> &'static [&'static str] {
match self {
Self::Up => &["up"],
Self::Host => &["host", "run"],
Self::Collector => &["collector"],
}
}
}
impl std::fmt::Display for Role {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.name())
}
}
pub fn command_line(role: Role, exe: &Path, data_dir: &Path) -> Result<Vec<String>> {
let mut argv = vec![plain(exe)?];
argv.extend(role.subcommand().iter().map(|s| s.to_string()));
argv.push("--data-dir".into());
argv.push(plain(data_dir)?);
Ok(argv)
}
pub fn plain(path: &Path) -> Result<String> {
let Some(text) = path.to_str() else {
bail!("{} is not valid UTF-8", path.display());
};
if !path.is_absolute() {
bail!("{text} is not an absolute path");
}
if text.chars().any(char::is_control) {
bail!("{text:?} holds a control character");
}
Ok(text.to_string())
}
pub(crate) fn xml(text: &str) -> String {
text.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
fn unxml(text: &str) -> String {
text.replace("<", "<")
.replace(">", ">")
.replace(""", "\"")
.replace("'", "'")
.replace("&", "&")
}
pub fn plist(role: Role, argv: &[String], path: &str, shell: &str, log: &str) -> String {
let args: String = argv
.iter()
.map(|a| format!("\t\t<string>{}</string>\n", xml(a)))
.collect();
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>{label}</string>
<key>ProgramArguments</key>
<array>
{args} </array>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>{path}</string>
<key>SHELL</key>
<string>{shell}</string>
<key>HENNERY_SERVICE</key>
<string>launchd</string>
</dict>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<dict>
<key>SuccessfulExit</key>
<false/>
</dict>
<key>ThrottleInterval</key>
<integer>10</integer>
<key>ExitTimeOut</key>
<integer>30</integer>
<key>LimitLoadToSessionType</key>
<string>Aqua</string>
<key>StandardOutPath</key>
<string>{log}</string>
<key>StandardErrorPath</key>
<string>{log}</string>
</dict>
</plist>
"#,
label = xml(&role.label()),
path = xml(path),
shell = xml(shell),
log = xml(log),
)
}
pub fn plist_command_line(text: &str) -> Option<Vec<String>> {
let (_, rest) = text.split_once("<key>ProgramArguments</key>")?;
let (array, _) = rest.split_once("</array>")?;
Some(
array
.split("<string>")
.skip(1)
.filter_map(|s| s.split_once("</string>").map(|(arg, _)| unxml(arg)))
.collect(),
)
}
fn systemd_word(text: &str) -> String {
let escaped = text
.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('%', "%%")
.replace('$', "$$");
format!("\"{escaped}\"")
}
pub fn systemd_unit(role: Role, argv: &[String], env_file: &str) -> Result<String> {
if env_file.contains(['\\', '"']) {
bail!("{env_file} holds a backslash or a double quote, which an EnvironmentFile= path cannot");
}
let exec: Vec<String> = argv.iter().map(|a| systemd_word(a)).collect();
let revoked = if role == Role::Host {
format!("RestartPreventExitStatus={}\n", crate::supervisor::REVOKED_EXIT)
} else {
String::new()
};
Ok(format!(
"[Unit]
Description=hennery {role}
StartLimitIntervalSec=300
StartLimitBurst=10
[Service]
Type=exec
ExecStart={exec}
EnvironmentFile={env_file}
Environment=HENNERY_SERVICE=systemd
Restart=on-failure
{revoked}RestartSec=3
KillMode=mixed
TimeoutStopSec=30
[Install]
WantedBy=default.target
",
exec = exec.join(" "),
env_file = env_file.replace('%', "%%"),
))
}
pub fn systemd_command_line(text: &str) -> Option<Vec<String>> {
let line = text.lines().find_map(|l| l.trim_end().strip_prefix("ExecStart="))?;
let mut words = Vec::new();
let mut chars = line.chars().peekable();
loop {
while chars.next_if_eq(&' ').is_some() {}
if chars.next()? != '"' {
return None;
}
let mut word = String::new();
loop {
match chars.next()? {
'"' => break,
'\\' => word.push(chars.next()?),
c @ ('%' | '$') => {
if chars.next()? != c {
return None;
}
word.push(c);
}
c => word.push(c),
}
}
words.push(word);
if chars.peek().is_none() {
return Some(words);
}
}
}
pub fn env_file(path: &str, shell: &str) -> String {
format!(
"# Written by `hennery service install`: the login shell and its PATH.\nSHELL={}\nPATH={}\n",
env_quoted(shell),
env_quoted(path)
)
}
fn env_quoted(value: &str) -> String {
let mut escaped = String::new();
for c in value.chars() {
if matches!(c, '\\' | '"' | '$' | '`') {
escaped.push('\\');
}
escaped.push(c);
}
format!("\"{escaped}\"")
}
pub fn plist_path(text: &str) -> Option<String> {
plist_env(text, "PATH")
}
pub fn plist_shell(text: &str) -> Option<String> {
plist_env(text, "SHELL")
}
fn plist_env(text: &str, name: &str) -> Option<String> {
let (_, rest) = text.split_once("<key>EnvironmentVariables</key>")?;
let (dict, _) = rest.split_once("</dict>")?;
let (_, after) = dict.split_once(&format!("<key>{name}</key>"))?;
let (between, value) = after.split_once("<string>")?;
if !between.trim().is_empty() {
return None;
}
let (value, _) = value.split_once("</string>")?;
Some(unxml(value))
}
pub fn env_file_path(text: &str) -> Option<String> {
env_file_value(text, "PATH")
}
pub fn env_file_shell(text: &str) -> Option<String> {
env_file_value(text, "SHELL")
}
fn env_file_value(text: &str, name: &str) -> Option<String> {
let prefix = format!("{name}=");
let line = text.lines().rev().find_map(|l| l.strip_prefix(prefix.as_str()))?;
let quoted = line.trim_end().strip_prefix('"')?.strip_suffix('"')?;
let mut value = String::new();
let mut chars = quoted.chars();
while let Some(c) = chars.next() {
if c == '\\' {
value.push(chars.next()?);
} else {
value.push(c);
}
}
Some(value)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn awkward() -> PathBuf {
PathBuf::from("/tmp/a dir/with %h $HOME \"quotes\" & <tags> 'and' \\back")
}
#[test]
fn each_role_runs_its_own_subcommand_with_its_data_dir() {
let exe = Path::new("/opt/bin/hennery");
let data = Path::new("/var/data");
assert_eq!(
command_line(Role::Up, exe, data).unwrap(),
["/opt/bin/hennery", "up", "--data-dir", "/var/data"]
);
assert_eq!(
command_line(Role::Host, exe, data).unwrap(),
["/opt/bin/hennery", "host", "run", "--data-dir", "/var/data"]
);
assert_eq!(
command_line(Role::Collector, exe, data).unwrap(),
["/opt/bin/hennery", "collector", "--data-dir", "/var/data"]
);
assert_eq!(Role::Up.unit(), "hennery.service");
assert_eq!(Role::Host.label(), "dev.hennery.host");
}
#[test]
fn paths_no_service_file_can_carry_are_refused() {
let exe = Path::new("/opt/bin/hennery");
for bad in ["/tmp/new\nline", "/tmp/tab\there", "relative/dir"] {
assert!(command_line(Role::Up, exe, Path::new(bad)).is_err(), "{bad:?}");
}
use std::os::unix::ffi::OsStrExt;
let invalid = Path::new(std::ffi::OsStr::from_bytes(b"/tmp/\xff"));
assert!(command_line(Role::Up, exe, invalid).is_err());
}
#[test]
fn the_plist_holds_what_section_6_2_names() {
let argv = command_line(Role::Up, Path::new("/opt/bin/hennery"), &awkward()).unwrap();
let text = plist(
Role::Up,
&argv,
"/opt/x & y/bin:/usr/bin",
"/opt/my & shell/bin/fish",
"/Users/me/Library/Logs/hennery/up.log",
);
for wanted in [
"<string>dev.hennery.up</string>",
"<key>RunAtLoad</key>\n\t<true/>",
"<key>SuccessfulExit</key>\n\t\t<false/>",
"<key>ThrottleInterval</key>\n\t<integer>10</integer>",
"<key>ExitTimeOut</key>\n\t<integer>30</integer>",
"<key>LimitLoadToSessionType</key>\n\t<string>Aqua</string>",
"<key>PATH</key>\n\t\t<string>/opt/x & y/bin:/usr/bin</string>",
"<key>SHELL</key>\n\t\t<string>/opt/my & shell/bin/fish</string>",
"<key>HENNERY_SERVICE</key>\n\t\t<string>launchd</string>",
"<key>StandardErrorPath</key>\n\t<string>/Users/me/Library/Logs/hennery/up.log</string>",
] {
assert!(text.contains(wanted), "{wanted:?} not in:\n{text}");
}
assert!(!text.contains("CLAUDE"), "{text}");
}
#[test]
fn the_unit_holds_what_section_6_3_names() {
let exe = Path::new("/home/me/.local/bin/hen nery");
let argv = command_line(Role::Host, exe, &awkward()).unwrap();
let text = systemd_unit(Role::Host, &argv, "/home/me/.config/hennery 100%/service.env").unwrap();
for wanted in [
"Description=hennery host",
"StartLimitIntervalSec=300",
"StartLimitBurst=10",
"Type=exec",
"ExecStart=\"/home/me/.local/bin/hen nery\" \"host\" \"run\" \"--data-dir\" ",
"EnvironmentFile=/home/me/.config/hennery 100%%/service.env\n",
"Environment=HENNERY_SERVICE=systemd",
"Restart=on-failure",
"RestartPreventExitStatus=78\n",
"RestartSec=3",
"KillMode=mixed",
"TimeoutStopSec=30",
"WantedBy=default.target",
] {
assert!(text.contains(wanted), "{wanted:?} not in:\n{text}");
}
assert!(text.contains("with %%h $$HOME \\\"quotes\\\""), "{text}");
for role in [Role::Up, Role::Collector] {
let argv = command_line(role, exe, &awkward()).unwrap();
let text = systemd_unit(role, &argv, "/tmp/service.env").unwrap();
assert!(!text.contains("RestartPreventExitStatus"), "{role}: {text}");
}
assert!(systemd_unit(Role::Host, &argv, "/home/me/a\\b/service.env").is_err());
assert!(systemd_unit(Role::Host, &argv, "/home/me/a\"b/service.env").is_err());
}
#[test]
fn the_env_file_holds_the_shell_and_the_path_alone_quoted() {
let text = env_file("/opt/a b/bin:/opt/$x/`y`/\"z\"\\:/usr/bin", "/opt/$s/fish");
assert_eq!(
text.lines().last().unwrap(),
"PATH=\"/opt/a b/bin:/opt/\\$x/\\`y\\`/\\\"z\\\"\\\\:/usr/bin\""
);
assert!(text.contains("\nSHELL=\"/opt/\\$s/fish\"\n"), "{text}");
assert_eq!(text.lines().filter(|l| !l.starts_with('#')).count(), 2);
}
#[cfg(target_os = "macos")]
#[test]
fn plutil_accepts_the_plist() {
let dir = tempfile::tempdir().unwrap();
let argv = command_line(Role::Up, &std::env::current_exe().unwrap(), &awkward()).unwrap();
let file = dir.path().join("dev.hennery.up.plist");
std::fs::write(
&file,
plist(Role::Up, &argv, "/usr/bin:/bin", "/bin/zsh", "/tmp/a & b/up.log"),
)
.unwrap();
let out = std::process::Command::new("/usr/bin/plutil")
.arg("-lint")
.arg(&file)
.output()
.unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stdout));
}
#[cfg(target_os = "linux")]
#[test]
fn systemd_analyze_accepts_the_unit() {
let required = std::env::var_os("HENNERY_REQUIRE_SYSTEMD_ANALYZE").is_some_and(|v| !v.is_empty());
let Ok(version) = std::process::Command::new("systemd-analyze").arg("--version").output() else {
assert!(!required, "systemd-analyze is required but not installed");
eprintln!("skipped: no systemd-analyze");
return;
};
assert!(version.status.success());
let dir = tempfile::tempdir().unwrap();
let env = dir.path().join("service 100%.env");
std::fs::write(&env, env_file("/usr/bin:/bin", "/bin/bash")).unwrap();
for role in Role::ALL {
let argv = command_line(role, &std::env::current_exe().unwrap(), &awkward()).unwrap();
let file = dir.path().join(role.unit());
std::fs::write(&file, systemd_unit(role, &argv, env.to_str().unwrap()).unwrap()).unwrap();
let out = std::process::Command::new("systemd-analyze")
.args(["--user", "verify"])
.arg(&file)
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(out.status.success(), "{role}: {stderr}");
assert!(!stderr.contains(role.unit()), "{role}: {stderr}");
}
}
#[test]
fn the_command_line_reads_back_from_either_file() {
let exe = Path::new("/home/me/.local/bin/hen nery");
for role in Role::ALL {
let argv = command_line(role, exe, &awkward()).unwrap();
let text = plist(role, &argv, "/usr/bin", "/bin/sh", "/tmp/a & b.log");
assert_eq!(plist_command_line(&text).unwrap(), argv, "{role}");
let text = systemd_unit(role, &argv, "/tmp/env").unwrap();
assert_eq!(systemd_command_line(&text).unwrap(), argv, "{role}");
}
assert_eq!(systemd_command_line("ExecStart=/bin/true"), None);
let argv = command_line(Role::Up, exe, &awkward()).unwrap();
let edited = systemd_unit(Role::Up, &argv, "/tmp/env")
.unwrap()
.replace('\n', " \r\n");
assert_eq!(systemd_command_line(&edited).unwrap(), argv);
assert_eq!(plist_command_line("<plist/>"), None);
}
}