Skip to main content

capsule_runtime/
murmur_home.rs

1//! The murmur home, `~/.murmur`, and the modes of what it holds.
2//!
3//! Every writer under the home holds `~/.murmur` itself at [`MURMUR_HOME_DIR_MODE`] before it
4//! creates anything beneath it, so a store directory is never reachable through a home the umask
5//! left `0755`. Files that hold a secret go through [`write_private_file`], which lands them at
6//! [`PRIVATE_FILE_MODE`] by rename.
7//!
8//! [`audit_murmur_home`] is the read-only half: it reports the mode of every entry and every wider
9//! than expected path under an owner-only one, for `mur doctor`. It reads metadata only, never a
10//! file's contents, and changes nothing.
11
12use std::path::{Path, PathBuf};
13
14use murmur_artifact::{security_warning_link, W_SEC_028};
15
16/// Mode `~/.murmur` and every owner-only directory beneath it are held at.
17pub const MURMUR_HOME_DIR_MODE: u32 = 0o700;
18
19/// Mode every owner-only file under `~/.murmur` is held at.
20pub const PRIVATE_FILE_MODE: u32 = 0o600;
21
22/// The most wide descendants one entry's report lists; the rest are counted in
23/// `HomeEntryState::Present::wide_descendants_omitted`.
24const WIDE_DESCENDANT_CAP: usize = 20;
25
26/// Resolve `~/.murmur`, create it at [`MURMUR_HOME_DIR_MODE`] if missing, and strip every group
27/// and other bit from it whether or not this call created it.
28///
29/// An existing home keeps the owner bits it has: a home the owner closed to `0500` stays closed,
30/// so every write beneath it fails and reports, rather than the runtime reopening it.
31///
32/// Errors when `HOME` is unset or relative, when the path is not a directory, or when the
33/// directory cannot be created or its mode set. Each caller wraps the reason in its own error
34/// type.
35pub fn ensure_murmur_home() -> Result<PathBuf, String> {
36    use std::os::unix::fs::PermissionsExt;
37
38    let home = crate::state_store::murmur_home_dir()?;
39    let located = |reason: String| format!("{}: {reason}", home.display());
40    match std::fs::metadata(&home) {
41        Ok(metadata) if !metadata.is_dir() => return Err(located("not a directory".to_string())),
42        Ok(metadata) => {
43            let mode = metadata.permissions().mode() & 0o777;
44            let narrowed = mode & MURMUR_HOME_DIR_MODE;
45            if narrowed != mode {
46                std::fs::set_permissions(&home, std::fs::Permissions::from_mode(narrowed))
47                    .map_err(|err| located(format!("failed to set mode {narrowed:04o}: {err}")))?;
48            }
49        }
50        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
51            hold_private_dir(&home).map_err(located)?;
52        }
53        Err(err) => return Err(located(err.to_string())),
54    }
55    Ok(home)
56}
57
58/// Create `path` if missing and hold it at [`MURMUR_HOME_DIR_MODE`]. Only `path` itself gets the
59/// mode; a missing ancestor is created under the umask, so callers hold each level oldest first.
60pub fn hold_private_dir(path: &Path) -> Result<(), String> {
61    crate::state_store::ensure_private_dir(path, MURMUR_HOME_DIR_MODE)
62}
63
64/// Replace `target` with `contents` at [`PRIVATE_FILE_MODE`].
65///
66/// Staged beside `target` and renamed over it, so a reader sees the old file or the new one, and
67/// the inode changes on every write — which is how a running capsule's credential stamp notices a
68/// `mur config set -g`. The mode is set on the staged file before the rename, so the new contents
69/// are never visible at a wider mode. The parent directory must already exist.
70pub fn write_private_file(target: &Path, contents: &[u8]) -> Result<(), String> {
71    crate::retention::StagedRewrite::stage_with_mode(target, contents, Some(PRIVATE_FILE_MODE))?
72        .commit()
73}
74
75/// The identity and last change of a file, as `fstat(2)` reports them on an open handle: device,
76/// inode, size, owner, mode, mtime and ctime.
77///
78/// The kernel sets ctime to the current time on every write, truncate, chmod, chown and rename of
79/// the inode, and nothing without `CAP_SYS_TIME` can set it back, so a file changed or replaced
80/// since its stamp was taken has a different stamp even when its mtime was restored. Where ctime
81/// has timer-tick resolution, as on Linux before 6.13, a same-size write within one tick of the
82/// stamp can keep it.
83///
84/// Its `Display` form is recorded on disk beside each compiled form, so changing the format makes
85/// every recorded stamp miss once.
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub(crate) struct FileStamp {
88    dev: u64,
89    ino: u64,
90    len: u64,
91    uid: u32,
92    pub(crate) mode: u32,
93    mtime: (i64, i64),
94    ctime: (i64, i64),
95}
96
97impl FileStamp {
98    pub(crate) fn of(metadata: &std::fs::Metadata) -> Self {
99        use std::os::unix::fs::MetadataExt;
100
101        Self {
102            dev: metadata.dev(),
103            ino: metadata.ino(),
104            len: metadata.len(),
105            uid: metadata.uid(),
106            mode: metadata.mode(),
107            mtime: (metadata.mtime(), metadata.mtime_nsec()),
108            ctime: (metadata.ctime(), metadata.ctime_nsec()),
109        }
110    }
111}
112
113impl std::fmt::Display for FileStamp {
114    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
115        write!(
116            f,
117            "{} {} {} {} {:o} {}.{:09} {}.{:09}",
118            self.dev,
119            self.ino,
120            self.len,
121            self.uid,
122            self.mode,
123            self.mtime.0,
124            self.mtime.1,
125            self.ctime.0,
126            self.ctime.1,
127        )
128    }
129}
130
131/// Whether `mode` grants any permission bit `allowed` does not.
132pub fn is_wider_than(mode: u32, allowed: u32) -> bool {
133    (mode & 0o777) & !allowed != 0
134}
135
136/// What one top-level entry of `~/.murmur` is.
137#[derive(Debug, Clone, PartialEq, Eq)]
138pub enum HomeEntryKind {
139    Home,
140    Config,
141    DeployKeys,
142    DeployStaging,
143    Deployments,
144    Spend,
145    Conversations,
146    Running,
147    State,
148    Artifacts,
149    CompiledForms,
150    BinaryCache,
151    Formations,
152    /// A top-level name this build writes nothing under.
153    Unrecognised(String),
154}
155
156impl HomeEntryKind {
157    /// Every kind with a fixed name, in the order the audit reports them after the home itself.
158    const KNOWN: [HomeEntryKind; 12] = [
159        Self::Config,
160        Self::DeployKeys,
161        Self::DeployStaging,
162        Self::Deployments,
163        Self::Spend,
164        Self::Conversations,
165        Self::Running,
166        Self::State,
167        Self::Artifacts,
168        Self::CompiledForms,
169        Self::BinaryCache,
170        Self::Formations,
171    ];
172
173    /// The entry's name under `~/.murmur`, or `None` for the home itself.
174    pub fn file_name(&self) -> Option<&str> {
175        Some(match self {
176            Self::Home => return None,
177            Self::Config => "config.yaml",
178            Self::DeployKeys => "deploy_keys",
179            Self::DeployStaging => "deploy_staging",
180            Self::Deployments => "deployments.json",
181            Self::Spend => "spend",
182            Self::Conversations => "conversations",
183            Self::Running => "running",
184            Self::State => "state",
185            Self::Artifacts => "artifacts",
186            Self::CompiledForms => "compiled",
187            Self::BinaryCache => "bin",
188            Self::Formations => crate::formation_launch::FORMATIONS_DIR,
189            Self::Unrecognised(name) => name,
190        })
191    }
192
193    /// What the entry holds, as a noun phrase that completes "`<path>` holds …".
194    pub fn holds(&self) -> &'static str {
195        match self {
196            Self::Home => "the murmur home",
197            Self::Config => "provider credentials",
198            Self::DeployKeys => "SSH private keys",
199            Self::DeployStaging => "deployment staging copies",
200            Self::Deployments => "deployment records",
201            Self::Spend => "the machine spend ledger",
202            Self::Conversations => "conversation records",
203            Self::Running => "running-capsule records",
204            Self::State => "capsule state stores",
205            Self::Artifacts => "installed artifacts",
206            Self::CompiledForms => "compiled WASM cache",
207            Self::BinaryCache => "cached mur binaries",
208            Self::Formations => "formation members' working directories",
209            Self::Unrecognised(_) => "something not recognised by this build",
210        }
211    }
212
213    /// How long what the entry holds is kept, as a clause that follows [`Self::holds`] after a
214    /// comma, or `None` for an entry `mur` never removes on its own.
215    pub fn kept(&self) -> Option<&'static str> {
216        match self {
217            Self::Formations => Some(
218                "each kept until its formation has ended and the entry member's trace.retain \
219                 expires it",
220            ),
221            _ => None,
222        }
223    }
224
225    /// Whether deleting the entry loses nothing, because `mur` writes it again when it needs it.
226    ///
227    /// `formations/` is not: deleting an ended formation's directory stops nothing, but its peers'
228    /// traces go with it and are never written again.
229    pub fn safe_to_delete(&self) -> bool {
230        matches!(self, Self::CompiledForms)
231    }
232
233    /// Whether the entry, and everything beneath it down to [`Self::private_depth`], is expected
234    /// to be owner-only.
235    pub fn expected_private(&self) -> bool {
236        !matches!(
237            self,
238            Self::Artifacts | Self::BinaryCache | Self::Unrecognised(_)
239        )
240    }
241
242    /// How many directory levels below the entry the audit holds owner-only, or `None` for every
243    /// level.
244    ///
245    /// `formations/<frm_id>/<member>/` is held at two: the launcher makes those directories
246    /// owner-only, and below them is a member's ordinary working directory, whose sessions are
247    /// written at the umask's modes like any `--workdir`. The owner-only member directory is what
248    /// keeps them from other accounts, so walking further reports every file a session writes.
249    pub fn private_depth(&self) -> Option<usize> {
250        match self {
251            Self::Formations => Some(2),
252            _ => None,
253        }
254    }
255}
256
257/// A path under an owner-only entry whose mode grants more than it should.
258#[derive(Debug, Clone, PartialEq, Eq)]
259pub struct WideEntry {
260    pub path: PathBuf,
261    /// Permission bits only, `& 0o777`.
262    pub mode: u32,
263    /// [`MURMUR_HOME_DIR_MODE`] for a directory, [`PRIVATE_FILE_MODE`] for a file.
264    pub allowed: u32,
265}
266
267#[derive(Debug, Clone, PartialEq, Eq)]
268pub enum HomeEntryState {
269    Absent,
270    Present {
271        /// Permission bits only, `& 0o777`, from `lstat`.
272        mode: u32,
273        /// Whether the entry itself is wider than its kind allows. Always `false` for a kind that
274        /// is not [`HomeEntryKind::expected_private`], and for a symlink.
275        wide: bool,
276        /// Wide paths beneath an owner-only directory, depth first, at most
277        /// [`WIDE_DESCENDANT_CAP`].
278        wide_descendants: Vec<WideEntry>,
279        /// Wide paths found beyond the cap.
280        wide_descendants_omitted: usize,
281    },
282    /// `lstat` failed for a reason other than the entry not existing.
283    Unreadable {
284        error: String,
285    },
286}
287
288#[derive(Debug, Clone, PartialEq, Eq)]
289pub struct HomeEntryReport {
290    pub path: PathBuf,
291    pub kind: HomeEntryKind,
292    pub state: HomeEntryState,
293}
294
295impl HomeEntryReport {
296    /// The mode this entry is held to: [`MURMUR_HOME_DIR_MODE`] for a directory,
297    /// [`PRIVATE_FILE_MODE`] otherwise.
298    pub fn allowed(&self) -> u32 {
299        allowed_for(&self.path)
300    }
301}
302
303/// Report `murmur_home` and every entry in it.
304///
305/// The home first, then every [`HomeEntryKind`] with a fixed name, absent ones included, then
306/// every other top-level name, sorted. Owner-only directories are walked without following
307/// symlinks; entries that are not expected to be owner-only get their mode and nothing else.
308/// Reads metadata only and changes nothing on disk.
309pub fn audit_murmur_home(murmur_home: &Path) -> Vec<HomeEntryReport> {
310    let mut reports = vec![report_entry(murmur_home.to_path_buf(), HomeEntryKind::Home)];
311
312    for kind in HomeEntryKind::KNOWN {
313        let name = kind.file_name().expect("a known kind has a fixed name");
314        reports.push(report_entry(murmur_home.join(name), kind));
315    }
316
317    let mut others: Vec<String> = std::fs::read_dir(murmur_home)
318        .map(|entries| {
319            entries
320                .filter_map(Result::ok)
321                .map(|entry| entry.file_name().to_string_lossy().into_owned())
322                .filter(|name| {
323                    !HomeEntryKind::KNOWN
324                        .iter()
325                        .any(|kind| kind.file_name() == Some(name.as_str()))
326                })
327                .collect()
328        })
329        .unwrap_or_default();
330    others.sort();
331    for name in others {
332        reports.push(report_entry(
333            murmur_home.join(&name),
334            HomeEntryKind::Unrecognised(name),
335        ));
336    }
337
338    reports
339}
340
341fn report_entry(path: PathBuf, kind: HomeEntryKind) -> HomeEntryReport {
342    use std::os::unix::fs::PermissionsExt;
343
344    let metadata = match std::fs::symlink_metadata(&path) {
345        Ok(metadata) => metadata,
346        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
347            return HomeEntryReport {
348                path,
349                kind,
350                state: HomeEntryState::Absent,
351            }
352        }
353        Err(err) => {
354            return HomeEntryReport {
355                path,
356                kind,
357                state: HomeEntryState::Unreadable {
358                    error: err.to_string(),
359                },
360            }
361        }
362    };
363    let mode = metadata.permissions().mode() & 0o777;
364    let private = kind.expected_private();
365    let file_type = metadata.file_type();
366    let wide =
367        private && !file_type.is_symlink() && is_wider_than(mode, allowed_for_type(file_type));
368
369    let mut wide_descendants = Vec::new();
370    let mut omitted = 0;
371    // The home's own entries are reported one by one, so only an entry below it is walked.
372    if private && file_type.is_dir() && kind != HomeEntryKind::Home {
373        collect_wide_descendants(
374            &path,
375            kind.private_depth(),
376            &mut wide_descendants,
377            &mut omitted,
378        );
379    }
380
381    HomeEntryReport {
382        path,
383        kind,
384        state: HomeEntryState::Present {
385            mode,
386            wide,
387            wide_descendants,
388            wide_descendants_omitted: omitted,
389        },
390    }
391}
392
393/// Wide paths beneath `dir`, `depth` levels down, or every level for `None`.
394fn collect_wide_descendants(
395    dir: &Path,
396    depth: Option<usize>,
397    found: &mut Vec<WideEntry>,
398    omitted: &mut usize,
399) {
400    use std::os::unix::fs::PermissionsExt;
401
402    if depth == Some(0) {
403        return;
404    }
405    let Ok(entries) = std::fs::read_dir(dir) else {
406        return;
407    };
408    let mut paths: Vec<PathBuf> = entries.filter_map(Result::ok).map(|e| e.path()).collect();
409    paths.sort();
410    for path in paths {
411        let Ok(metadata) = std::fs::symlink_metadata(&path) else {
412            continue;
413        };
414        let file_type = metadata.file_type();
415        if file_type.is_symlink() || !(file_type.is_dir() || file_type.is_file()) {
416            continue;
417        }
418        let mode = metadata.permissions().mode() & 0o777;
419        let allowed = allowed_for_type(file_type);
420        if is_wider_than(mode, allowed) {
421            if found.len() < WIDE_DESCENDANT_CAP {
422                found.push(WideEntry {
423                    path: path.clone(),
424                    mode,
425                    allowed,
426                });
427            } else {
428                *omitted += 1;
429            }
430        }
431        if file_type.is_dir() {
432            collect_wide_descendants(&path, depth.map(|depth| depth - 1), found, omitted);
433        }
434    }
435}
436
437fn allowed_for_type(file_type: std::fs::FileType) -> u32 {
438    if file_type.is_dir() {
439        MURMUR_HOME_DIR_MODE
440    } else {
441        PRIVATE_FILE_MODE
442    }
443}
444
445fn allowed_for(path: &Path) -> u32 {
446    match std::fs::symlink_metadata(path) {
447        Ok(metadata) => allowed_for_type(metadata.file_type()),
448        Err(_) => PRIVATE_FILE_MODE,
449    }
450}
451
452/// The `W-SEC-028` line for a path under `~/.murmur` that holds `holds` and is wider than
453/// `allowed`. Names the path and its mode, never its contents.
454pub fn wide_entry_warning(path: &Path, holds: &str, mode: u32, allowed: u32) -> String {
455    let path = path.display();
456    format!(
457        "[capsule-runtime] warning[{W_SEC_028}]: {path} holds {holds} and is mode {:04o}, which \
458         other accounts on this host can read; run `chmod {:o} {path}` ({})",
459        mode & 0o777,
460        allowed,
461        security_warning_link(W_SEC_028)
462    )
463}
464
465/// Print `W-SEC-028` when the config file an inference credential was just read from grants any
466/// group or other bit. Never a refusal, and never names the value.
467pub fn warn_on_wide_credential_file(path: &Path, name: &str, mode: u32) {
468    if let Some(warning) = wide_credential_file_warning(path, name, mode) {
469        crate::runtime_err!("{warning}");
470    }
471}
472
473fn wide_credential_file_warning(path: &Path, name: &str, mode: u32) -> Option<String> {
474    if !is_wider_than(mode, PRIVATE_FILE_MODE) {
475        return None;
476    }
477    let path = path.display();
478    Some(format!(
479        "[capsule-runtime] warning[{W_SEC_028}]: the credential credentials.{name} is \
480         read from {path}, which is mode {:04o} and readable by other accounts on this host; run \
481         `chmod 600 {path}` ({})",
482        mode & 0o777,
483        security_warning_link(W_SEC_028)
484    ))
485}
486
487/// Runs one `#[ignore]`d test of this crate in a child process with `HOME` set to `home`.
488///
489/// `HOME` is process-global, and every staging test in this binary resolves `~/.murmur` from it,
490/// so a test that needs a scratch home gets its own process rather than a lock no other test
491/// takes. The child test returns without asserting unless [`INNER_HOME_ENV`] is set.
492#[cfg(test)]
493pub(crate) fn run_with_home(test: &str, home: &Path) {
494    let exe = std::env::current_exe().expect("test binary path");
495    let output = std::process::Command::new(exe)
496        .args([
497            test,
498            "--exact",
499            "--ignored",
500            "--test-threads=1",
501            "--nocapture",
502        ])
503        .env("HOME", home)
504        .env(INNER_HOME_ENV, "1")
505        .output()
506        .expect("re-exec the test binary with a scratch HOME");
507    let stdout = String::from_utf8_lossy(&output.stdout);
508    let stderr = String::from_utf8_lossy(&output.stderr);
509    assert!(
510        output.status.success(),
511        "{test} failed\n--- stdout ---\n{stdout}\n--- stderr ---\n{stderr}"
512    );
513    assert!(
514        stdout.contains("1 passed"),
515        "{test} was not selected\n--- stdout ---\n{stdout}"
516    );
517}
518
519/// Set only in the child [`run_with_home`] starts.
520#[cfg(test)]
521pub(crate) const INNER_HOME_ENV: &str = "MURMUR_TEST_INNER_HOME";
522
523/// Whether this process is a [`run_with_home`] child.
524#[cfg(test)]
525pub(crate) fn in_scratch_home() -> bool {
526    std::env::var_os(INNER_HOME_ENV).is_some()
527}
528
529/// `path`'s permission bits.
530#[cfg(test)]
531pub(crate) fn mode_of(path: &Path) -> u32 {
532    use std::os::unix::fs::PermissionsExt;
533    std::fs::symlink_metadata(path)
534        .unwrap_or_else(|err| panic!("{}: {err}", path.display()))
535        .permissions()
536        .mode()
537        & 0o777
538}
539
540/// Creates `path` as a directory at `mode`.
541#[cfg(test)]
542pub(crate) fn wide_dir(path: &Path, mode: u32) {
543    use std::os::unix::fs::PermissionsExt;
544    std::fs::create_dir_all(path).unwrap();
545    std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).unwrap();
546}
547
548/// Writes `path` with `contents` at `mode`.
549#[cfg(test)]
550pub(crate) fn wide_file(path: &Path, contents: &str, mode: u32) {
551    use std::os::unix::fs::PermissionsExt;
552    std::fs::write(path, contents).unwrap();
553    std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).unwrap();
554}
555
556#[cfg(test)]
557mod tests {
558    use super::*;
559
560    #[test]
561    fn murmur_home_is_wider_than_truth_table() {
562        for (mode, allowed, wide) in [
563            (0o600, 0o600, false),
564            (0o644, 0o600, true),
565            (0o640, 0o600, true),
566            (0o700, 0o700, false),
567            (0o711, 0o700, true),
568            (0o400, 0o600, false),
569        ] {
570            assert_eq!(
571                is_wider_than(mode, allowed),
572                wide,
573                "{mode:04o} against {allowed:04o}"
574            );
575        }
576    }
577
578    #[test]
579    fn ensure_murmur_home_creates_and_tightens_the_home_directory() {
580        let wide = tempfile::tempdir().unwrap();
581        wide_dir(&wide.path().join(".murmur"), 0o755);
582        run_with_home(
583            "murmur_home::tests::inner_ensure_murmur_home_holds_the_home_at_0700",
584            wide.path(),
585        );
586        assert_eq!(mode_of(&wide.path().join(".murmur")), 0o700);
587
588        let missing = tempfile::tempdir().unwrap();
589        run_with_home(
590            "murmur_home::tests::inner_ensure_murmur_home_holds_the_home_at_0700",
591            missing.path(),
592        );
593        assert_eq!(mode_of(&missing.path().join(".murmur")), 0o700);
594
595        let closed = tempfile::tempdir().unwrap();
596        wide_dir(&closed.path().join(".murmur"), 0o555);
597        run_with_home(
598            "murmur_home::tests::inner_ensure_murmur_home_holds_the_home_at_0700",
599            closed.path(),
600        );
601        assert_eq!(
602            mode_of(&closed.path().join(".murmur")),
603            0o500,
604            "an owner-closed home loses group and other bits and is not reopened"
605        );
606    }
607
608    #[test]
609    #[ignore = "run by ensure_murmur_home_creates_and_tightens_the_home_directory"]
610    fn inner_ensure_murmur_home_holds_the_home_at_0700() {
611        if !in_scratch_home() {
612            return;
613        }
614        let home = ensure_murmur_home().unwrap();
615        assert!(home.ends_with(".murmur"));
616        assert_eq!(mode_of(&home) & 0o077, 0);
617    }
618
619    #[test]
620    fn murmur_home_write_private_file_replaces_a_wide_file_owner_only() {
621        use std::os::unix::fs::MetadataExt;
622        let dir = tempfile::tempdir().unwrap();
623        let path = dir.path().join("config.yaml");
624        wide_file(&path, "old", 0o644);
625        let before = std::fs::metadata(&path).unwrap().ino();
626
627        write_private_file(&path, b"new").unwrap();
628
629        assert_eq!(std::fs::read_to_string(&path).unwrap(), "new");
630        assert_eq!(mode_of(&path), 0o600);
631        assert_ne!(std::fs::metadata(&path).unwrap().ino(), before);
632    }
633
634    #[test]
635    fn murmur_home_audit_reports_absent_entries_in_kind_order() {
636        let dir = tempfile::tempdir().unwrap();
637        let home = dir.path().join(".murmur");
638
639        let reports = audit_murmur_home(&home);
640
641        assert_eq!(reports.len(), 13);
642        assert_eq!(reports[0].kind, HomeEntryKind::Home);
643        assert_eq!(reports[1].kind, HomeEntryKind::Config);
644        assert_eq!(reports[10].kind, HomeEntryKind::CompiledForms);
645        assert_eq!(reports[11].kind, HomeEntryKind::BinaryCache);
646        assert_eq!(reports[12].kind, HomeEntryKind::Formations);
647        assert!(reports
648            .iter()
649            .all(|report| report.state == HomeEntryState::Absent));
650    }
651
652    /// Only `formations/` says how long it is kept, and only `compiled/` is safe to delete: a
653    /// formation directory is removed by retention, and what it holds is never written again.
654    #[test]
655    fn murmur_home_kinds_say_how_formations_are_kept_and_only_compiled_is_safe_to_delete() {
656        let every = HomeEntryKind::KNOWN
657            .into_iter()
658            .chain([HomeEntryKind::Home, HomeEntryKind::Unrecognised("x".into())]);
659        for kind in every {
660            assert_eq!(
661                kind.kept().is_some(),
662                kind == HomeEntryKind::Formations,
663                "{kind:?}"
664            );
665            assert_eq!(
666                kind.safe_to_delete(),
667                kind == HomeEntryKind::CompiledForms,
668                "{kind:?}"
669            );
670        }
671        assert_eq!(
672            HomeEntryKind::Formations.kept(),
673            Some(
674                "each kept until its formation has ended and the entry member's trace.retain \
675                 expires it"
676            )
677        );
678    }
679
680    /// `formations/` holds members' working directories: known, owner-only down to each member's
681    /// directory, and never offered for deletion. What a member's sessions write beneath its
682    /// directory, at the umask's modes, is not reported.
683    #[test]
684    fn murmur_home_audit_knows_formations_as_owner_only() {
685        let dir = tempfile::tempdir().unwrap();
686        let home = dir.path().join(".murmur");
687        wide_dir(&home, 0o700);
688        wide_dir(&home.join("formations"), 0o700);
689        wide_dir(&home.join("formations/frm_x"), 0o700);
690        wide_dir(&home.join("formations/frm_x/worker"), 0o755);
691        wide_dir(&home.join("formations/frm_x/worker/.murmur/ses_1"), 0o775);
692        wide_file(
693            &home.join("formations/frm_x/worker/.murmur/ses_1/trace.jsonl"),
694            "{}\n",
695            0o664,
696        );
697        wide_file(&home.join("formations/frm_x/worker/task.md"), "t", 0o664);
698
699        let report = audit_murmur_home(&home)
700            .into_iter()
701            .find(|report| report.kind == HomeEntryKind::Formations)
702            .unwrap();
703        assert_eq!(report.path, home.join("formations"));
704        assert_eq!(
705            report.kind.holds(),
706            "formation members' working directories"
707        );
708        assert!(report.kind.expected_private());
709        assert!(!report.kind.safe_to_delete());
710        match report.state {
711            HomeEntryState::Present {
712                wide,
713                wide_descendants,
714                ..
715            } => {
716                assert!(!wide);
717                assert_eq!(wide_descendants.len(), 1);
718                assert!(wide_descendants[0].path.ends_with("frm_x/worker"));
719            }
720            other => panic!("{other:?}"),
721        }
722    }
723
724    #[test]
725    fn murmur_home_audit_flags_wide_private_entries_and_never_non_private_ones() {
726        let dir = tempfile::tempdir().unwrap();
727        let home = dir.path().join(".murmur");
728        wide_dir(&home, 0o755);
729        wide_file(&home.join("config.yaml"), "credentials: {}\n", 0o644);
730        wide_dir(&home.join("artifacts"), 0o777);
731        wide_file(&home.join("artifacts/loose"), "x", 0o666);
732        wide_dir(&home.join("spend"), 0o700);
733        wide_file(&home.join("spend/2026-09-13.jsonl"), "", 0o600);
734        wide_file(&home.join("nexus-config.json"), "{}", 0o644);
735
736        let reports = audit_murmur_home(&home);
737        let find = |kind: &HomeEntryKind| {
738            reports
739                .iter()
740                .find(|report| &report.kind == kind)
741                .unwrap()
742                .state
743                .clone()
744        };
745        let wide = |state: HomeEntryState| match state {
746            HomeEntryState::Present {
747                wide,
748                wide_descendants,
749                ..
750            } => (wide, wide_descendants.len()),
751            other => panic!("{other:?}"),
752        };
753
754        assert_eq!(wide(find(&HomeEntryKind::Home)), (true, 0));
755        assert_eq!(wide(find(&HomeEntryKind::Config)), (true, 0));
756        assert_eq!(wide(find(&HomeEntryKind::Artifacts)), (false, 0));
757        assert_eq!(wide(find(&HomeEntryKind::Spend)), (false, 0));
758        let last = reports.last().unwrap();
759        assert_eq!(
760            last.kind,
761            HomeEntryKind::Unrecognised("nexus-config.json".to_string())
762        );
763        assert_eq!(wide(last.state.clone()), (false, 0));
764        assert_eq!(mode_of(&home), 0o755, "the audit changes nothing");
765    }
766
767    #[test]
768    fn murmur_home_audit_does_not_follow_symlinks() {
769        let dir = tempfile::tempdir().unwrap();
770        let home = dir.path().join(".murmur");
771        wide_dir(&home.join("state"), 0o700);
772        let outside = dir.path().join("outside");
773        wide_dir(&outside, 0o777);
774        wide_file(&outside.join("secret"), "x", 0o666);
775        std::os::unix::fs::symlink(&outside, home.join("state/link")).unwrap();
776
777        let reports = audit_murmur_home(&home);
778        let state = &reports
779            .iter()
780            .find(|report| report.kind == HomeEntryKind::State)
781            .unwrap()
782            .state;
783        assert!(
784            matches!(state, HomeEntryState::Present { wide: false, wide_descendants, .. } if wide_descendants.is_empty()),
785            "{state:?}"
786        );
787    }
788
789    #[test]
790    fn murmur_home_audit_caps_wide_descendants_and_counts_the_rest() {
791        let dir = tempfile::tempdir().unwrap();
792        let home = dir.path().join(".murmur");
793        let keys = home.join("deploy_keys");
794        wide_dir(&keys, 0o700);
795        for index in 0..25 {
796            wide_file(&keys.join(format!("key_{index:02}")), "x", 0o644);
797        }
798
799        let reports = audit_murmur_home(&home);
800        let state = &reports
801            .iter()
802            .find(|report| report.kind == HomeEntryKind::DeployKeys)
803            .unwrap()
804            .state;
805        match state {
806            HomeEntryState::Present {
807                wide,
808                wide_descendants,
809                wide_descendants_omitted,
810                ..
811            } => {
812                assert!(!wide);
813                assert_eq!(wide_descendants.len(), WIDE_DESCENDANT_CAP);
814                assert_eq!(*wide_descendants_omitted, 5);
815                assert!(wide_descendants
816                    .iter()
817                    .all(|entry| entry.mode == 0o644 && entry.allowed == 0o600));
818            }
819            other => panic!("{other:?}"),
820        }
821    }
822
823    #[test]
824    fn murmur_home_wide_credential_file_warning_names_the_path_and_mode_only() {
825        let path = Path::new("/home/someone/.murmur/config.yaml");
826        assert_eq!(
827            wide_credential_file_warning(path, "PROVIDER_KEY", 0o600),
828            None
829        );
830        assert_eq!(
831            wide_credential_file_warning(path, "PROVIDER_KEY", 0o100600),
832            None
833        );
834
835        let warning = wide_credential_file_warning(path, "PROVIDER_KEY", 0o100644).unwrap();
836        assert!(warning.contains("warning[W-SEC-028]"), "{warning}");
837        assert!(warning.contains("credentials.PROVIDER_KEY"), "{warning}");
838        assert!(
839            warning.contains("/home/someone/.murmur/config.yaml"),
840            "{warning}"
841        );
842        assert!(warning.contains("mode 0644"), "{warning}");
843        assert!(
844            warning.contains("`chmod 600 /home/someone/.murmur/config.yaml`"),
845            "{warning}"
846        );
847        assert!(warning.ends_with("#w-sec-028)"), "{warning}");
848    }
849
850    #[test]
851    fn murmur_home_wide_entry_warning_prints_the_chmod_for_the_allowed_mode() {
852        let warning = wide_entry_warning(Path::new("/h/.murmur"), "the murmur home", 0o755, 0o700);
853        assert_eq!(
854            warning,
855            "[capsule-runtime] warning[W-SEC-028]: /h/.murmur holds the murmur home and is mode \
856             0755, which other accounts on this host can read; run `chmod 700 /h/.murmur` \
857             (https://docs.murmur.nexus/reference/diagnostics/#w-sec-028)"
858        );
859    }
860}