use std::path::PathBuf;
use crate::{config::DaemonConfig, lifecycle::units::InstallOptions};
pub const PLIST_LABEL: &str = "ai.verivus.sqry.sqryd";
pub const INSTALL_PATH: &str = "~/Library/LaunchAgents/ai.verivus.sqry.sqryd.plist";
#[must_use]
pub fn generate_plist(cfg: &DaemonConfig, opts: &InstallOptions) -> String {
let _ = cfg;
let exe_raw = resolve_exe(opts);
let exe = xml_escape(&exe_raw);
let version = env!("CARGO_PKG_VERSION");
let home = opts.home_dir.clone().or_else(resolve_home);
let log_out_path = xml_escape(
&home
.as_ref()
.map(|h| {
h.join("Library")
.join("Logs")
.join("sqry")
.join("sqryd.log")
.to_string_lossy()
.into_owned()
})
.unwrap_or_else(|| "SQRYD_ERR_NO_HOME_DIR/Library/Logs/sqry/sqryd.log".to_owned()),
);
let log_err_path = xml_escape(
&home
.as_ref()
.map(|h| {
h.join("Library")
.join("Logs")
.join("sqry")
.join("sqryd.err.log")
.to_string_lossy()
.into_owned()
})
.unwrap_or_else(|| "SQRYD_ERR_NO_HOME_DIR/Library/Logs/sqry/sqryd.err.log".to_owned()),
);
let working_dir = xml_escape(
&home
.as_ref()
.map(|h| {
h.join("Library")
.join("Application Support")
.join("sqry")
.to_string_lossy()
.into_owned()
})
.unwrap_or_else(|| "SQRYD_ERR_NO_HOME_DIR/Library/Application Support/sqry".to_owned()),
);
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">
<!-- sqryd version {version} -->
<!-- Install path: {install_path} -->
<!-- Load: launchctl load {install_path} -->
<!-- Unload: launchctl unload {install_path} -->
<plist version="1.0">
<dict>
<key>Label</key><string>{label}</string>
<key>ProgramArguments</key>
<array>
<string>{exe}</string>
<string>foreground</string>
</array>
<key>KeepAlive</key><true/>
<key>RunAtLoad</key><true/>
<key>StandardOutPath</key><string>{log_out}</string>
<key>StandardErrorPath</key><string>{log_err}</string>
<key>WorkingDirectory</key><string>{working_dir}</string>
<key>EnvironmentVariables</key>
<dict>
<key>RUST_BACKTRACE</key><string>1</string>
</dict>
</dict>
</plist>
"#,
version = version,
install_path = INSTALL_PATH,
label = PLIST_LABEL,
exe = exe,
log_out = log_out_path,
log_err = log_err_path,
working_dir = working_dir,
)
}
fn xml_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 8);
for ch in s.chars() {
match ch {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
other => out.push(other),
}
}
out
}
fn resolve_home() -> Option<std::path::PathBuf> {
dirs::home_dir().or_else(|| std::env::var_os("HOME").map(std::path::PathBuf::from))
}
fn resolve_exe(opts: &InstallOptions) -> String {
if let Some(path) = &opts.exe_path {
return path.to_string_lossy().into_owned();
}
std::env::current_exe()
.ok()
.and_then(|p| p.canonicalize().ok().or(Some(p)))
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|| "sqryd".to_owned())
}
#[must_use]
pub fn default_install_path() -> Option<PathBuf> {
resolve_home().map(|home| {
home.join("Library")
.join("LaunchAgents")
.join("ai.verivus.sqry.sqryd.plist")
})
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::*;
use crate::{config::DaemonConfig, lifecycle::units::InstallOptions};
fn stable_opts() -> InstallOptions {
InstallOptions {
exe_path: Some(PathBuf::from("/usr/local/bin/sqryd")),
user: None,
home_dir: None,
}
}
#[test]
fn launchd_plist_contains_label_and_keepalive() {
let cfg = DaemonConfig::default();
let plist = generate_plist(&cfg, &stable_opts());
assert!(
plist.contains("<key>Label</key><string>ai.verivus.sqry.sqryd</string>"),
"plist must contain the correct Label value; got:\n{plist}"
);
assert!(
plist.contains("<key>KeepAlive</key><true/>"),
"plist must set KeepAlive=true; got:\n{plist}"
);
assert!(
plist.contains("<key>RunAtLoad</key><true/>"),
"plist must set RunAtLoad=true; got:\n{plist}"
);
}
#[test]
fn launchd_plist_contains_program_arguments() {
let cfg = DaemonConfig::default();
let plist = generate_plist(&cfg, &stable_opts());
assert!(
plist.contains("<string>/usr/local/bin/sqryd</string>"),
"plist must embed the resolved binary path; got:\n{plist}"
);
assert!(
plist.contains("<string>foreground</string>"),
"plist must pass 'foreground' as the first argument; got:\n{plist}"
);
}
#[test]
fn launchd_plist_contains_standard_out_and_err_paths() {
let cfg = DaemonConfig::default();
let plist = generate_plist(&cfg, &stable_opts());
assert!(
plist.contains("<key>StandardOutPath</key>"),
"plist must have StandardOutPath key; got:\n{plist}"
);
assert!(
plist.contains("Library/Logs/sqry/sqryd.log"),
"StandardOutPath must point into Library/Logs/sqry/; got:\n{plist}"
);
assert!(
!plist.contains("~/Library/Logs/sqry/sqryd.log"),
"StandardOutPath must be an absolute path, not tilde-prefixed; got:\n{plist}"
);
assert!(
plist.contains("<key>StandardErrorPath</key>"),
"plist must have StandardErrorPath key; got:\n{plist}"
);
assert!(
plist.contains("Library/Logs/sqry/sqryd.err.log"),
"StandardErrorPath must point into Library/Logs/sqry/; got:\n{plist}"
);
assert!(
!plist.contains("~/Library/Logs/sqry/sqryd.err.log"),
"StandardErrorPath must be an absolute path, not tilde-prefixed; got:\n{plist}"
);
}
#[test]
fn launchd_plist_contains_working_directory() {
let cfg = DaemonConfig::default();
let plist = generate_plist(&cfg, &stable_opts());
assert!(
plist.contains("<key>WorkingDirectory</key>"),
"plist must have WorkingDirectory key; got:\n{plist}"
);
assert!(
plist.contains("Library/Application Support/sqry"),
"WorkingDirectory must point into Library/Application Support/sqry; got:\n{plist}"
);
assert!(
!plist.contains("~/Library/Application Support/sqry"),
"WorkingDirectory must be an absolute path, not tilde-prefixed; got:\n{plist}"
);
}
#[test]
fn launchd_plist_runtime_paths_are_absolute_when_home_available() {
if resolve_home().is_none() {
return;
}
let cfg = DaemonConfig::default();
let plist = generate_plist(&cfg, &stable_opts());
let log_out = plist
.split("<key>StandardOutPath</key><string>")
.nth(1)
.and_then(|s| s.split("</string>").next())
.expect("plist must contain StandardOutPath");
assert!(
log_out.starts_with('/'),
"StandardOutPath must be an absolute path starting with '/'; got: {log_out:?}"
);
let log_err = plist
.split("<key>StandardErrorPath</key><string>")
.nth(1)
.and_then(|s| s.split("</string>").next())
.expect("plist must contain StandardErrorPath");
assert!(
log_err.starts_with('/'),
"StandardErrorPath must be an absolute path starting with '/'; got: {log_err:?}"
);
let working = plist
.split("<key>WorkingDirectory</key><string>")
.nth(1)
.and_then(|s| s.split("</string>").next())
.expect("plist must contain WorkingDirectory");
assert!(
working.starts_with('/'),
"WorkingDirectory must be an absolute path starting with '/'; got: {working:?}"
);
}
#[test]
fn launchd_plist_contains_rust_backtrace_env() {
let cfg = DaemonConfig::default();
let plist = generate_plist(&cfg, &stable_opts());
assert!(
plist.contains("<key>EnvironmentVariables</key>"),
"plist must have EnvironmentVariables key; got:\n{plist}"
);
assert!(
plist.contains("<key>RUST_BACKTRACE</key><string>1</string>"),
"EnvironmentVariables must include RUST_BACKTRACE=1; got:\n{plist}"
);
}
#[test]
fn launchd_plist_contains_version_stamp() {
let cfg = DaemonConfig::default();
let plist = generate_plist(&cfg, &stable_opts());
let version = env!("CARGO_PKG_VERSION");
assert!(
plist.contains(&format!("<!-- sqryd version {version} -->")),
"plist must include version stamp comment; got:\n{plist}"
);
}
#[test]
fn launchd_plist_is_well_formed_xml() {
let cfg = DaemonConfig::default();
let plist = generate_plist(&cfg, &stable_opts());
assert!(
plist.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"),
"plist must begin with the XML declaration; got start: {:?}",
&plist[..plist.len().min(60)]
);
assert!(
plist.contains("<plist version=\"1.0\">"),
"plist must contain the root <plist> element; got:\n{plist}"
);
assert!(
plist.contains("</plist>"),
"plist root element must be closed; got:\n{plist}"
);
assert!(
plist.contains("<dict>"),
"plist must have a root <dict>; got:\n{plist}"
);
assert!(
plist.contains("</dict>"),
"plist root <dict> must be closed; got:\n{plist}"
);
}
#[test]
fn launchd_plist_snapshot() {
let cfg = DaemonConfig::default();
let plist = generate_plist(&cfg, &stable_opts());
let version = env!("CARGO_PKG_VERSION");
let home = resolve_home();
let log_out = home
.as_ref()
.map(|h| {
h.join("Library")
.join("Logs")
.join("sqry")
.join("sqryd.log")
.to_string_lossy()
.into_owned()
})
.unwrap_or_else(|| "SQRYD_ERR_NO_HOME_DIR/Library/Logs/sqry/sqryd.log".to_owned());
let log_err = home
.as_ref()
.map(|h| {
h.join("Library")
.join("Logs")
.join("sqry")
.join("sqryd.err.log")
.to_string_lossy()
.into_owned()
})
.unwrap_or_else(|| "SQRYD_ERR_NO_HOME_DIR/Library/Logs/sqry/sqryd.err.log".to_owned());
let working = home
.as_ref()
.map(|h| {
h.join("Library")
.join("Application Support")
.join("sqry")
.to_string_lossy()
.into_owned()
})
.unwrap_or_else(|| "SQRYD_ERR_NO_HOME_DIR/Library/Application Support/sqry".to_owned());
let expected = format!(
concat!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n",
"<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\"\n",
" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n",
"<!-- sqryd version {version} -->\n",
"<!-- Install path: ~/Library/LaunchAgents/ai.verivus.sqry.sqryd.plist -->\n",
"<!-- Load: launchctl load ~/Library/LaunchAgents/ai.verivus.sqry.sqryd.plist -->\n",
"<!-- Unload: launchctl unload ~/Library/LaunchAgents/ai.verivus.sqry.sqryd.plist -->\n",
"<plist version=\"1.0\">\n",
"<dict>\n",
" <key>Label</key><string>ai.verivus.sqry.sqryd</string>\n",
" <key>ProgramArguments</key>\n",
" <array>\n",
" <string>/usr/local/bin/sqryd</string>\n",
" <string>foreground</string>\n",
" </array>\n",
" <key>KeepAlive</key><true/>\n",
" <key>RunAtLoad</key><true/>\n",
" <key>StandardOutPath</key><string>{log_out}</string>\n",
" <key>StandardErrorPath</key><string>{log_err}</string>\n",
" <key>WorkingDirectory</key><string>{working}</string>\n",
" <key>EnvironmentVariables</key>\n",
" <dict>\n",
" <key>RUST_BACKTRACE</key><string>1</string>\n",
" </dict>\n",
"</dict>\n",
"</plist>\n",
),
version = version,
log_out = log_out,
log_err = log_err,
working = working,
);
assert_eq!(
plist, expected,
"launchd plist snapshot mismatch.\n\nActual:\n{plist}\n\nExpected:\n{expected}"
);
}
#[test]
fn default_install_path_ends_with_expected_filename() {
if let Some(path) = default_install_path() {
assert!(
path.ends_with("Library/LaunchAgents/ai.verivus.sqry.sqryd.plist"),
"default_install_path must end with the standard LaunchAgents path; got: {path:?}"
);
}
}
#[test]
fn launchd_plist_with_current_exe_fallback_does_not_panic() {
let cfg = DaemonConfig::default();
let opts = InstallOptions {
exe_path: None,
user: None,
home_dir: None,
};
let plist = generate_plist(&cfg, &opts);
assert!(
plist.contains("<key>ProgramArguments</key>"),
"plist with fallback exe must still have ProgramArguments; got:\n{plist}"
);
}
#[test]
fn launchd_plist_xml_escapes_special_chars_in_exe_path() {
let cfg = DaemonConfig::default();
let opts = InstallOptions {
exe_path: Some(PathBuf::from("/opt/sqry&tools/<foo>/sqryd\"bar'baz>")),
user: None,
home_dir: None,
};
let plist = generate_plist(&cfg, &opts);
assert!(
!plist.contains("/opt/sqry&tools/"),
"raw '&' must be escaped to '&'; got:\n{plist}"
);
assert!(
!plist.contains("<foo>"),
"raw '<' must be escaped to '<'; got:\n{plist}"
);
assert!(
plist.contains("&"),
"plist must contain '&' for the escaped ampersand; got:\n{plist}"
);
assert!(
plist.contains("<"),
"plist must contain '<' for the escaped '<'; got:\n{plist}"
);
assert!(
plist.contains(">"),
"plist must contain '>' for the escaped '>'; got:\n{plist}"
);
assert!(
plist.contains("""),
"plist must contain '"' for the escaped '\"'; got:\n{plist}"
);
assert!(
plist.contains("'"),
"plist must contain ''' for the escaped \"'\"; got:\n{plist}"
);
assert_eq!(
xml_escape("a&b<c>d\"e'f"),
"a&b<c>d"e'f"
);
assert_eq!(xml_escape("plain/path/no/special"), "plain/path/no/special");
assert_eq!(xml_escape(""), "");
}
#[test]
fn launchd_plist_xml_escapes_runtime_paths() {
let cfg = DaemonConfig::default();
let fake_home = PathBuf::from("/Users/Tom & <Jerry>");
let opts = InstallOptions {
exe_path: Some(PathBuf::from("/usr/local/bin/sqryd")),
home_dir: Some(fake_home.clone()),
..InstallOptions::default()
};
let plist = generate_plist(&cfg, &opts);
assert!(
!plist.contains("/Users/Tom & <Jerry>"),
"raw '&' and '<' must be escaped in runtime paths; got:\n{plist}"
);
assert!(
plist.contains("Tom & <Jerry>"),
"runtime paths must contain entity-escaped home components; got:\n{plist}"
);
let log_out = plist
.split("<key>StandardOutPath</key><string>")
.nth(1)
.and_then(|s| s.split("</string>").next())
.expect("plist must contain StandardOutPath");
assert!(
log_out.contains("&"),
"StandardOutPath must escape '&' from home path; got: {log_out:?}"
);
assert!(
log_out.contains("<"),
"StandardOutPath must escape '<' from home path; got: {log_out:?}"
);
assert!(
!log_out.contains(" & "),
"StandardOutPath must not contain raw '&'; got: {log_out:?}"
);
let log_err = plist
.split("<key>StandardErrorPath</key><string>")
.nth(1)
.and_then(|s| s.split("</string>").next())
.expect("plist must contain StandardErrorPath");
assert!(
log_err.contains("&"),
"StandardErrorPath must escape '&' from home path; got: {log_err:?}"
);
assert!(
log_err.contains("<"),
"StandardErrorPath must escape '<' from home path; got: {log_err:?}"
);
let working = plist
.split("<key>WorkingDirectory</key><string>")
.nth(1)
.and_then(|s| s.split("</string>").next())
.expect("plist must contain WorkingDirectory");
assert!(
working.contains("&"),
"WorkingDirectory must escape '&' from home path; got: {working:?}"
);
assert!(
working.contains("<"),
"WorkingDirectory must escape '<' from home path; got: {working:?}"
);
}
}