trusty-mpm 1.8.2

trusty-mpm: unified multi-agent orchestration platform (core, daemon, CLI, TUI, Telegram)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
//! `tm doctor --fix` repair for credentials in a LaunchAgent plist (#8236).
//!
//! Why: the renderer guard only covers a unit that gets regenerated. A host
//! whose plist is hand-maintained — which is how #8236's `com.trusty.mpm.plist`
//! came to hold two credentials, since no code in this workspace writes that
//! file — is never reached by an install. This is.
//!
//! What: MIGRATE, then strip, and only what was migrated.
//!
//! 1. Read the plist's credential entries.
//! 2. For each key the credential REGISTRY maps to a provider, READ the store
//!    first. An absent value is written and READ BACK; only a byte-equal
//!    read-back counts as imported. An equal value counts as imported with no
//!    write. A DIFFERENT value is never overwritten (#8563): it may be a key
//!    the operator already rotated, and the plist copy is the old one. A store
//!    read error the backend reports fails closed before the write. The file
//!    store reports a corrupt file as absent, so the write is attempted and
//!    refused; either way a key that was not stored is never stripped.
//! 3. Remove exactly the imported keys, with an ATOMIC write (temp file beside
//!    the target, then rename, mode preserved), so an interrupted repair cannot
//!    leave a truncated plist that launchd refuses to load.
//! 4. Anything not imported — an unregistered key, a conflicting store value, a
//!    store that refused, a read-back that disagreed — stays in the file, and
//!    the step says so. The dry run runs the same store pre-check read-only,
//!    carrying the values it plans to write across every plist in the run, so
//!    its plan names the same keys — except a write the store refuses only at
//!    apply time, which the plan's text warns about.
//!
//! **No backup is taken**, unlike every other `--fix` repair. A backup of a
//! plist holding a credential is a second user-readable copy of that
//! credential, which is the defect, not a safety net. Atomicity is what makes
//! that safe: the original is byte-identical until the rename, and the rename
//! either happens completely or not at all.
//!
//! Rotation is NOT part of this and cannot be: the value was readable by
//! everything on the host for as long as it sat there, so removing it makes the
//! file safe and the credential still compromised. Every step's text says so,
//! and says the running daemon keeps the old environment until launchd reloads
//! the unit.
//!
//! Test: `doctor_launchd_secrets_tests.rs`, `doctor_launchd_secrets_repair_tests.rs`.

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};

/// Rewrite every trusty LaunchAgent that holds a migratable credential.
///
/// Why/What: see the module docs.
/// [`StepStatus::Planned`] under [`RepairMode::DryRun`];
/// [`StepStatus::Applied`] with NO backup once written;
/// [`StepStatus::Refused`] when there is nothing this repair may safely do;
/// [`StepStatus::Failed`] when the plist cannot be parsed, the import cannot be
/// confirmed, or the rewrite cannot be written — never a warning followed by a
/// pass. A clean host produces no steps at all.
/// Test: `repair_plans_without_writing`, `repair_migrates_then_removes`,
/// `repair_leaves_the_plist_untouched_when_the_import_fails`,
/// `repair_keeps_an_unmapped_key_and_says_so`,
/// `repair_refuses_a_binary_plist`,
/// `repair_refuses_a_symlinked_plist`,
/// `repair_fails_loudly_on_an_unparseable_plist`,
/// `repair_fails_loudly_when_the_plist_is_unwritable`,
/// `repair_fails_loudly_when_the_directory_cannot_be_listed`,
/// `repair_produces_no_steps_for_a_clean_host`,
/// `repair_is_idempotent`, `a_different_store_value_is_never_overwritten`,
/// `an_equal_store_value_is_imported_without_a_write`,
/// `a_store_read_error_before_the_write_fails_closed`,
/// `a_corrupt_file_store_refuses_the_write_and_keeps_the_key`,
/// `the_applied_step_names_the_reload_commands`.
pub fn repair_launchd_plist_secrets(home: &Path, mode: RepairMode) -> Vec<RepairStep> {
    repair_with_store(home, mode, Arc::from(default_store()))
}

/// [`repair_launchd_plist_secrets`] against an injected store.
///
/// Why: every test here migrates into a `MemoryKeyStore`. No test may write to
/// a real Keychain or a real `~/.trusty`, and the read-back confirmation is
/// exactly the behaviour a test double has to be able to break.
/// Test: as [`repair_launchd_plist_secrets`].
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()
}

/// The provider values a dry run has already planned to write in this run.
///
/// Why (#8563): the apply writes the first plist's value, so a second plist
/// holding the same provider is checked against THAT value, not the store as
/// it stood before the run. A dry run that ignored its own earlier plan would
/// plan both writes while the apply refuses the second.
/// What: provider → planned value, filled only by a dry run. No `Debug`, so the
/// values it holds cannot be formatted.
#[derive(Default)]
pub(crate) struct PlannedImports(HashMap<&'static str, PlistSecret>);

/// The one step reported when `~/Library/LaunchAgents` cannot be listed.
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())),
    }
}

/// One file's repair.
///
/// Why: the unreadable arm, the unmapped-only arm and the rewrite arm each have
/// to produce a step, so an operator running `--fix` sees the file that could
/// NOT be fixed beside the ones that were.
/// What: the dry run reads the plist and runs the store pre-check read-only,
/// against the store plus the writes `planned` already holds from earlier
/// plists in the same run, so a key the apply would leave in place is named in
/// the plan too. A step whose plist is (or would be) rewritten carries the
/// reload commands.
/// Test: as [`repair_launchd_plist_secrets`], and
/// `the_plan_refuses_a_second_plist_with_a_different_value`,
/// `the_plan_imports_a_second_plist_with_an_equal_value`.
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(", ")
            )),
        );
    }

    // Re-read rather than trusting the scan's copy: `--fix` runs after the
    // report, and a reinstall in between would have changed the file.
    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 {
        // #8563: the plan runs the same pre-check the apply does, read-only.
        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() {
        // No backup, deliberately — see the module header.
        step(what, StepStatus::Applied { backup: None })
    } else {
        step(
            what,
            StepStatus::Failed(format!(
                "migrated and removed {}, but left {} in place",
                imported.join(", "),
                blocked.join("; ")
            )),
        )
    }
}

/// What the store pre-check decided for one plist entry.
enum Precheck {
    /// The store holds nothing for `provider`: write, then read back.
    Write(&'static str),
    /// The store already holds this exact value: imported, no write.
    AlreadyStored,
    /// The entry stays in the plist, for this value-free reason.
    Blocked(String),
}

/// Decide one entry's fate by READING the store, never writing it.
///
/// Why (#8563): `set` overwrites. An operator who already stored a rotated key
/// would lose it to the old plist value, and the read-back would then match
/// and report success. Reading first is the only way to know.
/// What: an unregistered key or an empty entry is blocked; an absent (or empty)
/// store value means write; an equal one means already stored; a different one
/// is blocked and left in place. A read error the backend REPORTS is blocked
/// before any write (fail-closed). The file store does not report one: its
/// `try_get` turns an unreadable or corrupt `credentials.toml` into "absent",
/// so this says write, the dry run plans it, and the apply's `set` then fails
/// on the same file — [`import_all`] keeps that key in the plist. Every reason
/// names the key, the provider and an error kind — never a value.
/// Test: `a_different_store_value_is_never_overwritten`,
/// `an_equal_store_value_is_imported_without_a_write`,
/// `a_store_read_error_before_the_write_fails_closed`,
/// `a_corrupt_file_store_refuses_the_write_and_keeps_the_key`.
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)
        )),
    }
}

/// What a planned write can still meet at apply time (#8563).
///
/// Why: the file store reports an unreadable or corrupt `credentials.toml` as
/// absent, so the dry run plans a write that the apply's `set` then refuses.
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";

/// The reason a key stays when its provider already holds another value.
fn different_value(key: &str, provider: &str) -> String {
    format!("{key}: the store already holds a different {provider} credential; left in place")
}

/// The dry run's view of one plist.
#[derive(Default)]
struct Plan {
    /// Entries the apply would import (a write, or an equal stored value).
    importable: usize,
    /// How many of those need a store write.
    writes: usize,
    /// One value-free reason per entry that would stay.
    blocked: Vec<String>,
}

/// The dry run's view: what would be imported, and why the rest would stay.
///
/// Why (#8563): the apply writes as it goes, so a later entry — in this plist
/// or a later one — is checked against the values written before it.
/// What: a provider already in `planned` is judged against the planned value
/// (equal → imported with no write, different → left in place); any other
/// entry goes through [`precheck`]. Each planned write is added to `planned`.
/// Test: `the_plan_refuses_a_second_plist_with_a_different_value`,
/// `the_plan_imports_a_second_plist_with_an_equal_value`.
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
}

/// Import every registry-mapped entry the pre-check allows, confirming each
/// write by read-back.
///
/// Why (#8236 item 2): a plist entry may be stripped only once its value is
/// provably retrievable from the store. `set` returning `Ok` is not that proof
/// — a backend can accept a write it cannot serve back. #8563: and `set` runs
/// only when the store held nothing — see [`precheck`].
/// What: returns `(keys confirmed imported, one reason per key that was not)`.
/// Never logs, returns or formats a value: a read-back mismatch reports the KEY
/// and the word "mismatch", never either side of the comparison.
/// Test: `repair_migrates_then_removes`,
/// `repair_leaves_the_plist_untouched_when_the_import_fails`,
/// `repair_keeps_an_unmapped_key_and_says_so`,
/// `a_different_store_value_is_never_overwritten`.
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)
}

/// A value-free label for a store error.
///
/// Why: `KeyStoreError`'s own `Display` names a path and a backend message.
/// Neither is a credential, but the repair's contract is that it reports kinds,
/// and a kind cannot regress into a disclosure.
fn kind_of(e: &trusty_common::credentials::KeyStoreError) -> &'static str {
    trusty_common::credentials::StoreErrorKind::from(e).as_str()
}

/// The one-line description both modes print.
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(),
    )
}

/// How to make the running daemon drop the removed credential.
///
/// Why (#8563): launchd hands a job the environment it loaded, so the daemon
/// keeps the stripped value until the unit is unloaded and loaded again, and
/// `kickstart -k` restarts the process from the SAME loaded definition. An
/// operator told only "removed" would restart it and believe it done.
/// What: `bootout` + `bootstrap` for the plist's label — its file stem, which
/// is the label every trusty installer writes.
/// Test: `the_applied_step_names_the_reload_commands`.
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;