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    /// A top-level name this build writes nothing under.
152    Unrecognised(String),
153}
154
155impl HomeEntryKind {
156    /// Every kind with a fixed name, in the order the audit reports them after the home itself.
157    const KNOWN: [HomeEntryKind; 11] = [
158        Self::Config,
159        Self::DeployKeys,
160        Self::DeployStaging,
161        Self::Deployments,
162        Self::Spend,
163        Self::Conversations,
164        Self::Running,
165        Self::State,
166        Self::Artifacts,
167        Self::CompiledForms,
168        Self::BinaryCache,
169    ];
170
171    /// The entry's name under `~/.murmur`, or `None` for the home itself.
172    pub fn file_name(&self) -> Option<&str> {
173        Some(match self {
174            Self::Home => return None,
175            Self::Config => "config.yaml",
176            Self::DeployKeys => "deploy_keys",
177            Self::DeployStaging => "deploy_staging",
178            Self::Deployments => "deployments.json",
179            Self::Spend => "spend",
180            Self::Conversations => "conversations",
181            Self::Running => "running",
182            Self::State => "state",
183            Self::Artifacts => "artifacts",
184            Self::CompiledForms => "compiled",
185            Self::BinaryCache => "bin",
186            Self::Unrecognised(name) => name,
187        })
188    }
189
190    /// What the entry holds, as a noun phrase that completes "`<path>` holds …".
191    pub fn holds(&self) -> &'static str {
192        match self {
193            Self::Home => "the murmur home",
194            Self::Config => "provider credentials",
195            Self::DeployKeys => "SSH private keys",
196            Self::DeployStaging => "deployment staging copies",
197            Self::Deployments => "deployment records",
198            Self::Spend => "the machine spend ledger",
199            Self::Conversations => "conversation records",
200            Self::Running => "running-capsule records",
201            Self::State => "capsule state stores",
202            Self::Artifacts => "installed artifacts",
203            Self::CompiledForms => "compiled WASM cache",
204            Self::BinaryCache => "cached mur binaries",
205            Self::Unrecognised(_) => "something not recognised by this build",
206        }
207    }
208
209    /// Whether deleting the entry loses nothing, because `mur` writes it again when it needs it.
210    pub fn safe_to_delete(&self) -> bool {
211        matches!(self, Self::CompiledForms)
212    }
213
214    /// Whether the entry, and everything beneath it, is expected to be owner-only.
215    pub fn expected_private(&self) -> bool {
216        !matches!(
217            self,
218            Self::Artifacts | Self::BinaryCache | Self::Unrecognised(_)
219        )
220    }
221}
222
223/// A path under an owner-only entry whose mode grants more than it should.
224#[derive(Debug, Clone, PartialEq, Eq)]
225pub struct WideEntry {
226    pub path: PathBuf,
227    /// Permission bits only, `& 0o777`.
228    pub mode: u32,
229    /// [`MURMUR_HOME_DIR_MODE`] for a directory, [`PRIVATE_FILE_MODE`] for a file.
230    pub allowed: u32,
231}
232
233#[derive(Debug, Clone, PartialEq, Eq)]
234pub enum HomeEntryState {
235    Absent,
236    Present {
237        /// Permission bits only, `& 0o777`, from `lstat`.
238        mode: u32,
239        /// Whether the entry itself is wider than its kind allows. Always `false` for a kind that
240        /// is not [`HomeEntryKind::expected_private`], and for a symlink.
241        wide: bool,
242        /// Wide paths beneath an owner-only directory, depth first, at most
243        /// [`WIDE_DESCENDANT_CAP`].
244        wide_descendants: Vec<WideEntry>,
245        /// Wide paths found beyond the cap.
246        wide_descendants_omitted: usize,
247    },
248    /// `lstat` failed for a reason other than the entry not existing.
249    Unreadable {
250        error: String,
251    },
252}
253
254#[derive(Debug, Clone, PartialEq, Eq)]
255pub struct HomeEntryReport {
256    pub path: PathBuf,
257    pub kind: HomeEntryKind,
258    pub state: HomeEntryState,
259}
260
261impl HomeEntryReport {
262    /// The mode this entry is held to: [`MURMUR_HOME_DIR_MODE`] for a directory,
263    /// [`PRIVATE_FILE_MODE`] otherwise.
264    pub fn allowed(&self) -> u32 {
265        allowed_for(&self.path)
266    }
267}
268
269/// Report `murmur_home` and every entry in it.
270///
271/// The home first, then every [`HomeEntryKind`] with a fixed name, absent ones included, then
272/// every other top-level name, sorted. Owner-only directories are walked without following
273/// symlinks; entries that are not expected to be owner-only get their mode and nothing else.
274/// Reads metadata only and changes nothing on disk.
275pub fn audit_murmur_home(murmur_home: &Path) -> Vec<HomeEntryReport> {
276    let mut reports = vec![report_entry(murmur_home.to_path_buf(), HomeEntryKind::Home)];
277
278    for kind in HomeEntryKind::KNOWN {
279        let name = kind.file_name().expect("a known kind has a fixed name");
280        reports.push(report_entry(murmur_home.join(name), kind));
281    }
282
283    let mut others: Vec<String> = std::fs::read_dir(murmur_home)
284        .map(|entries| {
285            entries
286                .filter_map(Result::ok)
287                .map(|entry| entry.file_name().to_string_lossy().into_owned())
288                .filter(|name| {
289                    !HomeEntryKind::KNOWN
290                        .iter()
291                        .any(|kind| kind.file_name() == Some(name.as_str()))
292                })
293                .collect()
294        })
295        .unwrap_or_default();
296    others.sort();
297    for name in others {
298        reports.push(report_entry(
299            murmur_home.join(&name),
300            HomeEntryKind::Unrecognised(name),
301        ));
302    }
303
304    reports
305}
306
307fn report_entry(path: PathBuf, kind: HomeEntryKind) -> HomeEntryReport {
308    use std::os::unix::fs::PermissionsExt;
309
310    let metadata = match std::fs::symlink_metadata(&path) {
311        Ok(metadata) => metadata,
312        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
313            return HomeEntryReport {
314                path,
315                kind,
316                state: HomeEntryState::Absent,
317            }
318        }
319        Err(err) => {
320            return HomeEntryReport {
321                path,
322                kind,
323                state: HomeEntryState::Unreadable {
324                    error: err.to_string(),
325                },
326            }
327        }
328    };
329    let mode = metadata.permissions().mode() & 0o777;
330    let private = kind.expected_private();
331    let file_type = metadata.file_type();
332    let wide =
333        private && !file_type.is_symlink() && is_wider_than(mode, allowed_for_type(file_type));
334
335    let mut wide_descendants = Vec::new();
336    let mut omitted = 0;
337    // The home's own entries are reported one by one, so only an entry below it is walked.
338    if private && file_type.is_dir() && kind != HomeEntryKind::Home {
339        collect_wide_descendants(&path, &mut wide_descendants, &mut omitted);
340    }
341
342    HomeEntryReport {
343        path,
344        kind,
345        state: HomeEntryState::Present {
346            mode,
347            wide,
348            wide_descendants,
349            wide_descendants_omitted: omitted,
350        },
351    }
352}
353
354fn collect_wide_descendants(dir: &Path, found: &mut Vec<WideEntry>, omitted: &mut usize) {
355    use std::os::unix::fs::PermissionsExt;
356
357    let Ok(entries) = std::fs::read_dir(dir) else {
358        return;
359    };
360    let mut paths: Vec<PathBuf> = entries.filter_map(Result::ok).map(|e| e.path()).collect();
361    paths.sort();
362    for path in paths {
363        let Ok(metadata) = std::fs::symlink_metadata(&path) else {
364            continue;
365        };
366        let file_type = metadata.file_type();
367        if file_type.is_symlink() || !(file_type.is_dir() || file_type.is_file()) {
368            continue;
369        }
370        let mode = metadata.permissions().mode() & 0o777;
371        let allowed = allowed_for_type(file_type);
372        if is_wider_than(mode, allowed) {
373            if found.len() < WIDE_DESCENDANT_CAP {
374                found.push(WideEntry {
375                    path: path.clone(),
376                    mode,
377                    allowed,
378                });
379            } else {
380                *omitted += 1;
381            }
382        }
383        if file_type.is_dir() {
384            collect_wide_descendants(&path, found, omitted);
385        }
386    }
387}
388
389fn allowed_for_type(file_type: std::fs::FileType) -> u32 {
390    if file_type.is_dir() {
391        MURMUR_HOME_DIR_MODE
392    } else {
393        PRIVATE_FILE_MODE
394    }
395}
396
397fn allowed_for(path: &Path) -> u32 {
398    match std::fs::symlink_metadata(path) {
399        Ok(metadata) => allowed_for_type(metadata.file_type()),
400        Err(_) => PRIVATE_FILE_MODE,
401    }
402}
403
404/// The `W-SEC-028` line for a path under `~/.murmur` that holds `holds` and is wider than
405/// `allowed`. Names the path and its mode, never its contents.
406pub fn wide_entry_warning(path: &Path, holds: &str, mode: u32, allowed: u32) -> String {
407    let path = path.display();
408    format!(
409        "[capsule-runtime] warning[{W_SEC_028}]: {path} holds {holds} and is mode {:04o}, which \
410         other accounts on this host can read; run `chmod {:o} {path}` ({})",
411        mode & 0o777,
412        allowed,
413        security_warning_link(W_SEC_028)
414    )
415}
416
417/// Print `W-SEC-028` when the config file an inference credential was just read from grants any
418/// group or other bit. Never a refusal, and never names the value.
419pub fn warn_on_wide_credential_file(path: &Path, name: &str, mode: u32) {
420    if let Some(warning) = wide_credential_file_warning(path, name, mode) {
421        crate::runtime_err!("{warning}");
422    }
423}
424
425fn wide_credential_file_warning(path: &Path, name: &str, mode: u32) -> Option<String> {
426    if !is_wider_than(mode, PRIVATE_FILE_MODE) {
427        return None;
428    }
429    let path = path.display();
430    Some(format!(
431        "[capsule-runtime] warning[{W_SEC_028}]: the credential credentials.{name} is \
432         read from {path}, which is mode {:04o} and readable by other accounts on this host; run \
433         `chmod 600 {path}` ({})",
434        mode & 0o777,
435        security_warning_link(W_SEC_028)
436    ))
437}
438
439/// Runs one `#[ignore]`d test of this crate in a child process with `HOME` set to `home`.
440///
441/// `HOME` is process-global, and every staging test in this binary resolves `~/.murmur` from it,
442/// so a test that needs a scratch home gets its own process rather than a lock no other test
443/// takes. The child test returns without asserting unless [`INNER_HOME_ENV`] is set.
444#[cfg(test)]
445pub(crate) fn run_with_home(test: &str, home: &Path) {
446    let exe = std::env::current_exe().expect("test binary path");
447    let output = std::process::Command::new(exe)
448        .args([
449            test,
450            "--exact",
451            "--ignored",
452            "--test-threads=1",
453            "--nocapture",
454        ])
455        .env("HOME", home)
456        .env(INNER_HOME_ENV, "1")
457        .output()
458        .expect("re-exec the test binary with a scratch HOME");
459    let stdout = String::from_utf8_lossy(&output.stdout);
460    let stderr = String::from_utf8_lossy(&output.stderr);
461    assert!(
462        output.status.success(),
463        "{test} failed\n--- stdout ---\n{stdout}\n--- stderr ---\n{stderr}"
464    );
465    assert!(
466        stdout.contains("1 passed"),
467        "{test} was not selected\n--- stdout ---\n{stdout}"
468    );
469}
470
471/// Set only in the child [`run_with_home`] starts.
472#[cfg(test)]
473pub(crate) const INNER_HOME_ENV: &str = "MURMUR_TEST_INNER_HOME";
474
475/// Whether this process is a [`run_with_home`] child.
476#[cfg(test)]
477pub(crate) fn in_scratch_home() -> bool {
478    std::env::var_os(INNER_HOME_ENV).is_some()
479}
480
481/// `path`'s permission bits.
482#[cfg(test)]
483pub(crate) fn mode_of(path: &Path) -> u32 {
484    use std::os::unix::fs::PermissionsExt;
485    std::fs::symlink_metadata(path)
486        .unwrap_or_else(|err| panic!("{}: {err}", path.display()))
487        .permissions()
488        .mode()
489        & 0o777
490}
491
492/// Creates `path` as a directory at `mode`.
493#[cfg(test)]
494pub(crate) fn wide_dir(path: &Path, mode: u32) {
495    use std::os::unix::fs::PermissionsExt;
496    std::fs::create_dir_all(path).unwrap();
497    std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).unwrap();
498}
499
500/// Writes `path` with `contents` at `mode`.
501#[cfg(test)]
502pub(crate) fn wide_file(path: &Path, contents: &str, mode: u32) {
503    use std::os::unix::fs::PermissionsExt;
504    std::fs::write(path, contents).unwrap();
505    std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).unwrap();
506}
507
508#[cfg(test)]
509mod tests {
510    use super::*;
511
512    #[test]
513    fn murmur_home_is_wider_than_truth_table() {
514        for (mode, allowed, wide) in [
515            (0o600, 0o600, false),
516            (0o644, 0o600, true),
517            (0o640, 0o600, true),
518            (0o700, 0o700, false),
519            (0o711, 0o700, true),
520            (0o400, 0o600, false),
521        ] {
522            assert_eq!(
523                is_wider_than(mode, allowed),
524                wide,
525                "{mode:04o} against {allowed:04o}"
526            );
527        }
528    }
529
530    #[test]
531    fn ensure_murmur_home_creates_and_tightens_the_home_directory() {
532        let wide = tempfile::tempdir().unwrap();
533        wide_dir(&wide.path().join(".murmur"), 0o755);
534        run_with_home(
535            "murmur_home::tests::inner_ensure_murmur_home_holds_the_home_at_0700",
536            wide.path(),
537        );
538        assert_eq!(mode_of(&wide.path().join(".murmur")), 0o700);
539
540        let missing = tempfile::tempdir().unwrap();
541        run_with_home(
542            "murmur_home::tests::inner_ensure_murmur_home_holds_the_home_at_0700",
543            missing.path(),
544        );
545        assert_eq!(mode_of(&missing.path().join(".murmur")), 0o700);
546
547        let closed = tempfile::tempdir().unwrap();
548        wide_dir(&closed.path().join(".murmur"), 0o555);
549        run_with_home(
550            "murmur_home::tests::inner_ensure_murmur_home_holds_the_home_at_0700",
551            closed.path(),
552        );
553        assert_eq!(
554            mode_of(&closed.path().join(".murmur")),
555            0o500,
556            "an owner-closed home loses group and other bits and is not reopened"
557        );
558    }
559
560    #[test]
561    #[ignore = "run by ensure_murmur_home_creates_and_tightens_the_home_directory"]
562    fn inner_ensure_murmur_home_holds_the_home_at_0700() {
563        if !in_scratch_home() {
564            return;
565        }
566        let home = ensure_murmur_home().unwrap();
567        assert!(home.ends_with(".murmur"));
568        assert_eq!(mode_of(&home) & 0o077, 0);
569    }
570
571    #[test]
572    fn murmur_home_write_private_file_replaces_a_wide_file_owner_only() {
573        use std::os::unix::fs::MetadataExt;
574        let dir = tempfile::tempdir().unwrap();
575        let path = dir.path().join("config.yaml");
576        wide_file(&path, "old", 0o644);
577        let before = std::fs::metadata(&path).unwrap().ino();
578
579        write_private_file(&path, b"new").unwrap();
580
581        assert_eq!(std::fs::read_to_string(&path).unwrap(), "new");
582        assert_eq!(mode_of(&path), 0o600);
583        assert_ne!(std::fs::metadata(&path).unwrap().ino(), before);
584    }
585
586    #[test]
587    fn murmur_home_audit_reports_absent_entries_in_kind_order() {
588        let dir = tempfile::tempdir().unwrap();
589        let home = dir.path().join(".murmur");
590
591        let reports = audit_murmur_home(&home);
592
593        assert_eq!(reports.len(), 12);
594        assert_eq!(reports[0].kind, HomeEntryKind::Home);
595        assert_eq!(reports[1].kind, HomeEntryKind::Config);
596        assert_eq!(reports[10].kind, HomeEntryKind::CompiledForms);
597        assert_eq!(reports[11].kind, HomeEntryKind::BinaryCache);
598        assert!(reports
599            .iter()
600            .all(|report| report.state == HomeEntryState::Absent));
601    }
602
603    #[test]
604    fn murmur_home_audit_flags_wide_private_entries_and_never_non_private_ones() {
605        let dir = tempfile::tempdir().unwrap();
606        let home = dir.path().join(".murmur");
607        wide_dir(&home, 0o755);
608        wide_file(&home.join("config.yaml"), "credentials: {}\n", 0o644);
609        wide_dir(&home.join("artifacts"), 0o777);
610        wide_file(&home.join("artifacts/loose"), "x", 0o666);
611        wide_dir(&home.join("spend"), 0o700);
612        wide_file(&home.join("spend/2026-09-13.jsonl"), "", 0o600);
613        wide_file(&home.join("nexus-config.json"), "{}", 0o644);
614
615        let reports = audit_murmur_home(&home);
616        let find = |kind: &HomeEntryKind| {
617            reports
618                .iter()
619                .find(|report| &report.kind == kind)
620                .unwrap()
621                .state
622                .clone()
623        };
624        let wide = |state: HomeEntryState| match state {
625            HomeEntryState::Present {
626                wide,
627                wide_descendants,
628                ..
629            } => (wide, wide_descendants.len()),
630            other => panic!("{other:?}"),
631        };
632
633        assert_eq!(wide(find(&HomeEntryKind::Home)), (true, 0));
634        assert_eq!(wide(find(&HomeEntryKind::Config)), (true, 0));
635        assert_eq!(wide(find(&HomeEntryKind::Artifacts)), (false, 0));
636        assert_eq!(wide(find(&HomeEntryKind::Spend)), (false, 0));
637        let last = reports.last().unwrap();
638        assert_eq!(
639            last.kind,
640            HomeEntryKind::Unrecognised("nexus-config.json".to_string())
641        );
642        assert_eq!(wide(last.state.clone()), (false, 0));
643        assert_eq!(mode_of(&home), 0o755, "the audit changes nothing");
644    }
645
646    #[test]
647    fn murmur_home_audit_does_not_follow_symlinks() {
648        let dir = tempfile::tempdir().unwrap();
649        let home = dir.path().join(".murmur");
650        wide_dir(&home.join("state"), 0o700);
651        let outside = dir.path().join("outside");
652        wide_dir(&outside, 0o777);
653        wide_file(&outside.join("secret"), "x", 0o666);
654        std::os::unix::fs::symlink(&outside, home.join("state/link")).unwrap();
655
656        let reports = audit_murmur_home(&home);
657        let state = &reports
658            .iter()
659            .find(|report| report.kind == HomeEntryKind::State)
660            .unwrap()
661            .state;
662        assert!(
663            matches!(state, HomeEntryState::Present { wide: false, wide_descendants, .. } if wide_descendants.is_empty()),
664            "{state:?}"
665        );
666    }
667
668    #[test]
669    fn murmur_home_audit_caps_wide_descendants_and_counts_the_rest() {
670        let dir = tempfile::tempdir().unwrap();
671        let home = dir.path().join(".murmur");
672        let keys = home.join("deploy_keys");
673        wide_dir(&keys, 0o700);
674        for index in 0..25 {
675            wide_file(&keys.join(format!("key_{index:02}")), "x", 0o644);
676        }
677
678        let reports = audit_murmur_home(&home);
679        let state = &reports
680            .iter()
681            .find(|report| report.kind == HomeEntryKind::DeployKeys)
682            .unwrap()
683            .state;
684        match state {
685            HomeEntryState::Present {
686                wide,
687                wide_descendants,
688                wide_descendants_omitted,
689                ..
690            } => {
691                assert!(!wide);
692                assert_eq!(wide_descendants.len(), WIDE_DESCENDANT_CAP);
693                assert_eq!(*wide_descendants_omitted, 5);
694                assert!(wide_descendants
695                    .iter()
696                    .all(|entry| entry.mode == 0o644 && entry.allowed == 0o600));
697            }
698            other => panic!("{other:?}"),
699        }
700    }
701
702    #[test]
703    fn murmur_home_wide_credential_file_warning_names_the_path_and_mode_only() {
704        let path = Path::new("/home/someone/.murmur/config.yaml");
705        assert_eq!(
706            wide_credential_file_warning(path, "PROVIDER_KEY", 0o600),
707            None
708        );
709        assert_eq!(
710            wide_credential_file_warning(path, "PROVIDER_KEY", 0o100600),
711            None
712        );
713
714        let warning = wide_credential_file_warning(path, "PROVIDER_KEY", 0o100644).unwrap();
715        assert!(warning.contains("warning[W-SEC-028]"), "{warning}");
716        assert!(warning.contains("credentials.PROVIDER_KEY"), "{warning}");
717        assert!(
718            warning.contains("/home/someone/.murmur/config.yaml"),
719            "{warning}"
720        );
721        assert!(warning.contains("mode 0644"), "{warning}");
722        assert!(
723            warning.contains("`chmod 600 /home/someone/.murmur/config.yaml`"),
724            "{warning}"
725        );
726        assert!(warning.ends_with("#w-sec-028)"), "{warning}");
727    }
728
729    #[test]
730    fn murmur_home_wide_entry_warning_prints_the_chmod_for_the_allowed_mode() {
731        let warning = wide_entry_warning(Path::new("/h/.murmur"), "the murmur home", 0o755, 0o700);
732        assert_eq!(
733            warning,
734            "[capsule-runtime] warning[W-SEC-028]: /h/.murmur holds the murmur home and is mode \
735             0755, which other accounts on this host can read; run `chmod 700 /h/.murmur` \
736             (https://docs.murmur.nexus/murmur-nexus/murmur/reference/diagnostics/#w-sec-028)"
737        );
738    }
739}