trusty-memory 0.28.0

MCP server (stdio + Unix socket) for trusty-memory
Documentation
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
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
//! `trusty-memory audit secrets --count-only` — count stored drawers the
//! current secret filter would refuse.
//!
//! Why: #8645. `check_secret` runs at write time only, so a drawer stored
//! before a filter fix is never re-screened. trusty-common 0.52.3 (#8589)
//! started screening the VALUE half of a `KEY=value` token; drawers written
//! earlier may hold values the filter now refuses. This command measures how
//! many, without printing any of them — remediation is a separate,
//! owner-gated step.
//! What: enumerates palaces the way `kg-rebuild` does
//! (`PalaceRegistry::list_palaces`), reads each drawer table through
//! [`with_store_copy`] (a private copy of `kg.redb`, never the live file), runs
//! `check_secret` on each drawer's content in memory, and reports counts only.
//! The `PotentialSecret { token }` preview is matched away at the call site
//! and never stored, logged or printed.
//!
//! Daemon safety: the live store is only ever `std::fs::copy`-read. No redb
//! open, lock, WAL or rename touches it, so a running daemon neither blocks the
//! scan nor sees it. `ReadOnlyRedb` was rejected: its live variant takes a
//! shared lock, and a daemon reopening an idle-evicted palace during the scan
//! would get `DatabaseAlreadyOpen` and fail its writes loud. The cost of the
//! copy is a point-in-time read: a copy torn by a concurrent commit fails for
//! that palace and is reported as an error. Anything the scan could not see —
//! an unreadable store, an undecodable drawer row — fails the run.
//! Test: `counts_refusals_per_palace_and_variant`,
//! `output_and_tracing_carry_no_drawer_content`,
//! `undecodable_drawer_row_is_counted_and_fails_the_run`,
//! `scan_leaves_palace_files_byte_identical_under_a_live_writer`.

use std::future::Future;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;

use anyhow::{Context, Result};
use clap::{Args, Subcommand};
use serde::Serialize;
use trusty_common::memory_core::filter::{check_secret, FilterReject};
use trusty_common::memory_core::PalaceRegistry;

use super::store_snapshot::{sweep_stale_copies, with_store_copy, SCRATCH_PREFIX, STALE_COPY_AGE};

/// Arguments of `trusty-memory audit` (#8645).
///
/// Why: lives beside its handler, as `PalaceAction` does, so `main.rs` stays
/// under the SLOC cap — `main.rs` holds only the newtype variant.
/// What: wraps the required [`AuditAction`] subcommand.
/// Test: `count_only_flag_is_required`.
#[derive(Debug, Args)]
pub struct AuditArgs {
    #[command(subcommand)]
    pub action: AuditAction,
}

/// Actions under `trusty-memory audit` (#8645).
///
/// What: `Secrets` is the only action and is read-only.
/// Test: `count_only_flag_is_required`.
#[derive(Debug, Subcommand)]
pub enum AuditAction {
    /// Count stored drawers the current secret filter would refuse.
    ///
    /// READ-ONLY and COUNT ONLY. Each palace's store is copied to a private
    /// temp dir and read from the copy, so this is safe with the daemon
    /// running and writes nothing to any palace. No drawer text, id or token
    /// preview is printed — only counts per palace and per refusal kind.
    ///
    ///   trusty-memory audit secrets --count-only
    ///   trusty-memory audit secrets --count-only --palace trusty-tools --json
    Secrets {
        /// Required. Names the only mode: counts, never content.
        #[arg(long = "count-only", required = true)]
        count_only: bool,
        /// Restrict the scan to one palace id.
        #[arg(long, value_name = "ID")]
        palace: Option<String>,
        /// Emit JSON instead of text.
        #[arg(long)]
        json: bool,
    },
}

/// Run one [`AuditAction`].
///
/// What: one arm per variant; `count_only` is enforced by clap.
/// Test: `count_only_flag_is_required` (parsing); the handler is covered
/// through [`scan_palaces`] and [`render`].
pub async fn dispatch(args: AuditArgs) -> Result<()> {
    match args.action {
        AuditAction::Secrets { palace, json, .. } => {
            handle_audit_secrets(AuditSecretsOptions { palace, json }).await
        }
    }
}

/// What one `audit secrets` invocation was asked to do.
#[derive(Debug, Clone, Default)]
pub struct AuditSecretsOptions {
    /// Restrict the scan to one palace id. `None` scans every palace.
    pub palace: Option<String>,
    /// Emit JSON instead of text.
    pub json: bool,
}

/// Refusals broken down by [`FilterReject`] variant.
///
/// Why: the issue asks for a per-variant breakdown. `check_secret` only
/// returns `PotentialSecret` today; the exhaustive match in [`tally_reject`]
/// makes a new variant a compile error here rather than an uncounted refusal.
/// What: one counter per variant. The quality gates (`TooShort`,
/// `NoisePattern`, `NonAlphabetic`) are not run by this audit, so they stay 0.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct RejectCounts {
    pub potential_secret: usize,
    pub too_short: usize,
    pub noise_pattern: usize,
    pub non_alphabetic: usize,
}

/// What happened to a palace's store during the scan.
///
/// Why: #8645 — a palace with no `kg.redb` and a palace whose store was read
/// and found clean must never render alike.
/// What: `Read` — the copy was opened and its drawer table read; `Absent` — the
/// palace has no store file; `Error` — the store could not be read (see
/// [`PalaceSecretCounts::error`]).
/// Test: `unstattable_palace_dir_is_an_error_row_not_an_absent_store`.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum StoreState {
    #[default]
    Read,
    Absent,
    Error,
}

impl StoreState {
    fn as_str(self) -> &'static str {
        match self {
            Self::Read => "read",
            Self::Absent => "absent",
            Self::Error => "error",
        }
    }
}

/// One palace's counts. Carries no drawer text, id or token preview.
///
/// Why: the output contract is counts only (#8645).
/// What: `drawers_unreadable` counts drawer rows present in the table that
/// could not be decoded, so were never screened; any non-zero value fails the
/// run (see [`scan_verdict`]). `drawers_refused` is the number of drawers
/// `check_secret` refuses. `key_value_first` counts refusals whose refusing
/// token — the first flagged token, the one `check_secret` names — is
/// `KEY=value`-shaped. `key_value_only` counts refusals where every flagged token is
/// `KEY=value`-shaped: the best cheap estimate of drawers only the #8589 fix
/// catches, since a drawer that also holds a bare flagged token was refused
/// before it too. Both are shape classifications, not a replay of the old
/// filter. `error` holds only the outermost error message, which this module
/// writes itself, so a store error can never echo stored bytes.
/// Test: `counts_refusals_per_palace_and_variant`,
/// `undecodable_drawer_row_is_counted_and_fails_the_run`.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct PalaceSecretCounts {
    pub palace: String,
    pub store: StoreState,
    pub drawers_scanned: usize,
    pub drawers_unreadable: usize,
    pub drawers_refused: usize,
    pub by_variant: RejectCounts,
    pub key_value_first: usize,
    pub key_value_only: usize,
    pub error: Option<String>,
}

/// Count one refusal against its variant, dropping any payload unread.
///
/// Why: the `PotentialSecret` preview is a partial secret; it must be
/// discarded where it is produced.
/// What: increments the matching counter. Every arm binds with `{ .. }`.
/// Test: `counts_refusals_per_palace_and_variant`.
fn tally_reject(counts: &mut RejectCounts, reject: FilterReject) {
    match reject {
        FilterReject::PotentialSecret { .. } => counts.potential_secret += 1,
        FilterReject::TooShort { .. } => counts.too_short += 1,
        FilterReject::NoisePattern { .. } => counts.noise_pattern += 1,
        FilterReject::NonAlphabetic { .. } => counts.non_alphabetic += 1,
    }
}

/// Split `content` into the tokens `find_secret_token` classifies.
///
/// Why: telling the `KEY=value` path apart needs the flagged tokens, and
/// trusty-common returns only a redacted preview of the first.
/// What: mirrors `find_secret_token`'s tokenizer in
/// `trusty-common/src/memory_core/filter/secret.rs` — split on whitespace or a
/// backtick, then trim everything but ASCII alphanumerics, `-` and `_` from
/// both ends. `check_secret` on one such token is exactly the per-token test
/// `find_secret_token` applies, because the split and trim are idempotent.
/// Test: `every_refused_drawer_has_a_flagged_token`.
fn secret_tokens(content: &str) -> impl Iterator<Item = &str> {
    content
        .split(|c: char| c.is_whitespace() || c == '`')
        .map(|raw| {
            raw.trim_matches(|c: char| !(c.is_ascii_alphanumeric() || matches!(c, '-' | '_')))
        })
        .filter(|tok| check_secret(tok).is_err())
}

/// True when `token` has the `KEY=value` shape #8589 started screening.
///
/// What: a non-empty identifier-like key (`[A-Za-z0-9_.-]+`), one `=`, and a
/// value that is not pure `=` padding (so a padded base64 blob is not a key).
/// Test: `key_value_shape_boundaries`.
fn is_key_value_shaped(token: &str) -> bool {
    let Some((key, value)) = token.split_once('=') else {
        return false;
    };
    !key.is_empty()
        && key
            .bytes()
            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.'))
        && !value.is_empty()
        && !value.bytes().all(|b| b == b'=')
}

/// Screen one drawer's content and add it to `counts`.
///
/// What: counts the drawer as scanned; on a refusal, tallies the variant and
/// classifies the flagged tokens by shape. Nothing derived from `content`
/// outlives this call.
/// Test: `counts_refusals_per_palace_and_variant`.
fn screen_drawer(counts: &mut PalaceSecretCounts, content: &str) {
    counts.drawers_scanned += 1;
    let Err(reject) = check_secret(content) else {
        return;
    };
    counts.drawers_refused += 1;
    tally_reject(&mut counts.by_variant, reject);
    let mut flagged = secret_tokens(content);
    let Some(first) = flagged.next() else {
        return;
    };
    if is_key_value_shaped(first) {
        counts.key_value_first += 1;
        if flagged.all(is_key_value_shaped) {
            counts.key_value_only += 1;
        }
    }
}

/// A scan halted by its `stop` signal before every palace was read (#8645).
///
/// What: `scanned` is how many palaces finished before the stop was seen.
/// Test: `stop_signal_halts_the_scan_between_palaces`.
#[derive(Debug, thiserror::Error)]
#[error("audit secrets: scan stopped after {scanned} palace(s)")]
pub struct ScanInterrupted {
    pub scanned: usize,
}

/// Scan every palace (or one) under `registry_dir`.
///
/// Why: the testable core — the CLI handler only resolves the data root and
/// renders. `scratch_parent` is where the per-palace store copies live and die,
/// so a test can prove nothing is left behind.
/// What: lists palaces from disk, skips all but `palace_filter` when set, and
/// screens each palace's drawers from a private copy of its store. A palace
/// that cannot be read is recorded with an error and the scan continues; one
/// with no store is recorded as `Absent`; undecodable drawer rows are counted
/// in `drawers_unreadable`, never dropped. A `palace_filter` naming no palace
/// is an error, not an empty report. `stop` is polled before each palace; once
/// it returns true no further palace is copied, and the scan returns
/// [`ScanInterrupted`] instead of partial counts.
/// Test: `counts_refusals_per_palace_and_variant`,
/// `palace_filter_scans_one_and_rejects_an_unknown_name`,
/// `scan_leaves_palace_files_byte_identical_under_a_live_writer`,
/// `undecodable_drawer_row_is_counted_and_fails_the_run`,
/// `truncated_store_copy_is_an_error_row`,
/// `stop_signal_halts_the_scan_between_palaces`.
pub fn scan_palaces(
    registry_dir: &Path,
    palace_filter: Option<&str>,
    scratch_parent: &Path,
    stop: &dyn Fn() -> bool,
) -> Result<Vec<PalaceSecretCounts>> {
    let palaces = PalaceRegistry::list_palaces(registry_dir)
        .with_context(|| format!("list palaces under {}", registry_dir.display()))?;
    let mut out = Vec::new();
    for palace in palaces {
        let id = palace.id.0.clone();
        if palace_filter.is_some_and(|f| f != id) {
            continue;
        }
        if stop() {
            return Err(ScanInterrupted { scanned: out.len() }.into());
        }
        let mut counts = PalaceSecretCounts {
            palace: id,
            ..Default::default()
        };
        let read = with_store_copy(&palace.data_dir, scratch_parent, |store| {
            let (drawers, unreadable) =
                store.load_drawers_with_skipped().context("load drawers")?;
            // #8645: a skipped row was never screened; count it, never drop it.
            counts.drawers_unreadable = unreadable;
            for drawer in drawers {
                screen_drawer(&mut counts, drawer.content());
            }
            Ok(())
        });
        match read {
            Ok(Some(())) => {}
            Ok(None) => counts.store = StoreState::Absent,
            Err(e) => {
                // #8645: outermost message only — the chain below it comes
                // from the store and could quote stored bytes.
                counts = PalaceSecretCounts {
                    palace: counts.palace,
                    store: StoreState::Error,
                    error: Some(e.to_string()),
                    ..Default::default()
                };
            }
        }
        out.push(counts);
    }
    if let Some(name) = palace_filter {
        if out.is_empty() {
            anyhow::bail!("no palace named `{name}` under {}", registry_dir.display());
        }
    }
    Ok(out)
}

/// Totals across palaces, for the last text line and the JSON `totals`.
#[derive(Debug, Default, Serialize)]
struct Totals {
    palaces: usize,
    absent: usize,
    drawers_scanned: usize,
    drawers_unreadable: usize,
    drawers_refused: usize,
    key_value_first: usize,
    key_value_only: usize,
    errors: usize,
}

fn totals(rows: &[PalaceSecretCounts]) -> Totals {
    let mut t = Totals {
        palaces: rows.len(),
        ..Default::default()
    };
    for r in rows {
        t.absent += usize::from(r.store == StoreState::Absent);
        t.drawers_scanned += r.drawers_scanned;
        t.drawers_unreadable += r.drawers_unreadable;
        t.drawers_refused += r.drawers_refused;
        t.key_value_first += r.key_value_first;
        t.key_value_only += r.key_value_only;
        t.errors += usize::from(r.error.is_some());
    }
    t
}

/// Decide whether a finished scan may exit zero.
///
/// Why: #8645 — a scan that could not see everything must never look like a
/// clean scan. A palace that errored, or one with undecodable rows, hid drawers
/// from `check_secret`.
/// What: `Ok(())` when no row carries an error or an unreadable drawer;
/// otherwise an `Err` naming both counts. An `Absent` store is not a failure:
/// the palace has no drawers, and the output says so.
/// Test: `verdict_fails_on_error_or_unreadable_rows_and_passes_a_clean_set`.
pub fn scan_verdict(rows: &[PalaceSecretCounts]) -> Result<()> {
    let t = totals(rows);
    if t.errors == 0 && t.drawers_unreadable == 0 {
        return Ok(());
    }
    let partial = rows.iter().filter(|r| r.drawers_unreadable > 0).count();
    anyhow::bail!(
        "audit secrets: incomplete scan — {} palace(s) could not be read; \
         {} unreadable drawer row(s) in {partial} palace(s) were not screened",
        t.errors,
        t.drawers_unreadable,
    )
}

/// Render the counts as text (errors to `err`) or JSON (all to `out`).
///
/// What: text mode prints one `key=value` line per palace, carrying its
/// `store=` state and `unreadable=` count, and a total line;
/// JSON mode prints `{ "palaces": [...], "totals": {...} }`. Only counts,
/// palace ids and this module's own error messages are ever written.
/// Test: `output_and_tracing_carry_no_drawer_content`,
/// `json_output_is_counts_only`.
pub fn render(
    out: &mut dyn Write,
    err: &mut dyn Write,
    rows: &[PalaceSecretCounts],
    json: bool,
) -> Result<()> {
    let t = totals(rows);
    if json {
        let doc = serde_json::json!({ "palaces": rows, "totals": t });
        writeln!(out, "{}", serde_json::to_string_pretty(&doc)?)?;
        return Ok(());
    }
    writeln!(
        out,
        "audit secrets: COUNT ONLY — read-only scan of a private copy of each palace store; \
         no drawer text or token preview is printed"
    )?;
    for r in rows {
        if let Some(e) = &r.error {
            writeln!(err, "[error] palace={} error={e}", r.palace)?;
            continue;
        }
        let v = &r.by_variant;
        writeln!(
            out,
            "palace={} store={} scanned={} unreadable={} refused={} potential_secret={} \
             too_short={} noise_pattern={} non_alphabetic={} key_value_first={} \
             key_value_only={}",
            r.palace,
            r.store.as_str(),
            r.drawers_scanned,
            r.drawers_unreadable,
            r.drawers_refused,
            v.potential_secret,
            v.too_short,
            v.noise_pattern,
            v.non_alphabetic,
            r.key_value_first,
            r.key_value_only,
        )?;
    }
    writeln!(
        out,
        "total: palaces={} absent={} scanned={} unreadable={} refused={} key_value_first={} \
         key_value_only={} errors={}",
        t.palaces,
        t.absent,
        t.drawers_scanned,
        t.drawers_unreadable,
        t.drawers_refused,
        t.key_value_first,
        t.key_value_only,
        t.errors
    )?;
    Ok(())
}

/// CLI entry point for `trusty-memory audit secrets --count-only`.
///
/// Why: a thin shim over [`scan_palaces`] and [`render`], matching
/// `backfill-report`'s shape.
/// What: resolves the data root, sweeps store copies a dead run left in the
/// system temp dir, then scans through [`scan_until_interrupted`] with Ctrl-C
/// as the interrupt. Renders, then exits through [`scan_verdict`], so a partial
/// scan never passes as a complete one.
/// Test: not unit-tested (process-level entry point); `scan_until_interrupted`,
/// `render`, `scan_verdict` and `sweep_stale_copies` are the testable surfaces
/// (`sweep_removes_only_stale_scratch_dirs`).
pub async fn handle_audit_secrets(opts: AuditSecretsOptions) -> Result<()> {
    let data_dir = trusty_common::resolve_data_dir("trusty-memory")
        .context("resolve trusty-memory data dir")?;
    let registry_dir = crate::resolve_palace_registry_dir(data_dir);
    let tmp = std::env::temp_dir();
    // #8645: copies hold drawers in plaintext; remove any a killed run left.
    sweep_stale_copies(&tmp, STALE_COPY_AGE);
    let run_dir = tempfile::TempDir::with_prefix_in(SCRATCH_PREFIX, &tmp)
        .context("create scratch dir for the audit run")?;
    let rows = scan_until_interrupted(
        registry_dir,
        opts.palace.clone(),
        run_dir,
        tokio::signal::ctrl_c(),
    )
    .await?;
    render(
        &mut std::io::stdout().lock(),
        &mut std::io::stderr().lock(),
        &rows,
        opts.json,
    )?;
    scan_verdict(&rows)
}

/// Run [`scan_palaces`] on a blocking thread until it finishes or `interrupt`
/// fires.
///
/// Why: #8645 — each store copy holds drawers in plaintext. On Ctrl-C the scan
/// must stop copying, and the copies must be gone before the process exits.
/// The runtime waits for a running blocking task at shutdown, so an
/// uncancelled worker would hold the exit until every palace was scanned.
/// What: every copy lives under `run_dir`. If `interrupt` resolves `Ok` first,
/// sets the stop flag, waits for the worker (it finishes at most the in-flight
/// palace), removes `run_dir`, and returns an error that says whether the
/// removal succeeded. An `Err` from `interrupt` — the handler could not be
/// registered — disables that arm and the scan runs to completion.
/// Test: `interrupt_stops_the_scan_and_removes_the_run_dir`,
/// `failed_interrupt_registration_lets_the_scan_finish`.
async fn scan_until_interrupted(
    registry_dir: PathBuf,
    palace: Option<String>,
    run_dir: tempfile::TempDir,
    interrupt: impl Future<Output = std::io::Result<()>>,
) -> Result<Vec<PalaceSecretCounts>> {
    let stop = Arc::new(AtomicBool::new(false));
    let worker_stop = Arc::clone(&stop);
    let run_path = run_dir.path().to_path_buf();
    let mut scan = tokio::task::spawn_blocking(move || {
        let stopped = || worker_stop.load(Ordering::Relaxed);
        scan_palaces(&registry_dir, palace.as_deref(), &run_path, &stopped)
    });
    tokio::select! {
        biased;
        Ok(()) = interrupt => {}
        joined = &mut scan => return joined.context("audit scan task")?,
    }
    stop.store(true, Ordering::Relaxed);
    // #8645: the worker's result is moot; waiting means no copy is in flight
    // when the run dir goes.
    let _ = scan.await;
    let dir = run_dir.path().display().to_string();
    match run_dir.close() {
        Ok(()) => {
            anyhow::bail!("audit secrets: interrupted — store copies deleted, no counts reported")
        }
        Err(e) => anyhow::bail!(
            "audit secrets: interrupted — could not delete store copies under {dir}: {e}; \
             no counts reported"
        ),
    }
}

#[cfg(test)]
#[path = "audit_secrets_tests.rs"]
mod tests;