use std::path::{Path, PathBuf};
use std::sync::Arc;
use trusty_common::credentials::{KeyStore, KeyStoreError, MemoryKeyStore};
use super::*;
use crate::core::doctor_repair::{RepairMode, StepStatus};
use crate::daemon::doctor_launchd_secrets_repair::repair_with_store;
const FAKE_API_KEY: &str = "sk-or-v1-0000000000000000000000000000FAKE";
const FAKE_BOT_TOKEN: &str = "1234567890:AAFAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKE";
fn assert_value_never_echoed(text: &str) {
for value in [FAKE_API_KEY, FAKE_BOT_TOKEN] {
assert!(
!text.contains(value),
"output must name the key, never the value: {text}"
);
}
assert!(!text.contains("sk-or-"), "credential prefix leaked: {text}");
}
fn home_with_agents() -> tempfile::TempDir {
let home = tempfile::tempdir().expect("tempdir");
std::fs::create_dir_all(home.path().join("Library/LaunchAgents")).expect("mkdir");
home
}
fn install(home: &Path, name: &str, body: &str) -> PathBuf {
let path = home.join("Library/LaunchAgents").join(name);
std::fs::write(&path, body).expect("write plist");
path
}
fn plist_with_credential() -> String {
format!(
"<plist version=\"1.0\">\n<dict>\n \
<key>Label</key>\n <string>com.trusty.mpm</string>\n \
<key>EnvironmentVariables</key>\n <dict>\n \
<key>PATH</key>\n <string>/usr/bin</string>\n \
<key>OPENROUTER_API_KEY</key>\n <string>{FAKE_API_KEY}</string>\n \
<key>RUST_LOG</key>\n <string>info</string>\n \
</dict>\n</dict>\n</plist>\n"
)
}
struct WriteOnlyStore;
impl KeyStore for WriteOnlyStore {
fn get(&self, _provider: &str) -> Option<String> {
None
}
fn set(&self, _provider: &str, _value: &str) -> Result<(), KeyStoreError> {
Ok(())
}
fn unset(&self, _provider: &str) -> Result<(), KeyStoreError> {
Ok(())
}
fn list(&self) -> Vec<String> {
Vec::new()
}
}
#[test]
fn scan_names_the_key_not_the_value() {
let home = home_with_agents();
install(
home.path(),
"com.trusty.mpm.plist",
&plist_with_credential(),
);
let findings = scan_launch_agents(home.path()).expect("scan");
assert_eq!(findings.len(), 1);
assert_eq!(
findings[0].migratable,
vec!["OPENROUTER_API_KEY".to_string()]
);
assert_value_never_echoed(&format!("{:?}", findings[0]));
}
#[test]
fn scan_splits_registry_mapped_keys_from_unmapped_ones() {
let home = home_with_agents();
install(
home.path(),
"com.trusty.agents.slack.plist",
&format!(
"<plist version=\"1.0\">\n<dict>\n \
<key>EnvironmentVariables</key>\n <dict>\n \
<key>SLACK_APP_TOKEN</key>\n <string>xapp-1-FAKEFAKEFAKEFAKE</string>\n \
<key>AWS_SECRET_ACCESS_KEY</key>\n <string>{FAKE_BOT_TOKEN}</string>\n \
</dict>\n</dict>\n</plist>\n"
),
);
let findings = scan_launch_agents(home.path()).expect("scan");
assert_eq!(findings[0].migratable, vec!["SLACK_APP_TOKEN".to_string()]);
assert_eq!(
findings[0].unmapped,
vec!["AWS_SECRET_ACCESS_KEY".to_string()]
);
}
#[test]
fn scan_reports_a_binary_plist_as_unknown() {
let home = home_with_agents();
let path = home
.path()
.join("Library/LaunchAgents/com.trusty.mpm.plist");
let mut bytes = b"bplist00".to_vec();
bytes.extend_from_slice(&[0xd1, 0x01, 0x02]);
std::fs::write(&path, bytes).expect("write");
let findings = scan_launch_agents(home.path()).expect("scan");
let why = findings[0].unreadable.clone().expect("unreadable");
assert!(why.contains("BINARY"), "{why}");
assert!(why.contains("plutil -convert xml1"), "{why}");
assert!(findings[0].binary_plist, "the binary flag drives the row");
assert!(findings[0].migratable.is_empty());
}
#[test]
fn scan_reports_an_unparseable_plist() {
let home = home_with_agents();
install(
home.path(),
"com.trusty.mpm.plist",
"<plist>\n<dict>\n<key>EnvironmentVariables</key>\n<dict>\n<key>A</key>\n",
);
let findings = scan_launch_agents(home.path()).expect("scan");
assert!(findings[0].unreadable.is_some());
assert_value_never_echoed(&format!("{:?}", findings[0]));
}
#[test]
fn scan_reports_an_unreadable_plist() {
let home = home_with_agents();
std::fs::create_dir(
home.path()
.join("Library/LaunchAgents/com.trusty.mpm.plist"),
)
.expect("mkdir");
let findings = scan_launch_agents(home.path()).expect("scan");
let why = findings[0].unreadable.clone().expect("unreadable");
assert!(why.contains("could not read it"), "{why}");
assert!(!findings[0].binary_plist, "not a binary plist: {why}");
assert_eq!(
check_launchd_plist_secrets(home.path()).status,
CheckStatus::Unknown
);
}
#[test]
fn scan_ignores_foreign_plists() {
let home = home_with_agents();
install(
home.path(),
"com.example.other.plist",
&plist_with_credential(),
);
assert!(scan_launch_agents(home.path()).expect("scan").is_empty());
}
#[test]
fn scan_is_empty_without_a_launch_agents_dir() {
let home = tempfile::tempdir().expect("tempdir");
assert!(scan_launch_agents(home.path()).expect("scan").is_empty());
}
#[test]
fn scan_errors_when_the_directory_cannot_be_listed() {
let home = tempfile::tempdir().expect("tempdir");
std::fs::create_dir_all(home.path().join("Library")).expect("mkdir");
std::fs::write(home.path().join("Library/LaunchAgents"), b"not a dir").expect("write");
assert!(scan_launch_agents(home.path()).is_err());
}
#[test]
fn row_fails_and_names_the_key_not_the_value() {
let home = home_with_agents();
install(
home.path(),
"com.trusty.mpm.plist",
&plist_with_credential(),
);
let row = check_launchd_plist_secrets(home.path());
assert_eq!(row.status, CheckStatus::Fail);
assert!(
row.message.contains("OPENROUTER_API_KEY"),
"{}",
row.message
);
assert!(row.message.contains("ROTATE"), "{}", row.message);
assert!(
row.message
.contains("Run `tm doctor --fix-launchd-secrets --yes`"),
"{}",
row.message
);
assert_value_never_echoed(&row.message);
}
#[cfg(unix)]
#[test]
fn row_flags_a_world_readable_plist() {
use std::os::unix::fs::PermissionsExt;
let home = home_with_agents();
let path = install(
home.path(),
"com.trusty.mpm.plist",
&plist_with_credential(),
);
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).expect("chmod");
let row = check_launchd_plist_secrets(home.path());
assert!(row.message.contains("0644"), "{}", row.message);
}
#[test]
fn row_says_an_unmapped_key_is_not_stripped() {
let home = home_with_agents();
install(
home.path(),
"com.trusty.mpm.plist",
"<plist>\n<dict>\n<key>EnvironmentVariables</key>\n<dict>\n\
<key>AWS_SECRET_ACCESS_KEY</key>\n<string>fake</string>\n</dict>\n</dict>\n</plist>\n",
);
let row = check_launchd_plist_secrets(home.path());
assert_eq!(row.status, CheckStatus::Fail);
assert!(row.message.contains("will NOT remove"), "{}", row.message);
}
#[test]
fn row_is_ok_when_clean() {
let home = home_with_agents();
let path = install(
home.path(),
"com.trusty.mpm.plist",
"<plist>\n<dict>\n<key>EnvironmentVariables</key>\n<dict>\n\
<key>PATH</key>\n<string>/usr/bin</string>\n</dict>\n</dict>\n</plist>\n",
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).expect("chmod");
}
let _ = &path;
assert_eq!(
check_launchd_plist_secrets(home.path()).status,
CheckStatus::Ok
);
}
#[test]
fn row_is_unknown_when_a_plist_cannot_be_parsed() {
let home = home_with_agents();
install(
home.path(),
"com.trusty.mpm.plist",
"<plist>\n<dict>\n<key>EnvironmentVariables</key>\n<dict>\n<key>A</key>\n",
);
assert_eq!(
check_launchd_plist_secrets(home.path()).status,
CheckStatus::Unknown
);
}
#[test]
fn row_fails_on_a_binary_plist() {
let home = home_with_agents();
let path = home
.path()
.join("Library/LaunchAgents/com.trusty.mpm.plist");
let mut bytes = b"bplist00".to_vec();
bytes.extend_from_slice(&[0xd1, 0x01, 0x02]);
std::fs::write(&path, bytes).expect("write");
let row = check_launchd_plist_secrets(home.path());
assert_eq!(row.status, CheckStatus::Fail, "{row:?}");
assert!(row.message.contains("BINARY"), "{}", row.message);
assert!(
row.message.contains("plutil -convert xml1"),
"the row must name the remedy: {}",
row.message
);
}
#[test]
fn row_is_unknown_when_the_directory_cannot_be_listed() {
let home = tempfile::tempdir().expect("tempdir");
std::fs::create_dir_all(home.path().join("Library")).expect("mkdir");
std::fs::write(home.path().join("Library/LaunchAgents"), b"not a dir").expect("write");
assert_eq!(
check_launchd_plist_secrets(home.path()).status,
CheckStatus::Unknown
);
}
#[test]
fn row_fail_still_names_the_plists_it_could_not_judge() {
let home = home_with_agents();
install(
home.path(),
"com.trusty.mpm.plist",
&plist_with_credential(),
);
install(
home.path(),
"com.trusty.zebra.plist",
"<plist>\n<dict>\n<key>EnvironmentVariables</key>\n<dict>\n<key>A</key>\n",
);
let row = check_launchd_plist_secrets(home.path());
assert_eq!(row.status, CheckStatus::Fail);
assert!(
row.message.contains("com.trusty.zebra.plist"),
"{}",
row.message
);
}
#[test]
fn repair_plans_without_writing() {
let home = home_with_agents();
let path = install(
home.path(),
"com.trusty.mpm.plist",
&plist_with_credential(),
);
let before = std::fs::read_to_string(&path).expect("read");
let steps = repair_with_store(
home.path(),
RepairMode::DryRun,
Arc::new(MemoryKeyStore::new()),
);
assert_eq!(steps.len(), 1);
assert_eq!(steps[0].status, StepStatus::Planned);
assert_eq!(std::fs::read_to_string(&path).expect("read"), before);
}
#[test]
fn repair_migrates_then_removes() {
let home = home_with_agents();
let path = install(
home.path(),
"com.trusty.mpm.plist",
&plist_with_credential(),
);
let store = Arc::new(MemoryKeyStore::new());
let steps = repair_with_store(home.path(), RepairMode::Apply, store.clone());
assert_eq!(steps[0].status, StepStatus::Applied { backup: None });
assert_eq!(store.get("openrouter").as_deref(), Some(FAKE_API_KEY));
let after = std::fs::read_to_string(&path).expect("read");
assert!(!after.contains("OPENROUTER_API_KEY"), "{after}");
assert!(after.contains("RUST_LOG"), "{after}");
assert_value_never_echoed(&after);
assert_value_never_echoed(&format!("{:?}", steps[0]));
}
#[test]
fn repair_leaves_the_plist_untouched_when_the_import_fails() {
let home = home_with_agents();
let path = install(
home.path(),
"com.trusty.mpm.plist",
&plist_with_credential(),
);
let before = std::fs::read_to_string(&path).expect("read");
let steps = repair_with_store(home.path(), RepairMode::Apply, Arc::new(WriteOnlyStore));
assert!(
matches!(steps[0].status, StepStatus::Failed(_)),
"{:?}",
steps[0].status
);
assert_eq!(std::fs::read_to_string(&path).expect("read"), before);
assert_value_never_echoed(&format!("{:?}", steps[0]));
}
#[test]
fn repair_keeps_an_unmapped_key_and_says_so() {
let home = home_with_agents();
let path = install(
home.path(),
"com.trusty.mpm.plist",
"<plist>\n<dict>\n<key>EnvironmentVariables</key>\n<dict>\n\
<key>AWS_SECRET_ACCESS_KEY</key>\n<string>fake</string>\n</dict>\n</dict>\n</plist>\n",
);
let before = std::fs::read_to_string(&path).expect("read");
let steps = repair_with_store(
home.path(),
RepairMode::Apply,
Arc::new(MemoryKeyStore::new()),
);
assert!(
matches!(steps[0].status, StepStatus::Refused(_)),
"{:?}",
steps[0].status
);
assert_eq!(std::fs::read_to_string(&path).expect("read"), before);
}
#[test]
fn repair_refuses_a_binary_plist() {
let home = home_with_agents();
let path = home
.path()
.join("Library/LaunchAgents/com.trusty.mpm.plist");
let mut bytes = b"bplist00".to_vec();
bytes.extend_from_slice(&[0xd1, 0x01]);
std::fs::write(&path, &bytes).expect("write");
let steps = repair_with_store(
home.path(),
RepairMode::Apply,
Arc::new(MemoryKeyStore::new()),
);
let StepStatus::Failed(why) = &steps[0].status else {
panic!(
"a binary plist must be refused loudly: {:?}",
steps[0].status
);
};
assert!(why.contains("BINARY"), "{why}");
assert!(why.contains("plutil -convert xml1"), "{why}");
assert!(why.contains(&path.display().to_string()), "{why}");
assert!(why.contains("re-run `tm doctor`"), "{why}");
assert_eq!(std::fs::read(&path).expect("read"), bytes);
}
#[test]
fn repair_fails_loudly_on_an_unparseable_plist() {
let home = home_with_agents();
install(
home.path(),
"com.trusty.mpm.plist",
"<plist>\n<dict>\n<key>EnvironmentVariables</key>\n<dict>\n<key>A</key>\n",
);
let steps = repair_with_store(
home.path(),
RepairMode::Apply,
Arc::new(MemoryKeyStore::new()),
);
assert!(matches!(steps[0].status, StepStatus::Failed(_)));
}
#[cfg(unix)]
#[test]
fn repair_fails_loudly_when_the_plist_is_unwritable() {
use std::os::unix::fs::PermissionsExt;
let home = home_with_agents();
let path = install(
home.path(),
"com.trusty.mpm.plist",
&plist_with_credential(),
);
let before = std::fs::read_to_string(&path).expect("read");
let dir = home.path().join("Library/LaunchAgents");
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o500)).expect("chmod");
let steps = repair_with_store(
home.path(),
RepairMode::Apply,
Arc::new(MemoryKeyStore::new()),
);
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).expect("chmod back");
assert!(
matches!(steps[0].status, StepStatus::Failed(_)),
"{:?}",
steps[0].status
);
assert_eq!(std::fs::read_to_string(&path).expect("read"), before);
}
#[test]
fn repair_fails_loudly_when_the_directory_cannot_be_listed() {
let home = tempfile::tempdir().expect("tempdir");
std::fs::create_dir_all(home.path().join("Library")).expect("mkdir");
std::fs::write(home.path().join("Library/LaunchAgents"), b"not a dir").expect("write");
let steps = repair_with_store(
home.path(),
RepairMode::Apply,
Arc::new(MemoryKeyStore::new()),
);
assert_eq!(steps.len(), 1);
assert!(matches!(steps[0].status, StepStatus::Failed(_)));
}
#[test]
fn repair_produces_no_steps_for_a_clean_host() {
let home = home_with_agents();
install(
home.path(),
"com.trusty.mpm.plist",
"<plist>\n<dict>\n<key>EnvironmentVariables</key>\n<dict>\n\
<key>PATH</key>\n<string>/usr/bin</string>\n</dict>\n</dict>\n</plist>\n",
);
assert!(
repair_with_store(
home.path(),
RepairMode::Apply,
Arc::new(MemoryKeyStore::new())
)
.is_empty()
);
}
#[test]
fn repair_is_idempotent() {
let home = home_with_agents();
let path = install(
home.path(),
"com.trusty.mpm.plist",
&plist_with_credential(),
);
let store = Arc::new(MemoryKeyStore::new());
let first = repair_with_store(home.path(), RepairMode::Apply, store.clone());
let after_first = std::fs::read_to_string(&path).expect("read");
let second = repair_with_store(home.path(), RepairMode::Apply, store);
assert_eq!(first[0].status, StepStatus::Applied { backup: None });
assert!(second.is_empty(), "{second:?}");
assert_eq!(std::fs::read_to_string(&path).expect("read"), after_first);
}
#[cfg(unix)]
#[test]
fn scan_reports_a_symlinked_plist_as_unreadable() {
let home = home_with_agents();
let outside = home.path().join("dotfiles-com.trusty.mpm.plist");
std::fs::write(&outside, plist_with_credential()).expect("write target");
let link = home
.path()
.join("Library/LaunchAgents/com.trusty.mpm.plist");
std::os::unix::fs::symlink(&outside, &link).expect("symlink");
let findings = scan_launch_agents(home.path()).expect("scan");
let why = findings[0].unreadable.clone().expect("unreadable");
assert!(why.contains("SYMLINK"), "{why}");
assert!(why.contains(&outside.display().to_string()), "{why}");
assert!(
findings[0].migratable.is_empty() && findings[0].unmapped.is_empty(),
"the scan judged the link's target: {:?}",
findings[0]
);
assert!(!findings[0].binary_plist, "a link is not a binary plist");
assert_value_never_echoed(&why);
}
#[cfg(unix)]
#[test]
fn row_is_unknown_for_a_symlinked_plist() {
let home = home_with_agents();
let outside = home.path().join("elsewhere.plist");
std::fs::write(&outside, plist_with_credential()).expect("write target");
std::os::unix::fs::symlink(
&outside,
home.path()
.join("Library/LaunchAgents/com.trusty.mpm.plist"),
)
.expect("symlink");
let check = check_launchd_plist_secrets(home.path());
assert_eq!(check.status, CheckStatus::Unknown, "{check:?}");
assert!(check.message.contains("SYMLINK"), "{}", check.message);
assert_value_never_echoed(&check.message);
}
#[cfg(unix)]
#[test]
fn repair_refuses_a_symlinked_plist() {
let home = home_with_agents();
let outside = home.path().join("dotfiles.plist");
let body = plist_with_credential();
std::fs::write(&outside, &body).expect("write target");
let link = home
.path()
.join("Library/LaunchAgents/com.trusty.mpm.plist");
std::os::unix::fs::symlink(&outside, &link).expect("symlink");
let steps = repair_with_store(
home.path(),
RepairMode::Apply,
Arc::new(MemoryKeyStore::new()),
);
let StepStatus::Failed(why) = &steps[0].status else {
panic!(
"a symlinked plist must be refused loudly: {:?}",
steps[0].status
);
};
assert!(why.contains("SYMLINK"), "{why}");
assert!(
std::fs::symlink_metadata(&link)
.expect("stat")
.file_type()
.is_symlink(),
"the repair replaced the link with a plain file"
);
assert_eq!(std::fs::read_to_string(&outside).expect("read"), body);
assert_value_never_echoed(why);
}