pub(crate) const DAEMON_LAUNCHD_LABEL: &str = trusty_common::launchd_labels::MPM;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum DaemonLabelProbe {
Registered(String),
NotRegistered,
Undeterminable,
}
impl DaemonLabelProbe {
pub(crate) fn label(&self) -> Option<&str> {
match self {
Self::Registered(l) => Some(l),
Self::NotRegistered | Self::Undeterminable => None,
}
}
pub(crate) fn may_own_daemon(&self) -> bool {
!matches!(self, Self::NotRegistered)
}
}
pub(crate) fn daemon_launchd_label_in(home: &std::path::Path) -> DaemonLabelProbe {
let agents = home.join("Library/LaunchAgents");
if agents
.join(format!("{DAEMON_LAUNCHD_LABEL}.plist"))
.exists()
{
return DaemonLabelProbe::Registered(DAEMON_LAUNCHD_LABEL.to_string());
}
let entries = match std::fs::read_dir(&agents) {
Ok(entries) => entries,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return DaemonLabelProbe::NotRegistered;
}
Err(_) => return DaemonLabelProbe::Undeterminable,
};
let mut names = Vec::new();
for entry in entries {
match entry {
Ok(e) => names.push(e.file_name()),
Err(_) => return DaemonLabelProbe::Undeterminable,
}
}
let mut drifted: Vec<String> = names
.into_iter()
.filter_map(|name| name.into_string().ok())
.filter_map(|name| name.strip_suffix(".plist").map(str::to_owned))
.filter(|label| {
label.starts_with("com.trusty.")
&& label.contains("mpm")
&& !label.contains("supervisor")
&& !label.contains("gui")
})
.filter(|label| plist_runs_daemon(&agents.join(format!("{label}.plist"))))
.collect();
drifted.sort();
match drifted.into_iter().next() {
Some(label) => DaemonLabelProbe::Registered(label),
None => DaemonLabelProbe::NotRegistered,
}
}
fn plist_runs_daemon(path: &std::path::Path) -> bool {
let Ok(xml) = std::fs::read_to_string(path) else {
return false;
};
let Some(after_key) = xml.split("<key>ProgramArguments</key>").nth(1) else {
return false;
};
let array = after_key.split("</array>").next().unwrap_or_default();
array
.split("<string>")
.skip(1)
.filter_map(|s| s.split("</string>").next())
.any(|arg| arg.trim() == "daemon")
}
pub(crate) fn daemon_launchd_label() -> DaemonLabelProbe {
match dirs::home_dir() {
Some(home) => daemon_launchd_label_in(&home),
None => DaemonLabelProbe::NotRegistered,
}
}
pub(crate) fn launchd_may_own_daemon() -> bool {
daemon_launchd_label().may_own_daemon()
}
pub(crate) fn daemon_restart_command_for(label: Option<&str>) -> String {
match label {
Some(l) => format!("launchctl kickstart -k gui/$(id -u)/{l}"),
None => "tm restart".to_string(),
}
}
pub(crate) fn daemon_restart_command() -> String {
daemon_restart_command_for(daemon_launchd_label().label())
}
pub(crate) fn compute_supervised(is_launchd_supervised: bool, plist_exists: bool) -> bool {
!plist_exists || is_launchd_supervised
}
pub(crate) fn compute_no_spawn(plist_exists: bool) -> bool {
plist_exists
}
pub(crate) fn cli_spawn_refusal_hint(label: &str) -> String {
format!(
"refusing to spawn: the launchd unit `{label}` is registered, so launchd owns \
the daemon's lifecycle — spawning one here would create a duplicate, \
unsupervised daemon that can seize the port and serve a stale binary \
indefinitely (issue #2486/#4230). Restart it with `{}`, then re-run \
`tm status`. To start an unsupervised daemon on purpose, run \
`tm daemon --force`.",
daemon_restart_command_for(Some(label))
)
}
pub(crate) fn undeterminable_spawn_refusal_hint() -> String {
"refusing to spawn: `~/Library/LaunchAgents` could not be read, so whether launchd \
owns this daemon is unknown — spawning on that reading is how the unsupervised \
orphan of issue #2486/#4230 gets created, and `launchctl bootout` cannot remove it. \
Check the directory with `ls -ld ~/Library/LaunchAgents` and re-run. To start an \
unsupervised daemon on purpose, run `tm daemon --force`."
.to_string()
}
pub(crate) fn cli_spawn_refusal_for(probe: &DaemonLabelProbe) -> Option<String> {
match probe {
DaemonLabelProbe::Registered(label) => Some(cli_spawn_refusal_hint(label)),
DaemonLabelProbe::Undeterminable => Some(undeterminable_spawn_refusal_hint()),
DaemonLabelProbe::NotRegistered => None,
}
}
pub(crate) fn cli_spawn_refusal() -> Option<String> {
cli_spawn_refusal_for(&daemon_launchd_label())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum LaunchdOwnership {
Running(u32),
NotRunning,
Unavailable,
}
pub(crate) fn launchd_ownership(label: &str) -> LaunchdOwnership {
if let Some(state) = ask_launchctl(&["list".to_string(), label.to_string()]) {
return state;
}
let uid = unsafe { libc::getuid() };
if let Some(state) = ask_launchctl(&["print".to_string(), format!("gui/{uid}/{label}")]) {
return state;
}
LaunchdOwnership::Unavailable
}
fn ask_launchctl(args: &[String]) -> Option<LaunchdOwnership> {
let out = std::process::Command::new("launchctl")
.args(args)
.output()
.ok()?;
if !out.status.success() {
return None;
}
Some(ownership_from_output(&String::from_utf8_lossy(&out.stdout)))
}
fn ownership_from_output(stdout: &str) -> LaunchdOwnership {
match parse_launchctl_pid(stdout) {
Some(pid) => LaunchdOwnership::Running(pid),
None => LaunchdOwnership::NotRunning,
}
}
pub(crate) fn parse_launchctl_pid(output: &str) -> Option<u32> {
for raw in output.lines() {
let line = raw.trim();
if let Some(rest) = line.strip_prefix("\"PID\" = ") {
return rest.trim_end_matches(';').trim().parse().ok();
}
if let Some(rest) = line.strip_prefix("pid = ") {
return rest.trim_end_matches(';').trim().parse().ok();
}
if let Some((first, rest)) = raw.split_once('\t')
&& rest.contains('\t')
&& let Ok(pid) = first.trim().parse::<u32>()
{
return Some(pid);
}
}
None
}
pub(crate) fn compute_daemon_refuse(supervised: bool, force: bool) -> bool {
!supervised && !force
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn compute_supervised_true_when_no_plist() {
assert!(compute_supervised(false, false));
}
#[test]
fn compute_supervised_true_when_launchd_and_no_plist() {
assert!(compute_supervised(true, false));
}
#[test]
fn compute_supervised_true_when_plist_and_launchd() {
assert!(compute_supervised(true, true));
}
#[test]
fn compute_supervised_false_when_plist_without_launchd() {
assert!(!compute_supervised(false, true));
}
#[test]
fn compute_no_spawn_true_when_plist_exists() {
assert!(compute_no_spawn(true));
}
#[test]
fn compute_no_spawn_false_when_no_plist() {
assert!(!compute_no_spawn(false));
}
#[test]
fn cli_spawn_refusal_names_kickstart_and_force() {
let msg = cli_spawn_refusal_hint(DAEMON_LAUNCHD_LABEL);
assert!(msg.contains("launchctl kickstart"), "message was: {msg}");
assert!(msg.contains("com.trusty.mpm"), "message was: {msg}");
assert!(msg.contains("tm daemon --force"), "message was: {msg}");
assert!(msg.contains("#4230"), "message was: {msg}");
}
#[test]
fn cli_spawn_refusal_names_the_resolved_label() {
let msg = cli_spawn_refusal_hint("com.trusty.mpm.dogfood");
assert!(msg.contains("com.trusty.mpm.dogfood"), "message was: {msg}");
assert!(
msg.contains("gui/$(id -u)/com.trusty.mpm.dogfood"),
"message was: {msg}"
);
}
fn plist_running(subcommand: &str) -> String {
format!(
"<plist version=\"1.0\"><dict>\
<key>Label</key><string>x</string>\
<key>ProgramArguments</key><array>\
<string>/Users/x/.cargo/bin/trusty-mpm</string>\
<string>{subcommand}</string>\
</array>\
<key>StandardOutPath</key><string>/Users/x/Logs/daemon.log</string>\
</dict></plist>"
)
}
fn home_with(plists: &[&str]) -> tempfile::TempDir {
home_with_bodies(
&plists
.iter()
.map(|n| (*n, plist_running("daemon")))
.collect::<Vec<_>>(),
)
}
fn home_with_bodies(plists: &[(&str, String)]) -> tempfile::TempDir {
let dir = tempfile::tempdir().expect("tempdir");
let agents = dir.path().join("Library/LaunchAgents");
std::fs::create_dir_all(&agents).expect("create LaunchAgents");
for (name, body) in plists {
std::fs::write(agents.join(name), body).expect("write plist");
}
dir
}
#[test]
fn daemon_label_ignores_a_decoy_sibling_unit() {
let home =
home_with_bodies(&[("com.trusty.mpm.logrotate.plist", plist_running("logrotate"))]);
assert_eq!(
daemon_launchd_label_in(home.path()),
DaemonLabelProbe::NotRegistered,
"a sibling unit that does not run `daemon` must not become the daemon label"
);
}
#[test]
fn daemon_label_picks_the_daemon_unit_over_an_alphabetically_earlier_decoy() {
let home = home_with_bodies(&[
("com.trusty.aaa-mpm.plist", plist_running("logrotate")),
("com.trusty.zzz-mpm.plist", plist_running("daemon")),
]);
assert_eq!(
daemon_launchd_label_in(home.path()).label(),
Some("com.trusty.zzz-mpm")
);
}
#[test]
fn plist_runs_daemon_false_for_a_missing_file() {
assert!(!plist_runs_daemon(std::path::Path::new(
"/nonexistent/com.trusty.mpm.plist"
)));
}
#[test]
fn plist_runs_daemon_ignores_daemon_outside_program_arguments() {
let home =
home_with_bodies(&[("com.trusty.mpm.logrotate.plist", plist_running("logrotate"))]);
let path = home
.path()
.join("Library/LaunchAgents/com.trusty.mpm.logrotate.plist");
assert!(
std::fs::read_to_string(&path)
.expect("read fixture")
.contains("daemon.log"),
"fixture must contain the decoy substring for this test to mean anything"
);
assert!(!plist_runs_daemon(&path));
}
#[test]
fn ownership_from_output_running() {
assert_eq!(
ownership_from_output("{\n\t\"PID\" = 73599;\n};"),
LaunchdOwnership::Running(73599)
);
}
#[test]
fn ownership_from_output_not_running() {
assert_eq!(
ownership_from_output("{\n\t\"LastExitStatus\" = 0;\n};"),
LaunchdOwnership::NotRunning
);
}
#[test]
fn daemon_label_undeterminable_for_an_unreadable_launchagents() {
use std::os::unix::fs::PermissionsExt as _;
let home = home_with(&["com.trusty.mpm.plist"]);
let agents = home.path().join("Library/LaunchAgents");
std::fs::set_permissions(&agents, std::fs::Permissions::from_mode(0o000)).expect("chmod");
let got = daemon_launchd_label_in(home.path());
std::fs::set_permissions(&agents, std::fs::Permissions::from_mode(0o755)).expect("restore");
assert_eq!(got, DaemonLabelProbe::Undeterminable);
assert!(
got.may_own_daemon(),
"an unanswered scan must keep the spawn guard up"
);
}
#[test]
fn daemon_label_not_registered_when_launchagents_is_absent() {
let bare = tempfile::tempdir().expect("tempdir");
assert_eq!(
daemon_launchd_label_in(bare.path()),
DaemonLabelProbe::NotRegistered
);
}
#[test]
fn probe_undeterminable_may_own_the_daemon() {
assert!(DaemonLabelProbe::Undeterminable.may_own_daemon());
assert!(DaemonLabelProbe::Registered("com.trusty.mpm".into()).may_own_daemon());
}
#[test]
fn probe_not_registered_may_not_own_the_daemon() {
assert!(!DaemonLabelProbe::NotRegistered.may_own_daemon());
}
#[test]
fn probe_undeterminable_has_no_label() {
assert_eq!(DaemonLabelProbe::Undeterminable.label(), None);
assert_eq!(DaemonLabelProbe::NotRegistered.label(), None);
assert_eq!(
DaemonLabelProbe::Registered("com.trusty.mpm".into()).label(),
Some("com.trusty.mpm")
);
}
#[test]
fn spawn_refusal_none_when_no_unit_is_registered() {
assert_eq!(
cli_spawn_refusal_for(&DaemonLabelProbe::NotRegistered),
None
);
assert!(cli_spawn_refusal_for(&DaemonLabelProbe::Undeterminable).is_some());
assert!(
cli_spawn_refusal_for(&DaemonLabelProbe::Registered("com.trusty.mpm".into())).is_some()
);
}
#[test]
fn undeterminable_refusal_names_the_directory_and_force() {
let msg = undeterminable_spawn_refusal_hint();
assert!(msg.contains("~/Library/LaunchAgents"), "message was: {msg}");
assert!(msg.contains("tm daemon --force"), "message was: {msg}");
assert!(
!msg.contains("launchctl kickstart"),
"must not prescribe a unit no scan resolved: {msg}"
);
}
#[test]
fn daemon_label_prefers_the_canonical_plist() {
let home = home_with(&["com.trusty.mpm.plist", "com.trusty.mpm.dogfood.plist"]);
assert_eq!(
daemon_launchd_label_in(home.path()).label(),
Some("com.trusty.mpm")
);
}
#[test]
fn daemon_label_ignores_a_supervisor_only_home() {
let home = home_with(&["com.trusty.mpm.supervisor.plist"]);
assert_eq!(
daemon_launchd_label_in(home.path()),
DaemonLabelProbe::NotRegistered
);
assert!(!compute_no_spawn(
daemon_launchd_label_in(home.path()).may_own_daemon()
));
}
#[test]
fn daemon_label_none_for_an_empty_home() {
let home = home_with(&[]);
assert_eq!(
daemon_launchd_label_in(home.path()),
DaemonLabelProbe::NotRegistered
);
let bare = tempfile::tempdir().expect("tempdir");
assert_eq!(
daemon_launchd_label_in(bare.path()),
DaemonLabelProbe::NotRegistered
);
}
#[test]
fn daemon_label_accepts_a_drifted_label() {
let home = home_with(&["com.trusty.dogfood-mpm.plist"]);
assert_eq!(
daemon_launchd_label_in(home.path()).label(),
Some("com.trusty.dogfood-mpm")
);
}
#[test]
fn daemon_label_ignores_a_gui_plist() {
let home = home_with(&["com.trusty.mpm.gui.plist"]);
assert_eq!(
daemon_launchd_label_in(home.path()),
DaemonLabelProbe::NotRegistered
);
}
#[test]
fn restart_command_names_kickstart_when_launchd_owns_the_daemon() {
assert_eq!(
daemon_restart_command_for(Some("com.trusty.mpm")),
"launchctl kickstart -k gui/$(id -u)/com.trusty.mpm"
);
}
#[test]
fn restart_command_names_tm_restart_without_a_unit() {
assert_eq!(daemon_restart_command_for(None), "tm restart");
}
#[test]
fn parse_launchctl_pid_reads_the_list_dict_form() {
let out = "{\n\t\"LimitLoadToSessionType\" = \"Aqua\";\n\t\"Label\" = \
\"com.trusty.mpm\";\n\t\"OnDemand\" = true;\n\t\"LastExitStatus\" = 0;\n\t\
\"PID\" = 73599;\n\t\"Program\" = \"/Users/masa/.cargo/bin/trusty-mpm\";\n};";
assert_eq!(parse_launchctl_pid(out), Some(73599));
}
#[test]
fn parse_launchctl_pid_reads_the_print_form() {
let out = "\tstate = running\n\tpid = 73599\n\t\tstate = active\n";
assert_eq!(parse_launchctl_pid(out), Some(73599));
}
#[test]
fn parse_launchctl_pid_reads_the_table_form() {
assert_eq!(
parse_launchctl_pid("12345\t0\tcom.trusty.mpm\n"),
Some(12345)
);
}
#[test]
fn parse_launchctl_pid_none_when_not_running() {
let out = "{\n\t\"Label\" = \"com.trusty.mpm\";\n\t\"LastExitStatus\" = 0;\n};";
assert_eq!(parse_launchctl_pid(out), None);
}
#[test]
fn parse_launchctl_pid_none_for_the_table_dash() {
assert_eq!(parse_launchctl_pid("-\t0\tcom.trusty.mpm\n"), None);
}
#[test]
fn parse_launchctl_pid_ignores_other_numeric_keys() {
let out = "{\n\t\"LastExitStatus\" = 0;\n\t\"TimeOut\" = 30;\n};";
assert_eq!(parse_launchctl_pid(out), None);
}
#[test]
fn compute_daemon_refuse_true_when_unsupervised_and_not_forced() {
assert!(compute_daemon_refuse(false, false));
}
#[test]
fn compute_daemon_refuse_false_when_supervised() {
assert!(!compute_daemon_refuse(true, false));
assert!(!compute_daemon_refuse(true, true));
}
#[test]
fn compute_daemon_refuse_false_when_forced() {
assert!(!compute_daemon_refuse(false, true));
}
}