use std::fmt::Write;
use std::path::{Path, PathBuf};
pub const LABEL: &str = "no.elektronaut.carte";
pub const OLD_LABEL: &str = "carte.daemon";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Agent {
pub exe: PathBuf,
pub home: PathBuf,
pub config_dir: PathBuf,
pub state_dir: PathBuf,
pub http_port: u16,
pub https_port: u16,
}
impl Agent {
pub fn log_path(&self) -> PathBuf {
self.state_dir.join("carte.log")
}
pub fn path_var(&self) -> String {
let home = |rel: &str| self.home.join(rel).to_string_lossy().into_owned();
[
"/opt/homebrew/bin".to_string(),
"/opt/homebrew/sbin".to_string(),
"/usr/local/bin".to_string(),
home(".local/bin"),
home(".cargo/bin"),
"/usr/bin".to_string(),
"/bin".to_string(),
"/usr/sbin".to_string(),
"/sbin".to_string(),
]
.join(":")
}
pub fn to_plist(&self) -> String {
let path = |p: &Path| p.to_string_lossy().into_owned();
let log = path(&self.log_path());
let mut out = String::from(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \
\"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n\
<plist version=\"1.0\">\n<dict>\n",
);
key_string(&mut out, 1, "Label", LABEL);
key(&mut out, 1, "ProgramArguments");
line(&mut out, 1, "<array>");
string(&mut out, 2, &path(&self.exe));
string(&mut out, 2, "daemon");
line(&mut out, 1, "</array>");
key(&mut out, 1, "EnvironmentVariables");
line(&mut out, 1, "<dict>");
key_string(&mut out, 2, "PATH", &self.path_var());
key_string(&mut out, 2, "CARTE_CONFIG_DIR", &path(&self.config_dir));
key_string(&mut out, 2, "CARTE_STATE_HOME", &path(&self.state_dir));
line(&mut out, 1, "</dict>");
key(&mut out, 1, "KeepAlive");
line(&mut out, 1, "<true/>");
key(&mut out, 1, "RunAtLoad");
line(&mut out, 1, "<true/>");
key(&mut out, 1, "ProcessType");
string(&mut out, 1, "Interactive");
key(&mut out, 1, "ExitTimeOut");
line(&mut out, 1, "<integer>40</integer>");
key(&mut out, 1, "SoftResourceLimits");
line(&mut out, 1, "<dict>");
key(&mut out, 2, "NumberOfFiles");
line(&mut out, 2, "<integer>8192</integer>");
line(&mut out, 1, "</dict>");
key_string(&mut out, 1, "StandardOutPath", &log);
key_string(&mut out, 1, "StandardErrorPath", &log);
key(&mut out, 1, "Sockets");
line(&mut out, 1, "<dict>");
socket(&mut out, "http", self.http_port);
socket(&mut out, "https", self.https_port);
line(&mut out, 1, "</dict>");
out.push_str("</dict>\n</plist>\n");
out
}
}
fn socket(out: &mut String, name: &str, port: u16) {
key(out, 2, name);
line(out, 2, "<array>");
for (node, family) in [("127.0.0.1", "IPv4"), ("::1", "IPv6")] {
line(out, 3, "<dict>");
key_string(out, 4, "SockNodeName", node);
key_string(out, 4, "SockServiceName", &port.to_string());
key_string(out, 4, "SockType", "stream");
key_string(out, 4, "SockFamily", family);
line(out, 3, "</dict>");
}
line(out, 2, "</array>");
}
fn line(out: &mut String, depth: usize, text: &str) {
let _ = writeln!(out, "{}{text}", " ".repeat(depth));
}
fn key(out: &mut String, depth: usize, name: &str) {
line(out, depth, &format!("<key>{}</key>", escape(name)));
}
fn string(out: &mut String, depth: usize, value: &str) {
line(out, depth, &format!("<string>{}</string>", escape(value)));
}
fn key_string(out: &mut String, depth: usize, name: &str, value: &str) {
key(out, depth, name);
string(out, depth, value);
}
pub fn runs_with_state(plist: &str, state_dir: &Path) -> bool {
let value = format!(
"<key>CARTE_STATE_HOME</key>\n <string>{}</string>",
escape(&state_dir.to_string_lossy())
);
plist.contains(&value)
}
fn escape(text: &str) -> String {
text.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tells_which_state_directory_the_agent_serves() {
let plist = agent().to_plist();
assert!(runs_with_state(&plist, &agent().state_dir));
assert!(!runs_with_state(&plist, Path::new("/tmp/trial/state")));
}
fn agent() -> Agent {
Agent {
exe: "/opt/homebrew/bin/carte".into(),
home: "/Users/me".into(),
config_dir: "/Users/me/.config/carte".into(),
state_dir: "/Users/me/.local/state/R&D <carte>".into(),
http_port: 80,
https_port: 443,
}
}
#[test]
fn runs_the_daemon_with_launchd_sockets() {
let plist = agent().to_plist();
for expected in [
" <key>Label</key>\n <string>no.elektronaut.carte</string>\n",
" <array>\n <string>/opt/homebrew/bin/carte</string>\n <string>daemon</string>\n </array>\n",
"<key>KeepAlive</key>\n <true/>",
"<key>RunAtLoad</key>\n <true/>",
"<key>ExitTimeOut</key>\n <integer>40</integer>",
"<string>/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/Users/me/.local/bin:/Users/me/.cargo/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>",
"<key>CARTE_STATE_HOME</key>\n <string>/Users/me/.local/state/R&D <carte></string>",
"<key>StandardErrorPath</key>\n <string>/Users/me/.local/state/R&D <carte>/carte.log</string>",
" <key>http</key>\n <array>\n <dict>\n <key>SockNodeName</key>\n \
<string>127.0.0.1</string>\n <key>SockServiceName</key>\n <string>80</string>\n \
<key>SockType</key>\n <string>stream</string>\n <key>SockFamily</key>\n \
<string>IPv4</string>\n </dict>\n <dict>\n <key>SockNodeName</key>\n \
<string>::1</string>\n <key>SockServiceName</key>\n <string>80</string>\n \
<key>SockType</key>\n <string>stream</string>\n <key>SockFamily</key>\n \
<string>IPv6</string>\n </dict>\n </array>\n",
"<key>https</key>\n <array>\n <dict>\n <key>SockNodeName</key>\n \
<string>127.0.0.1</string>\n <key>SockServiceName</key>\n <string>443</string>",
] {
assert!(plist.contains(expected), "missing {expected:?} in\n{plist}");
}
assert!(plist.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist"));
assert!(plist.ends_with("</dict>\n</plist>\n"));
}
#[test]
#[cfg(target_os = "macos")]
fn is_a_valid_property_list() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("no.elektronaut.carte.plist");
std::fs::write(&path, agent().to_plist()).unwrap();
let output = std::process::Command::new("plutil")
.arg("-lint")
.arg(&path)
.output()
.unwrap();
assert!(output.status.success(), "{output:?}");
}
}