fix(service): unescape plist XML, surface both launchctl streams, clear status fields when not installed
Three review findings on task 4: - read_plist_fields now parses the plist properly via the plist crate (already a transitive dep of service-manager, promoted to direct) instead of slicing raw XML, so a PATH or program path containing & or < no longer round-trips as literal &/< through cli::service::render_status. - launchctl's error path now includes both stdout and stderr, trimmed and joined only on non-empty parts, so a failure never surfaces as "launchctl load failed: " with nothing after the colon. - status() now returns identical None fields (program, path_env, snapshotted, pid) whether the plist is absent or the plist exists but the crate reports NotInstalled (e.g. written but never loaded, or booted out of band) - extracted via a shared not_installed_status/build_status split so both paths run the same code. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EGntTHCW3sEPy1VBRopNNp
This commit is contained in:
Generated
+1
@@ -1348,6 +1348,7 @@ dependencies = [
|
|||||||
"etcetera",
|
"etcetera",
|
||||||
"humantime",
|
"humantime",
|
||||||
"nix",
|
"nix",
|
||||||
|
"plist",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"service-manager",
|
"service-manager",
|
||||||
|
|||||||
@@ -34,3 +34,4 @@ async-trait = "0.1"
|
|||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
tokio-test = "0.4"
|
tokio-test = "0.4"
|
||||||
service-manager = "0.11"
|
service-manager = "0.11"
|
||||||
|
plist = "1"
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ etcetera.workspace = true
|
|||||||
nix.workspace = true
|
nix.workspace = true
|
||||||
humantime.workspace = true
|
humantime.workspace = true
|
||||||
service-manager = { workspace = true }
|
service-manager = { workspace = true }
|
||||||
|
plist = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile.workspace = true
|
tempfile.workspace = true
|
||||||
|
|||||||
+158
-37
@@ -139,33 +139,27 @@ pub(crate) fn read_pid(pidfile: &Path) -> Option<u32> {
|
|||||||
.ok()
|
.ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn status(label: &str, pidfile: &Path) -> Result<AgentStatus> {
|
fn not_installed_status(label: &str, plist: PathBuf) -> AgentStatus {
|
||||||
let plist = plist_path_for(label)?;
|
AgentStatus {
|
||||||
|
label: label.to_string(),
|
||||||
|
plist,
|
||||||
|
state: AgentState::NotInstalled,
|
||||||
|
program: None,
|
||||||
|
path_env: None,
|
||||||
|
snapshotted: None,
|
||||||
|
pid: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if !plist.exists() {
|
fn build_status(label: &str, plist: PathBuf, pidfile: &Path, state: AgentState) -> AgentStatus {
|
||||||
return Ok(AgentStatus {
|
if state == AgentState::NotInstalled {
|
||||||
label: label.to_string(),
|
return not_installed_status(label, plist);
|
||||||
plist,
|
|
||||||
state: AgentState::NotInstalled,
|
|
||||||
program: None,
|
|
||||||
path_env: None,
|
|
||||||
snapshotted: None,
|
|
||||||
pid: None,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let snapshotted = std::fs::metadata(&plist)
|
let snapshotted = std::fs::metadata(&plist)
|
||||||
.and_then(|meta| meta.modified())
|
.and_then(|meta| meta.modified())
|
||||||
.ok();
|
.ok();
|
||||||
|
|
||||||
let parsed: ServiceLabel = label.parse().context("parse service label")?;
|
|
||||||
|
|
||||||
let state = match manager().status(ServiceStatusCtx { label: parsed })? {
|
|
||||||
ServiceStatus::Running => AgentState::Running,
|
|
||||||
ServiceStatus::Stopped(_) => AgentState::Stopped,
|
|
||||||
ServiceStatus::NotInstalled => AgentState::NotInstalled,
|
|
||||||
};
|
|
||||||
|
|
||||||
let pid = if state == AgentState::Running {
|
let pid = if state == AgentState::Running {
|
||||||
read_pid(pidfile)
|
read_pid(pidfile)
|
||||||
} else {
|
} else {
|
||||||
@@ -174,7 +168,7 @@ pub(crate) fn status(label: &str, pidfile: &Path) -> Result<AgentStatus> {
|
|||||||
|
|
||||||
let (program, path_env) = read_plist_fields(&plist);
|
let (program, path_env) = read_plist_fields(&plist);
|
||||||
|
|
||||||
Ok(AgentStatus {
|
AgentStatus {
|
||||||
label: label.to_string(),
|
label: label.to_string(),
|
||||||
plist,
|
plist,
|
||||||
state,
|
state,
|
||||||
@@ -182,26 +176,46 @@ pub(crate) fn status(label: &str, pidfile: &Path) -> Result<AgentStatus> {
|
|||||||
path_env,
|
path_env,
|
||||||
snapshotted,
|
snapshotted,
|
||||||
pid,
|
pid,
|
||||||
})
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn status(label: &str, pidfile: &Path) -> Result<AgentStatus> {
|
||||||
|
let plist = plist_path_for(label)?;
|
||||||
|
|
||||||
|
if !plist.exists() {
|
||||||
|
return Ok(not_installed_status(label, plist));
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: ServiceLabel = label.parse().context("parse service label")?;
|
||||||
|
|
||||||
|
let state = match manager().status(ServiceStatusCtx { label: parsed })? {
|
||||||
|
ServiceStatus::Running => AgentState::Running,
|
||||||
|
ServiceStatus::Stopped(_) => AgentState::Stopped,
|
||||||
|
ServiceStatus::NotInstalled => AgentState::NotInstalled,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(build_status(label, plist, pidfile, state))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_plist_fields(plist: &Path) -> (Option<PathBuf>, Option<String>) {
|
fn read_plist_fields(plist: &Path) -> (Option<PathBuf>, Option<String>) {
|
||||||
let Ok(contents) = std::fs::read_to_string(plist) else {
|
let Ok(value) = plist::Value::from_file(plist) else {
|
||||||
return (None, None);
|
return (None, None);
|
||||||
};
|
};
|
||||||
|
|
||||||
let program = contents
|
let dict = value.as_dictionary();
|
||||||
.split("<key>ProgramArguments</key>")
|
|
||||||
.nth(1)
|
let program = dict
|
||||||
.and_then(|rest| rest.split("<string>").nth(1))
|
.and_then(|dict| dict.get("ProgramArguments"))
|
||||||
.and_then(|rest| rest.split("</string>").next())
|
.and_then(plist::Value::as_array)
|
||||||
|
.and_then(|args| args.first())
|
||||||
|
.and_then(plist::Value::as_string)
|
||||||
.map(PathBuf::from);
|
.map(PathBuf::from);
|
||||||
|
|
||||||
let path_env = contents
|
let path_env = dict
|
||||||
.split("<key>PATH</key>")
|
.and_then(|dict| dict.get("EnvironmentVariables"))
|
||||||
.nth(1)
|
.and_then(plist::Value::as_dictionary)
|
||||||
.and_then(|rest| rest.split("<string>").nth(1))
|
.and_then(|env| env.get("PATH"))
|
||||||
.and_then(|rest| rest.split("</string>").next())
|
.and_then(plist::Value::as_string)
|
||||||
.map(str::to_string);
|
.map(str::to_string);
|
||||||
|
|
||||||
(program, path_env)
|
(program, path_env)
|
||||||
@@ -215,15 +229,24 @@ fn launchctl(verb: &str, plist: &Path) -> Result<()> {
|
|||||||
.with_context(|| format!("run launchctl {verb}"))?;
|
.with_context(|| format!("run launchctl {verb}"))?;
|
||||||
|
|
||||||
if !output.status.success() {
|
if !output.status.success() {
|
||||||
anyhow::bail!(
|
return Err(launchctl_error(verb, &output.stdout, &output.stderr));
|
||||||
"launchctl {verb} failed: {}",
|
|
||||||
String::from_utf8_lossy(&output.stderr).trim()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn launchctl_error(verb: &str, stdout: &[u8], stderr: &[u8]) -> anyhow::Error {
|
||||||
|
let stdout = String::from_utf8_lossy(stdout);
|
||||||
|
let stderr = String::from_utf8_lossy(stderr);
|
||||||
|
|
||||||
|
let detail: Vec<&str> = [stdout.trim(), stderr.trim()]
|
||||||
|
.into_iter()
|
||||||
|
.filter(|part| !part.is_empty())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
anyhow::anyhow!("launchctl {verb} failed: {}", detail.join("; "))
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn start(label: &str) -> Result<()> {
|
pub(crate) fn start(label: &str) -> Result<()> {
|
||||||
launchctl("load", &plist_path_for(label)?)
|
launchctl("load", &plist_path_for(label)?)
|
||||||
}
|
}
|
||||||
@@ -350,4 +373,102 @@ mod tests {
|
|||||||
assert_eq!(read_pid(&missing), None);
|
assert_eq!(read_pid(&missing), None);
|
||||||
assert_eq!(read_pid(&garbage), None);
|
assert_eq!(read_pid(&garbage), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn write_agent_plist(path: &Path, program: &str, path_env: Option<&str>) {
|
||||||
|
let mut dict = plist::Dictionary::new();
|
||||||
|
|
||||||
|
dict.insert(
|
||||||
|
"ProgramArguments".to_string(),
|
||||||
|
plist::Value::Array(vec![plist::Value::String(program.to_string())]),
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Some(path_env) = path_env {
|
||||||
|
let mut env = plist::Dictionary::new();
|
||||||
|
env.insert(
|
||||||
|
"PATH".to_string(),
|
||||||
|
plist::Value::String(path_env.to_string()),
|
||||||
|
);
|
||||||
|
|
||||||
|
dict.insert(
|
||||||
|
"EnvironmentVariables".to_string(),
|
||||||
|
plist::Value::Dictionary(env),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
plist::Value::Dictionary(dict).to_file_xml(path).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_plist_fields_unescapes_xml_entities() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let plist_path = tmp.path().join("agent.plist");
|
||||||
|
|
||||||
|
write_agent_plist(
|
||||||
|
&plist_path,
|
||||||
|
"/usr/local/bin/xy",
|
||||||
|
Some("/usr/bin:/opt/a&b:/opt/<c>"),
|
||||||
|
);
|
||||||
|
|
||||||
|
let (program, path_env) = read_plist_fields(&plist_path);
|
||||||
|
|
||||||
|
assert_eq!(program, Some(PathBuf::from("/usr/local/bin/xy")));
|
||||||
|
assert_eq!(path_env, Some("/usr/bin:/opt/a&b:/opt/<c>".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_plist_fields_on_malformed_plist_returns_none_without_panicking() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let plist_path = tmp.path().join("broken.plist");
|
||||||
|
|
||||||
|
std::fs::write(&plist_path, "<plist><dict><key>ProgramArgum").unwrap();
|
||||||
|
|
||||||
|
let (program, path_env) = read_plist_fields(&plist_path);
|
||||||
|
|
||||||
|
assert!(program.is_none());
|
||||||
|
assert!(path_env.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn launchctl_error_includes_both_streams_when_populated() {
|
||||||
|
let err = launchctl_error(
|
||||||
|
"unload",
|
||||||
|
b"out text",
|
||||||
|
b"Unload failed: 5: Input/output error",
|
||||||
|
);
|
||||||
|
|
||||||
|
let message = err.to_string();
|
||||||
|
|
||||||
|
assert!(message.contains("out text"));
|
||||||
|
assert!(message.contains("Unload failed: 5: Input/output error"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn launchctl_error_omits_dangling_separator_when_stdout_empty() {
|
||||||
|
let err = launchctl_error("load", b"", b"boom");
|
||||||
|
|
||||||
|
assert_eq!(err.to_string(), "launchctl load failed: boom");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn not_installed_state_clears_all_optional_fields_even_when_plist_exists() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let plist_path = tmp.path().join("agent.plist");
|
||||||
|
|
||||||
|
write_agent_plist(&plist_path, "/usr/local/bin/xy", Some("/usr/bin"));
|
||||||
|
|
||||||
|
let pidfile = tmp.path().join("xy.pid");
|
||||||
|
std::fs::write(&pidfile, "4821").unwrap();
|
||||||
|
|
||||||
|
let status = build_status(
|
||||||
|
"se.aceofba.xy-ghost",
|
||||||
|
plist_path,
|
||||||
|
&pidfile,
|
||||||
|
AgentState::NotInstalled,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(status.program.is_none());
|
||||||
|
assert!(status.path_env.is_none());
|
||||||
|
assert!(status.snapshotted.is_none());
|
||||||
|
assert!(status.pid.is_none());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user