use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
use trusty_common::atomic_file::write_atomic;
use trusty_common::credential_registry::provider_for_env_var;
use trusty_common::credentials::{KeyStore, default_store};
use trusty_common::launchd_secrets::{
PlistCredentialEntry, PlistSecret, credential_entries, scrub_plist_keys,
};
use super::doctor_launchd_secrets::{CHECK_NAME, PlistFinding, scan_launch_agents};
use crate::core::doctor_repair::{RepairMode, RepairStep, StepStatus};
pub fn repair_launchd_plist_secrets(home: &Path, mode: RepairMode) -> Vec<RepairStep> {
repair_with_store(home, mode, Arc::from(default_store()))
}
pub(crate) fn repair_with_store(
home: &Path,
mode: RepairMode,
store: Arc<dyn KeyStore>,
) -> Vec<RepairStep> {
let findings = match scan_launch_agents(home) {
Ok(findings) => findings,
Err(e) => return vec![listing_failed(home, &e)],
};
let mut planned = PlannedImports::default();
findings
.iter()
.filter(|f| f.actionable())
.map(|f| repair_one(f, mode, store.as_ref(), &mut planned))
.collect()
}
#[derive(Default)]
pub(crate) struct PlannedImports(HashMap<&'static str, PlistSecret>);
pub(crate) fn listing_failed(home: &Path, e: &std::io::Error) -> RepairStep {
RepairStep {
check: CHECK_NAME,
path: home.join("Library/LaunchAgents"),
what: "remove plaintext credentials from the trusty LaunchAgent plists".to_string(),
status: StepStatus::Failed(format!("could not list the directory: {}", e.kind())),
}
}
pub(crate) fn repair_one(
finding: &PlistFinding,
mode: RepairMode,
store: &dyn KeyStore,
planned: &mut PlannedImports,
) -> RepairStep {
let mut what = describe(finding);
let step = |what: String, status| RepairStep {
check: CHECK_NAME,
path: finding.path.clone(),
what,
status,
};
if let Some(why) = &finding.unreadable {
return step(what, StepStatus::Failed(why.clone()));
}
if finding.migratable.is_empty() {
return step(
what,
StepStatus::Refused(format!(
"no registered credential to migrate; {} has no provider mapping, so removing \
it would disable the feature it configures — move it out by hand and rotate it",
finding.unmapped.join(", ")
)),
);
}
let xml = match std::fs::read_to_string(&finding.path) {
Ok(xml) => xml,
Err(e) => {
let why = format!("could not read it: {}", e.kind());
return step(what, StepStatus::Failed(why));
}
};
let entries = match credential_entries(&xml) {
Ok(entries) => entries,
Err(e) => return step(what, StepStatus::Failed(e.reason)),
};
if entries.is_empty() {
return step(
what,
StepStatus::Refused(
"the plist no longer holds a credential — nothing to remove".to_string(),
),
);
}
if mode == RepairMode::DryRun {
let plan = plan_all(&entries, store, planned);
if plan.importable == 0 {
return step(
what,
StepStatus::Refused(format!(
"nothing would be imported into the credential store, so the plist would \
be left untouched: {}",
plan.blocked.join("; ")
)),
);
}
if !plan.blocked.is_empty() {
what.push_str(&format!("; leaves in place: {}", plan.blocked.join("; ")));
}
if plan.writes > 0 {
what.push_str(WRITE_CAVEAT);
}
what.push_str(&reload_note(&finding.path));
return step(what, StepStatus::Planned);
}
let (imported, blocked) = import_all(&entries, store);
if imported.is_empty() {
return step(
what,
StepStatus::Failed(format!(
"nothing was imported into the credential store, so the plist was left \
untouched: {}",
blocked.join("; ")
)),
);
}
let scrubbed = match scrub_plist_keys(&xml, &imported) {
Ok(scrubbed) => scrubbed,
Err(e) => return step(what, StepStatus::Failed(e.reason)),
};
if let Err(e) = write_atomic(&finding.path, scrubbed.xml.as_bytes()) {
return step(
what,
StepStatus::Failed(format!(
"could not write the scrubbed plist ({}); the original is unchanged",
e.kind()
)),
);
}
what.push_str(&reload_note(&finding.path));
if blocked.is_empty() {
step(what, StepStatus::Applied { backup: None })
} else {
step(
what,
StepStatus::Failed(format!(
"migrated and removed {}, but left {} in place",
imported.join(", "),
blocked.join("; ")
)),
)
}
}
enum Precheck {
Write(&'static str),
AlreadyStored,
Blocked(String),
}
fn precheck(entry: &PlistCredentialEntry, store: &dyn KeyStore) -> Precheck {
let key = &entry.key;
let Some(provider) = provider_for_env_var(key) else {
return Precheck::Blocked(format!(
"{key}: no credential provider is registered for it"
));
};
if entry.value.is_empty() {
return Precheck::Blocked(format!("{key}: the plist entry is empty"));
}
match store.try_get(provider) {
Ok(None) => Precheck::Write(provider),
Ok(Some(existing)) if existing.is_empty() => Precheck::Write(provider),
Ok(Some(existing)) if existing == entry.value.expose() => Precheck::AlreadyStored,
Ok(Some(_)) => Precheck::Blocked(different_value(key, provider)),
Err(e) => Precheck::Blocked(format!(
"{key}: the store could not be read before the write ({}); nothing was written",
kind_of(&e)
)),
}
}
const WRITE_CAVEAT: &str = "; a store read error the backend reports stops a write before it \
happens, but the file store reports a corrupt credentials file as absent, so a planned \
write can still be refused at apply time — a key the apply could not store is never \
stripped";
fn different_value(key: &str, provider: &str) -> String {
format!("{key}: the store already holds a different {provider} credential; left in place")
}
#[derive(Default)]
struct Plan {
importable: usize,
writes: usize,
blocked: Vec<String>,
}
fn plan_all(
entries: &[PlistCredentialEntry],
store: &dyn KeyStore,
planned: &mut PlannedImports,
) -> Plan {
let mut plan = Plan::default();
for entry in entries {
let earlier = provider_for_env_var(&entry.key)
.filter(|_| !entry.value.is_empty())
.and_then(|p| planned.0.get(p).map(|value| (p, value)));
let decision = match earlier {
Some((_, value)) if *value == entry.value => Precheck::AlreadyStored,
Some((provider, _)) => Precheck::Blocked(different_value(&entry.key, provider)),
None => precheck(entry, store),
};
match decision {
Precheck::Write(provider) => {
planned.0.insert(provider, entry.value.clone());
plan.writes += 1;
plan.importable += 1;
}
Precheck::AlreadyStored => plan.importable += 1,
Precheck::Blocked(why) => plan.blocked.push(why),
}
}
plan
}
fn import_all(
entries: &[PlistCredentialEntry],
store: &dyn KeyStore,
) -> (Vec<String>, Vec<String>) {
let mut imported = Vec::new();
let mut blocked = Vec::new();
for entry in entries {
let provider = match precheck(entry, store) {
Precheck::Write(provider) => provider,
Precheck::AlreadyStored => {
imported.push(entry.key.clone());
continue;
}
Precheck::Blocked(why) => {
blocked.push(why);
continue;
}
};
if let Err(e) = store.set(provider, entry.value.expose()) {
blocked.push(format!(
"{}: the store refused the write ({})",
entry.key,
kind_of(&e)
));
continue;
}
match store.try_get(provider) {
Ok(Some(back)) if back == entry.value.expose() => imported.push(entry.key.clone()),
Ok(_) => blocked.push(format!(
"{}: the store did not read back what was written (mismatch)",
entry.key
)),
Err(e) => blocked.push(format!(
"{}: the store could not be read back ({})",
entry.key,
kind_of(&e)
)),
}
}
(imported, blocked)
}
fn kind_of(e: &trusty_common::credentials::KeyStoreError) -> &'static str {
trusty_common::credentials::StoreErrorKind::from(e).as_str()
}
fn describe(finding: &PlistFinding) -> String {
format!(
"migrate {} into the credential store and remove each key confirmed there from \
EnvironmentVariables (mode {}) — no backup is taken (a backup would be a second \
readable copy); ROTATE the credential, removing it here does not un-expose it",
if finding.migratable.is_empty() {
"nothing".to_string()
} else {
finding.migratable.join(", ")
},
finding.mode_text(),
)
}
fn reload_note(path: &Path) -> String {
let label = path.file_stem().map_or_else(
|| "<label>".to_string(),
|s| s.to_string_lossy().into_owned(),
);
format!(
"; the running daemon keeps its old environment, credential included, until launchd \
reloads the unit: `launchctl bootout gui/$(id -u)/{label}` then `launchctl bootstrap \
gui/$(id -u) {}` — `launchctl kickstart -k` does NOT reload the environment",
path.display()
)
}
#[cfg(test)]
#[path = "doctor_launchd_secrets_repair_tests.rs"]
mod tests;