use std::path::{Path, PathBuf};
use trusty_common::credential_registry::provider_for_env_var;
use trusty_common::launchd_secrets::{credential_entries, is_binary_plist};
use crate::core::doctor::{CheckStatus, DoctorCheck};
pub(crate) const CHECK_NAME: &str = "launchd_secrets";
const TRUSTY_PLIST_PREFIX: &str = "com.trusty.";
const EXPECTED_MODE: u32 = 0o600;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PlistFinding {
pub path: PathBuf,
pub mode: Option<u32>,
pub migratable: Vec<String>,
pub unmapped: Vec<String>,
pub unreadable: Option<String>,
pub binary_plist: bool,
}
impl PlistFinding {
pub(crate) fn actionable(&self) -> bool {
!self.migratable.is_empty() || !self.unmapped.is_empty() || self.unreadable.is_some()
}
pub(crate) fn keys(&self) -> Vec<String> {
let mut all = self.migratable.clone();
all.extend(self.unmapped.iter().cloned());
all
}
pub(crate) fn mode_text(&self) -> String {
self.mode
.map_or_else(|| "unknown".to_string(), |m| format!("{m:04o}"))
}
fn too_wide(&self) -> bool {
self.mode.is_some_and(|m| m & 0o777 & !EXPECTED_MODE != 0)
}
}
pub fn scan_launch_agents(home: &Path) -> std::io::Result<Vec<PlistFinding>> {
let dir = home.join("Library/LaunchAgents");
let entries = match std::fs::read_dir(&dir) {
Ok(entries) => entries,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(e),
};
let mut out = Vec::new();
for entry in entries {
let path = entry?.path();
let name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
if !name.starts_with(TRUSTY_PLIST_PREFIX) || !name.ends_with(".plist") {
continue;
}
out.push(judge(path));
}
out.sort_by(|a, b| a.path.cmp(&b.path));
Ok(out)
}
fn judge(path: PathBuf) -> PlistFinding {
if let Some(finding) = symlink_finding(&path) {
return finding;
}
let mode = read_mode(&path);
let bytes = match std::fs::read(&path) {
Ok(bytes) => bytes,
Err(e) => {
return unreadable(
path,
mode,
format!("could not read it: {}", e.kind()),
false,
);
}
};
if is_binary_plist(&bytes) {
let why = format!(
"it is a BINARY plist, which this scan cannot read — convert it with \
`plutil -convert xml1 {}` and re-run `tm doctor`",
path.display()
);
return unreadable(path, mode, why, true);
}
let Ok(xml) = String::from_utf8(bytes) else {
return unreadable(path, mode, "it is not valid UTF-8".to_string(), false);
};
finding_for(path, mode, &xml)
}
fn symlink_finding(path: &Path) -> Option<PlistFinding> {
let meta = std::fs::symlink_metadata(path).ok()?;
if !meta.file_type().is_symlink() {
return None;
}
let target = std::fs::read_link(path).map_or_else(
|_| "a target that could not be read".to_string(),
|t| t.display().to_string(),
);
Some(unreadable(
path.to_path_buf(),
None,
format!(
"it is a SYMLINK to {target}, which this scan neither reads through nor rewrites \
— an atomic rewrite would replace the link with a plain file and leave the \
target stale. Judge and repair {target} directly, or replace the link with the \
real plist"
),
false,
))
}
fn unreadable(path: PathBuf, mode: Option<u32>, why: String, binary_plist: bool) -> PlistFinding {
PlistFinding {
path,
mode,
migratable: Vec::new(),
unmapped: Vec::new(),
unreadable: Some(why),
binary_plist,
}
}
fn read_mode(path: &Path) -> Option<u32> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::symlink_metadata(path)
.ok()
.map(|m| m.permissions().mode() & 0o7777)
}
#[cfg(not(unix))]
{
let _ = path;
None
}
}
fn finding_for(path: PathBuf, mode: Option<u32>, xml: &str) -> PlistFinding {
match credential_entries(xml) {
Ok(entries) => {
let (migratable, unmapped) = entries
.into_iter()
.map(|e| e.key)
.partition(|key| provider_for_env_var(key).is_some());
PlistFinding {
path,
mode,
migratable,
unmapped,
unreadable: None,
binary_plist: false,
}
}
Err(e) => unreadable(path, mode, e.reason, false),
}
}
pub(crate) fn check_launchd_plist_secrets(home: &Path) -> DoctorCheck {
match scan_launch_agents(home) {
Ok(findings) => build_row(&findings),
Err(e) => DoctorCheck::new(
CHECK_NAME,
CheckStatus::Unknown,
format!(
"could not list ~/Library/LaunchAgents ({}) — whether a LaunchAgent \
holds a plaintext credential is UNKNOWN",
e.kind()
),
),
}
}
fn build_row(findings: &[PlistFinding]) -> DoctorCheck {
let unreadable: Vec<String> = findings
.iter()
.filter_map(|f| {
f.unreadable
.as_ref()
.map(|why| format!("{} ({why})", f.path.display()))
})
.collect();
let exposed: Vec<&PlistFinding> = findings
.iter()
.filter(|f| !f.migratable.is_empty() || !f.unmapped.is_empty())
.collect();
if !exposed.is_empty() {
return DoctorCheck::new(
CHECK_NAME,
CheckStatus::Fail,
fail_message(&exposed, &unreadable),
);
}
let binary: Vec<String> = findings
.iter()
.filter(|f| f.binary_plist)
.map(|f| f.path.display().to_string())
.collect();
if !binary.is_empty() {
return DoctorCheck::new(
CHECK_NAME,
CheckStatus::Fail,
format!(
"{} LaunchAgent plist(s) are BINARY and cannot be read or repaired here: {} — \
whether they hold a plaintext credential is UNKNOWN, and `tm doctor --fix` \
refuses them. Convert each with `plutil -convert xml1 <path>`, then re-run \
`tm doctor`",
binary.len(),
binary.join("; ")
),
);
}
if !unreadable.is_empty() {
return DoctorCheck::new(
CHECK_NAME,
CheckStatus::Unknown,
format!(
"could not judge {} LaunchAgent plist(s): {} — whether they hold a \
plaintext credential is UNKNOWN",
unreadable.len(),
unreadable.join("; ")
),
);
}
let wide: Vec<String> = findings
.iter()
.filter(|f| f.too_wide())
.map(|f| format!("{} is mode {}", f.path.display(), f.mode_text()))
.collect();
if wide.is_empty() {
DoctorCheck::new(
CHECK_NAME,
CheckStatus::Ok,
format!(
"no plaintext credential in {} trusty LaunchAgent plist(s)",
findings.len()
),
)
} else {
DoctorCheck::new(
CHECK_NAME,
CheckStatus::Warn,
format!(
"no plaintext credential found, but {} plist(s) are readable beyond their \
owner ({}); run `tm doctor --fix-launchd-secrets --yes` (or `chmod 600` them) \
so the next mistake is not a disclosure",
wide.len(),
wide.join("; ")
),
)
}
}
fn fail_message(exposed: &[&PlistFinding], unreadable: &[String]) -> String {
let detail = exposed
.iter()
.map(|f| {
format!(
"{} (mode {}; {})",
f.path.display(),
f.mode_text(),
f.keys().join(", ")
)
})
.collect::<Vec<_>>()
.join("; ");
let unmapped: Vec<String> = exposed
.iter()
.flat_map(|f| f.unmapped.iter().cloned())
.collect();
let caveat = if unmapped.is_empty() {
String::new()
} else {
format!(
". `tm doctor --fix-launchd-secrets` will NOT remove {} — no credential provider is registered \
for it, so there is nowhere to migrate the value and stripping it would break \
the feature it configures; move it out by hand",
unmapped.join(", ")
)
};
let unjudged = if unreadable.is_empty() {
String::new()
} else {
format!(
". {} further plist(s) could not be judged at all: {}",
unreadable.len(),
unreadable.join("; ")
)
};
format!(
"a LaunchAgent plist holds a plaintext credential — the file is user-readable and \
lands in every backup: {detail}. Run `tm doctor --fix-launchd-secrets --yes` to \
migrate each registered credential into the credential store, remove it from the \
plist and tighten the plist to 0600, then ROTATE those credentials: removing a value \
does not un-expose it{caveat}{unjudged}"
)
}
#[cfg(test)]
#[path = "doctor_launchd_secrets_tests.rs"]
mod tests;