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