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/// Why `HOME` cannot hold `~/.murmur`, and with it a session's runtime and records directories.
65///
66/// The one answer every launch and every reader of a records group gives: `mur` renders it as
67/// `E-IO-004`, and nothing is written anywhere when it is returned.
68#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
69pub enum HomeUnusable {
70    #[error(
71        "HOME is not set, so there is no ~/.murmur to hold this session's runtime and records \
72         directories"
73    )]
74    NotSet,
75    #[error(
76        "HOME is '{0}', which is not an absolute path, so there is no ~/.murmur to hold this \
77         session's runtime and records directories"
78    )]
79    NotAbsolute(String),
80    #[error("~/.murmur ({}) cannot hold this session's runtime and records directories: {reason}", path.display())]
81    CannotHold { path: PathBuf, reason: String },
82}
83
84/// What an operator sets to clear any [`HomeUnusable`].
85pub const HOME_UNUSABLE_HINT: &str = "set HOME to the absolute path of a directory you own, in \
86                                      which ~/.murmur exists or can be created as a 0700 directory";
87
88/// `~/.murmur`, when `HOME` names an absolute path in which it exists as a directory this process
89/// can create entries in, or can be created. Creates nothing.
90///
91/// Also refuses a `~/.murmur/sessions` or `~/.murmur/records` that exists and is not such a
92/// directory, the two places every launch creates something beneath.
93pub fn usable_murmur_home() -> Result<PathBuf, HomeUnusable> {
94    let home = home_path()?;
95    let murmur_home = home.join(".murmur");
96    let cannot_hold = |reason: String| HomeUnusable::CannotHold {
97        path: murmur_home.clone(),
98        reason,
99    };
100    for sub in [
101        crate::runtime_dir::SESSIONS_DIR_NAME,
102        crate::records_dir::RECORDS_DIR_NAME,
103    ] {
104        let path = murmur_home.join(sub);
105        if std::fs::symlink_metadata(&path).is_ok() {
106            writable_dir(&path)
107                .map_err(|reason| cannot_hold(format!("{}: {reason}", path.display())))?;
108        }
109    }
110    if std::fs::symlink_metadata(&murmur_home).is_ok() {
111        writable_dir(&murmur_home).map_err(cannot_hold)?;
112    } else {
113        writable_dir(&home)
114            .map_err(|reason| cannot_hold(format!("HOME {}: {reason}", home.display())))?;
115    }
116    Ok(murmur_home)
117}
118
119/// `HOME`, when it is set, non-empty and absolute. Checks and creates nothing.
120///
121/// The one place the runtime reads `HOME`; every caller either checks `~/.murmur` further
122/// ([`usable_murmur_home`]) or wraps the reason in its own error.
123pub(crate) fn home_path() -> Result<PathBuf, HomeUnusable> {
124    let home = match std::env::var_os("HOME").filter(|home| !home.is_empty()) {
125        None => return Err(HomeUnusable::NotSet),
126        Some(home) => PathBuf::from(home),
127    };
128    if !home.is_absolute() {
129        return Err(HomeUnusable::NotAbsolute(home.display().to_string()));
130    }
131    Ok(home)
132}
133
134/// `Ok` when `path` is a directory this process may create entries in.
135fn writable_dir(path: &Path) -> Result<(), String> {
136    match std::fs::metadata(path) {
137        Ok(metadata) if metadata.is_dir() => {}
138        Ok(_) => return Err("not a directory".to_string()),
139        Err(error) => return Err(error.to_string()),
140    }
141    rustix::fs::access(
142        path,
143        rustix::fs::Access::WRITE_OK | rustix::fs::Access::EXEC_OK,
144    )
145    .map_err(|errno| format!("cannot be written ({})", std::io::Error::from(errno)))
146}
147
148/// Replace `target` with `contents` at [`PRIVATE_FILE_MODE`].
149///
150/// Staged beside `target` and renamed over it, so a reader sees the old file or the new one, and
151/// the inode changes on every write — which is how a running capsule's credential stamp notices a
152/// `mur config set -g`. The mode is set on the staged file before the rename, so the new contents
153/// are never visible at a wider mode. The parent directory must already exist.
154pub fn write_private_file(target: &Path, contents: &[u8]) -> Result<(), String> {
155    crate::retention::StagedRewrite::stage_with_mode(target, contents, Some(PRIVATE_FILE_MODE))?
156        .commit()
157}
158
159/// The identity and last change of a file, as `fstat(2)` reports them on an open handle: device,
160/// inode, size, owner, mode, mtime and ctime.
161///
162/// The kernel sets ctime to the current time on every write, truncate, chmod, chown and rename of
163/// the inode, and nothing without `CAP_SYS_TIME` can set it back, so a file changed or replaced
164/// since its stamp was taken has a different stamp even when its mtime was restored. Where ctime
165/// has timer-tick resolution, as on Linux before 6.13, a same-size write within one tick of the
166/// stamp can keep it.
167///
168/// Its `Display` form is recorded on disk beside each compiled form and in each host facts entry,
169/// so changing the format makes every recorded stamp miss once.
170#[derive(Debug, Clone, Copy, PartialEq, Eq)]
171pub(crate) struct FileStamp {
172    dev: u64,
173    ino: u64,
174    len: u64,
175    uid: u32,
176    pub(crate) mode: u32,
177    mtime: (i64, i64),
178    ctime: (i64, i64),
179}
180
181impl FileStamp {
182    pub(crate) fn of(metadata: &std::fs::Metadata) -> Self {
183        use std::os::unix::fs::MetadataExt;
184
185        Self {
186            dev: metadata.dev(),
187            ino: metadata.ino(),
188            len: metadata.len(),
189            uid: metadata.uid(),
190            mode: metadata.mode(),
191            mtime: (metadata.mtime(), metadata.mtime_nsec()),
192            ctime: (metadata.ctime(), metadata.ctime_nsec()),
193        }
194    }
195}
196
197impl std::fmt::Display for FileStamp {
198    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
199        write!(
200            f,
201            "{} {} {} {} {:o} {}.{:09} {}.{:09}",
202            self.dev,
203            self.ino,
204            self.len,
205            self.uid,
206            self.mode,
207            self.mtime.0,
208            self.mtime.1,
209            self.ctime.0,
210            self.ctime.1,
211        )
212    }
213}
214
215/// `<home>/<name>`, a cache directory of this uid held owner-only, and whether this call found
216/// it wider than that and emptied it; `None` when it cannot be used. `reachable` says whether a
217/// directory can be written from the capsule being staged: a `home` or directory it reaches is
218/// neither read nor written. Only `create` touches the disk: it holds the directory owner-only,
219/// as every other writer under the home does, and creates or narrows nothing through a symlink.
220/// The home is checked before anything is created beneath it and the directory again after.
221///
222/// A directory `create` finds wider than owner-only is narrowed and emptied of everything but
223/// subdirectories: while it was wide, another uid could have planted or hard-linked an entry that
224/// passes every per-file check of [`open_private`].
225pub(crate) fn private_subdir(
226    home: &Path,
227    name: &str,
228    create: bool,
229    reachable: impl Fn(&Path) -> bool,
230) -> Option<(PathBuf, bool)> {
231    if reachable(home) {
232        return None;
233    }
234    let dir = home.join(name);
235    let mut widened = false;
236    if create {
237        // `hold_private_dir` sets the mode through a symlink, so it only ever sees a real
238        // directory or a missing path.
239        match std::fs::symlink_metadata(&dir) {
240            Ok(metadata) if metadata.is_dir() => widened = !held_private(&dir),
241            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
242            _ => return None,
243        }
244        hold_private_dir(&dir).ok()?;
245    }
246    if !held_private(&dir) || reachable(&dir) {
247        return None;
248    }
249    if widened {
250        for (path, _) in direct_files(&dir) {
251            let _ = std::fs::remove_file(path);
252        }
253    }
254    Some((dir, widened))
255}
256
257/// Whether `dir` is `root` or lies beneath it once both are resolved. A `dir` that cannot be
258/// resolved, or a `root` that cannot be resolved for any reason but not existing, counts as
259/// reachable.
260pub(crate) fn reachable_from(dir: &Path, root: &Path) -> bool {
261    match (dir.canonicalize(), root.canonicalize()) {
262        (Ok(dir), Ok(root)) => dir.starts_with(root),
263        (Ok(_), Err(err)) => err.kind() != std::io::ErrorKind::NotFound,
264        (Err(_), _) => true,
265    }
266}
267
268/// Whether `dir` itself, not a symlink to it, is a directory of this uid in the state
269/// [`hold_private_dir`] leaves it in.
270pub(crate) fn held_private(dir: &Path) -> bool {
271    std::fs::symlink_metadata(dir).is_ok_and(|metadata| {
272        metadata.is_dir() && owned_within(&metadata, current_euid(), MURMUR_HOME_DIR_MODE)
273    })
274}
275
276/// Whether `metadata` belongs to `euid` and grants no permission bit beyond `allowed`.
277pub(crate) fn owned_within(metadata: &std::fs::Metadata, euid: u32, allowed: u32) -> bool {
278    use std::os::unix::fs::MetadataExt;
279
280    metadata.uid() == euid && !is_wider_than(metadata.mode(), allowed)
281}
282
283#[allow(unsafe_code)]
284pub(crate) fn current_euid() -> u32 {
285    // SAFETY: `geteuid` takes no arguments, touches no memory of the caller and cannot fail.
286    unsafe { libc::geteuid() }
287}
288
289/// `path` opened for reading, when it is a regular file of this uid at [`PRIVATE_FILE_MODE`] or
290/// narrower. Does not follow a symlink on the final component, does not block on a FIFO, and
291/// checks the owner, type and mode on the open handle.
292pub(crate) fn open_private(path: &Path) -> Option<(std::fs::File, std::fs::Metadata)> {
293    let (file, metadata) = crate::resource_plane::open_no_follow(path).ok()?;
294    (metadata.is_file() && owned_within(&metadata, current_euid(), PRIVATE_FILE_MODE))
295        .then_some((file, metadata))
296}
297
298/// Every direct entry of `dir` that is not a directory, with its `lstat` metadata. Never recurses,
299/// never follows a symlink, and skips an entry it cannot read.
300pub(crate) fn direct_files(dir: &Path) -> impl Iterator<Item = (PathBuf, std::fs::Metadata)> {
301    std::fs::read_dir(dir)
302        .into_iter()
303        .flatten()
304        .filter_map(Result::ok)
305        .filter_map(|entry| {
306            let path = entry.path();
307            let metadata = std::fs::symlink_metadata(&path).ok()?;
308            (!metadata.is_dir()).then_some((path, metadata))
309        })
310}
311
312/// Whether `mode` grants any permission bit `allowed` does not.
313pub fn is_wider_than(mode: u32, allowed: u32) -> bool {
314    (mode & 0o777) & !allowed != 0
315}
316
317/// What one top-level entry of `~/.murmur` is.
318#[derive(Debug, Clone, PartialEq, Eq)]
319pub enum HomeEntryKind {
320    Home,
321    Config,
322    DeployKeys,
323    DeployStaging,
324    Deployments,
325    Spend,
326    Conversations,
327    Running,
328    State,
329    Artifacts,
330    CompiledForms,
331    HostFacts,
332    BinaryCache,
333    Formations,
334    Sessions,
335    Records,
336    Programs,
337    Keys,
338    /// A top-level name this build writes nothing under.
339    Unrecognised(String),
340}
341
342impl HomeEntryKind {
343    /// Every kind with a fixed name, in the order the audit reports them after the home itself.
344    const KNOWN: [HomeEntryKind; 17] = [
345        Self::Config,
346        Self::DeployKeys,
347        Self::DeployStaging,
348        Self::Deployments,
349        Self::Spend,
350        Self::Conversations,
351        Self::Running,
352        Self::State,
353        Self::Artifacts,
354        Self::CompiledForms,
355        Self::HostFacts,
356        Self::BinaryCache,
357        Self::Formations,
358        Self::Sessions,
359        Self::Records,
360        Self::Programs,
361        Self::Keys,
362    ];
363
364    /// The entry's name under `~/.murmur`, or `None` for the home itself.
365    pub fn file_name(&self) -> Option<&str> {
366        Some(match self {
367            Self::Home => return None,
368            Self::Config => "config.yaml",
369            Self::DeployKeys => "deploy_keys",
370            Self::DeployStaging => "deploy_staging",
371            Self::Deployments => "deployments.json",
372            Self::Spend => "spend",
373            Self::Conversations => "conversations",
374            Self::Running => "running",
375            Self::State => "state",
376            Self::Artifacts => "artifacts",
377            Self::CompiledForms => "compiled",
378            Self::HostFacts => "host_facts",
379            Self::BinaryCache => "bin",
380            Self::Formations => crate::formation_launch::FORMATIONS_DIR,
381            Self::Sessions => crate::runtime_dir::SESSIONS_DIR_NAME,
382            Self::Records => crate::records_dir::RECORDS_DIR_NAME,
383            Self::Programs => crate::pinned_programs::PROGRAMS_DIR,
384            Self::Keys => crate::record_seal::KEYS_DIR,
385            Self::Unrecognised(name) => name,
386        })
387    }
388
389    /// What the entry holds, as a noun phrase that completes "`<path>` holds …".
390    pub fn holds(&self) -> &'static str {
391        match self {
392            Self::Home => "the murmur home",
393            Self::Config => "provider credentials",
394            Self::DeployKeys => "SSH private keys",
395            Self::DeployStaging => "deployment staging copies",
396            Self::Deployments => "deployment records",
397            Self::Spend => "the machine spend ledger",
398            Self::Conversations => "conversation records",
399            Self::Running => "running-capsule records",
400            Self::State => "capsule state stores",
401            Self::Artifacts => "installed artifacts",
402            Self::CompiledForms => "compiled WASM cache",
403            Self::HostFacts => "facts about allowlisted host binaries",
404            Self::BinaryCache => "cached mur binaries",
405            Self::Formations => "formation members' working directories",
406            Self::Sessions => "running sessions' runtime directories",
407            Self::Records => "every session's trace, logs, eval scores and delegation outcome",
408            Self::Programs => "pinned program downloads",
409            Self::Keys => "the record-seal key",
410            Self::Unrecognised(_) => "something not recognised by this build",
411        }
412    }
413
414    /// How long what the entry holds is kept, as a clause that follows [`Self::holds`] after a
415    /// comma, or `None` for an entry `mur` never removes on its own.
416    pub fn kept(&self) -> Option<&'static str> {
417        match self {
418            Self::Formations => Some(
419                "each kept until its formation has ended and the entry member's trace.retain \
420                 expires it",
421            ),
422            Self::Sessions => Some(
423                "each removed when its session ends, and by the next launch when a session \
424                 ended without removing its own",
425            ),
426            Self::Records => Some(
427                "each kept after its session ends, until its capsule's trace.retain expires it",
428            ),
429            _ => None,
430        }
431    }
432
433    /// Whether deleting the entry loses nothing, because `mur` writes it again when it needs it.
434    ///
435    /// `formations/` is not: deleting an ended formation's directory stops nothing, but its peers'
436    /// traces go with it and are never written again.
437    pub fn safe_to_delete(&self) -> bool {
438        matches!(self, Self::CompiledForms | Self::HostFacts)
439    }
440
441    /// Whether the entry, and everything beneath it down to [`Self::private_depth`], is expected
442    /// to be owner-only.
443    pub fn expected_private(&self) -> bool {
444        !matches!(
445            self,
446            Self::Artifacts | Self::BinaryCache | Self::Unrecognised(_)
447        )
448    }
449
450    /// The widest mode a regular file beneath the entry may have: [`PRIVATE_FILE_MODE`], or
451    /// `0700` under `programs/`, whose unpacked downloads keep their owner execute bit.
452    pub fn allowed_file_mode(&self) -> u32 {
453        match self {
454            Self::Programs => crate::pinned_programs::EXECUTABLE_FILE_MODE,
455            // `record-seal.pub` is world-readable by design; the seed beside it is refused by
456            // every launch at any group or other bit ([`crate::record_seal::HostKey`]).
457            Self::Keys => crate::record_seal::PUBLIC_KEY_MODE,
458            _ => PRIVATE_FILE_MODE,
459        }
460    }
461
462    /// How many directory levels below the entry the audit holds owner-only, or `None` for every
463    /// level.
464    ///
465    /// `formations/<frm_id>/<member>/` is held at two: the launcher makes those directories
466    /// owner-only, and below them is a member's ordinary working directory, whose sessions are
467    /// written at the umask's modes like any `--workdir`. The owner-only member directory is what
468    /// keeps them from other accounts, so walking further reports every file a session writes.
469    pub fn private_depth(&self) -> Option<usize> {
470        match self {
471            Self::Formations => Some(2),
472            // `sessions/<id>/` and the three directories in it are created owner-only; below
473            // them are the runtime's own files, written at the umask's modes.
474            Self::Sessions => Some(2),
475            // `records/<first component of a workdir root>/` is held owner-only; the rest of the
476            // mirrored path and each `<session-id>/` are created owner-only too, at a depth that
477            // varies with the workdir, and below them are the runtime's own files at the umask's
478            // modes. The owner-only `records/` is what keeps them from other accounts.
479            Self::Records => Some(1),
480            _ => None,
481        }
482    }
483}
484
485/// A path under an owner-only entry whose mode grants more than it should.
486#[derive(Debug, Clone, PartialEq, Eq)]
487pub struct WideEntry {
488    pub path: PathBuf,
489    /// Permission bits only, `& 0o777`.
490    pub mode: u32,
491    /// [`MURMUR_HOME_DIR_MODE`] for a directory, [`PRIVATE_FILE_MODE`] for a file.
492    pub allowed: u32,
493}
494
495#[derive(Debug, Clone, PartialEq, Eq)]
496pub enum HomeEntryState {
497    Absent,
498    Present {
499        /// Permission bits only, `& 0o777`, from `lstat`.
500        mode: u32,
501        /// Whether the entry itself is wider than its kind allows. Always `false` for a kind that
502        /// is not [`HomeEntryKind::expected_private`], and for a symlink.
503        wide: bool,
504        /// Wide paths beneath an owner-only directory, depth first, at most
505        /// [`WIDE_DESCENDANT_CAP`].
506        wide_descendants: Vec<WideEntry>,
507        /// Wide paths found beyond the cap.
508        wide_descendants_omitted: usize,
509    },
510    /// `lstat` failed for a reason other than the entry not existing.
511    Unreadable {
512        error: String,
513    },
514}
515
516#[derive(Debug, Clone, PartialEq, Eq)]
517pub struct HomeEntryReport {
518    pub path: PathBuf,
519    pub kind: HomeEntryKind,
520    pub state: HomeEntryState,
521}
522
523impl HomeEntryReport {
524    /// The mode this entry is held to: [`MURMUR_HOME_DIR_MODE`] for a directory,
525    /// [`PRIVATE_FILE_MODE`] otherwise.
526    pub fn allowed(&self) -> u32 {
527        allowed_for(&self.path)
528    }
529}
530
531/// Report `murmur_home` and every entry in it.
532///
533/// The home first, then every [`HomeEntryKind`] with a fixed name, absent ones included, then
534/// every other top-level name, sorted. Owner-only directories are walked without following
535/// symlinks; entries that are not expected to be owner-only get their mode and nothing else.
536/// Reads metadata only and changes nothing on disk.
537pub fn audit_murmur_home(murmur_home: &Path) -> Vec<HomeEntryReport> {
538    let mut reports = vec![report_entry(murmur_home.to_path_buf(), HomeEntryKind::Home)];
539
540    for kind in HomeEntryKind::KNOWN {
541        let name = kind.file_name().expect("a known kind has a fixed name");
542        reports.push(report_entry(murmur_home.join(name), kind));
543    }
544
545    let mut others: Vec<String> = std::fs::read_dir(murmur_home)
546        .map(|entries| {
547            entries
548                .filter_map(Result::ok)
549                .map(|entry| entry.file_name().to_string_lossy().into_owned())
550                .filter(|name| {
551                    !HomeEntryKind::KNOWN
552                        .iter()
553                        .any(|kind| kind.file_name() == Some(name.as_str()))
554                })
555                .collect()
556        })
557        .unwrap_or_default();
558    others.sort();
559    for name in others {
560        reports.push(report_entry(
561            murmur_home.join(&name),
562            HomeEntryKind::Unrecognised(name),
563        ));
564    }
565
566    reports
567}
568
569fn report_entry(path: PathBuf, kind: HomeEntryKind) -> HomeEntryReport {
570    use std::os::unix::fs::PermissionsExt;
571
572    let metadata = match std::fs::symlink_metadata(&path) {
573        Ok(metadata) => metadata,
574        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
575            return HomeEntryReport {
576                path,
577                kind,
578                state: HomeEntryState::Absent,
579            }
580        }
581        Err(err) => {
582            return HomeEntryReport {
583                path,
584                kind,
585                state: HomeEntryState::Unreadable {
586                    error: err.to_string(),
587                },
588            }
589        }
590    };
591    let mode = metadata.permissions().mode() & 0o777;
592    let private = kind.expected_private();
593    let file_type = metadata.file_type();
594    let wide =
595        private && !file_type.is_symlink() && is_wider_than(mode, allowed_for_type(file_type));
596
597    let mut wide_descendants = Vec::new();
598    let mut omitted = 0;
599    // The home's own entries are reported one by one, so only an entry below it is walked.
600    if private && file_type.is_dir() && kind != HomeEntryKind::Home {
601        collect_wide_descendants(
602            &path,
603            kind.private_depth(),
604            kind.allowed_file_mode(),
605            &mut wide_descendants,
606            &mut omitted,
607        );
608    }
609
610    HomeEntryReport {
611        path,
612        kind,
613        state: HomeEntryState::Present {
614            mode,
615            wide,
616            wide_descendants,
617            wide_descendants_omitted: omitted,
618        },
619    }
620}
621
622/// Wide paths beneath `dir`, `depth` levels down, or every level for `None`.
623fn collect_wide_descendants(
624    dir: &Path,
625    depth: Option<usize>,
626    file_allowed: u32,
627    found: &mut Vec<WideEntry>,
628    omitted: &mut usize,
629) {
630    use std::os::unix::fs::PermissionsExt;
631
632    if depth == Some(0) {
633        return;
634    }
635    let Ok(entries) = std::fs::read_dir(dir) else {
636        return;
637    };
638    let mut paths: Vec<PathBuf> = entries.filter_map(Result::ok).map(|e| e.path()).collect();
639    paths.sort();
640    for path in paths {
641        let Ok(metadata) = std::fs::symlink_metadata(&path) else {
642            continue;
643        };
644        let file_type = metadata.file_type();
645        if file_type.is_symlink() || !(file_type.is_dir() || file_type.is_file()) {
646            continue;
647        }
648        let mode = metadata.permissions().mode() & 0o777;
649        let allowed = if file_type.is_dir() {
650            MURMUR_HOME_DIR_MODE
651        } else {
652            file_allowed
653        };
654        if is_wider_than(mode, allowed) {
655            if found.len() < WIDE_DESCENDANT_CAP {
656                found.push(WideEntry {
657                    path: path.clone(),
658                    mode,
659                    allowed,
660                });
661            } else {
662                *omitted += 1;
663            }
664        }
665        if file_type.is_dir() {
666            collect_wide_descendants(
667                &path,
668                depth.map(|depth| depth - 1),
669                file_allowed,
670                found,
671                omitted,
672            );
673        }
674    }
675}
676
677fn allowed_for_type(file_type: std::fs::FileType) -> u32 {
678    if file_type.is_dir() {
679        MURMUR_HOME_DIR_MODE
680    } else {
681        PRIVATE_FILE_MODE
682    }
683}
684
685fn allowed_for(path: &Path) -> u32 {
686    match std::fs::symlink_metadata(path) {
687        Ok(metadata) => allowed_for_type(metadata.file_type()),
688        Err(_) => PRIVATE_FILE_MODE,
689    }
690}
691
692/// The `W-SEC-028` line for a path under `~/.murmur` that holds `holds` and is wider than
693/// `allowed`. Names the path and its mode, never its contents.
694pub fn wide_entry_warning(path: &Path, holds: &str, mode: u32, allowed: u32) -> String {
695    let path = path.display();
696    format!(
697        "[capsule-runtime] warning[{W_SEC_028}]: {path} holds {holds} and is mode {:04o}, which \
698         other accounts on this host can read; run `chmod {:o} {path}` ({})",
699        mode & 0o777,
700        allowed,
701        security_warning_link(W_SEC_028)
702    )
703}
704
705/// Print `W-SEC-028` when the config file an inference credential was just read from grants any
706/// group or other bit. Never a refusal, and never names the value.
707pub fn warn_on_wide_credential_file(path: &Path, name: &str, mode: u32) {
708    if let Some(warning) = wide_credential_file_warning(path, name, mode) {
709        crate::runtime_err!("{warning}");
710    }
711}
712
713fn wide_credential_file_warning(path: &Path, name: &str, mode: u32) -> Option<String> {
714    if !is_wider_than(mode, PRIVATE_FILE_MODE) {
715        return None;
716    }
717    let path = path.display();
718    Some(format!(
719        "[capsule-runtime] warning[{W_SEC_028}]: the credential credentials.{name} is \
720         read from {path}, which is mode {:04o} and readable by other accounts on this host; run \
721         `chmod 600 {path}` ({})",
722        mode & 0o777,
723        security_warning_link(W_SEC_028)
724    ))
725}
726
727/// Runs one `#[ignore]`d test of this crate in a child process with `HOME` set to `home`.
728///
729/// `HOME` is process-global, and every staging test in this binary resolves `~/.murmur` from it,
730/// so a test that needs a scratch home gets its own process rather than a lock no other test
731/// takes. The child test returns without asserting unless [`INNER_HOME_ENV`] is set.
732#[cfg(test)]
733pub(crate) fn run_with_home(test: &str, home: &Path) {
734    let exe = std::env::current_exe().expect("test binary path");
735    let output = std::process::Command::new(exe)
736        .args([
737            test,
738            "--exact",
739            "--ignored",
740            "--test-threads=1",
741            "--nocapture",
742        ])
743        .env("HOME", home)
744        .env(INNER_HOME_ENV, "1")
745        .output()
746        .expect("re-exec the test binary with a scratch HOME");
747    let stdout = String::from_utf8_lossy(&output.stdout);
748    let stderr = String::from_utf8_lossy(&output.stderr);
749    assert!(
750        output.status.success(),
751        "{test} failed\n--- stdout ---\n{stdout}\n--- stderr ---\n{stderr}"
752    );
753    assert!(
754        stdout.contains("1 passed"),
755        "{test} was not selected\n--- stdout ---\n{stdout}"
756    );
757}
758
759/// Set only in the child [`run_with_home`] starts.
760#[cfg(test)]
761pub(crate) const INNER_HOME_ENV: &str = "MURMUR_TEST_INNER_HOME";
762
763/// Whether this process is a [`run_with_home`] child.
764#[cfg(test)]
765pub(crate) fn in_scratch_home() -> bool {
766    std::env::var_os(INNER_HOME_ENV).is_some()
767}
768
769/// `path`'s permission bits.
770#[cfg(test)]
771pub(crate) fn mode_of(path: &Path) -> u32 {
772    use std::os::unix::fs::PermissionsExt;
773    std::fs::symlink_metadata(path)
774        .unwrap_or_else(|err| panic!("{}: {err}", path.display()))
775        .permissions()
776        .mode()
777        & 0o777
778}
779
780/// Creates `path` as a directory at `mode`.
781#[cfg(test)]
782pub(crate) fn wide_dir(path: &Path, mode: u32) {
783    use std::os::unix::fs::PermissionsExt;
784    std::fs::create_dir_all(path).unwrap();
785    std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).unwrap();
786}
787
788/// Writes `path` with `contents` at `mode`.
789#[cfg(test)]
790pub(crate) fn wide_file(path: &Path, contents: &str, mode: u32) {
791    use std::os::unix::fs::PermissionsExt;
792    std::fs::write(path, contents).unwrap();
793    std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).unwrap();
794}
795
796#[cfg(test)]
797mod tests {
798    use super::*;
799    use std::os::unix::fs::PermissionsExt;
800
801    #[test]
802    fn murmur_home_is_wider_than_truth_table() {
803        for (mode, allowed, wide) in [
804            (0o600, 0o600, false),
805            (0o644, 0o600, true),
806            (0o640, 0o600, true),
807            (0o700, 0o700, false),
808            (0o711, 0o700, true),
809            (0o400, 0o600, false),
810        ] {
811            assert_eq!(
812                is_wider_than(mode, allowed),
813                wide,
814                "{mode:04o} against {allowed:04o}"
815            );
816        }
817    }
818
819    #[test]
820    fn ensure_murmur_home_creates_and_tightens_the_home_directory() {
821        let wide = tempfile::tempdir().unwrap();
822        wide_dir(&wide.path().join(".murmur"), 0o755);
823        run_with_home(
824            "murmur_home::tests::inner_ensure_murmur_home_holds_the_home_at_0700",
825            wide.path(),
826        );
827        assert_eq!(mode_of(&wide.path().join(".murmur")), 0o700);
828
829        let missing = tempfile::tempdir().unwrap();
830        run_with_home(
831            "murmur_home::tests::inner_ensure_murmur_home_holds_the_home_at_0700",
832            missing.path(),
833        );
834        assert_eq!(mode_of(&missing.path().join(".murmur")), 0o700);
835
836        let closed = tempfile::tempdir().unwrap();
837        wide_dir(&closed.path().join(".murmur"), 0o555);
838        run_with_home(
839            "murmur_home::tests::inner_ensure_murmur_home_holds_the_home_at_0700",
840            closed.path(),
841        );
842        assert_eq!(
843            mode_of(&closed.path().join(".murmur")),
844            0o500,
845            "an owner-closed home loses group and other bits and is not reopened"
846        );
847    }
848
849    #[test]
850    #[ignore = "run by ensure_murmur_home_creates_and_tightens_the_home_directory"]
851    fn inner_ensure_murmur_home_holds_the_home_at_0700() {
852        if !in_scratch_home() {
853            return;
854        }
855        let home = ensure_murmur_home().unwrap();
856        assert!(home.ends_with(".murmur"));
857        assert_eq!(mode_of(&home) & 0o077, 0);
858    }
859
860    #[test]
861    fn murmur_home_write_private_file_replaces_a_wide_file_owner_only() {
862        use std::os::unix::fs::MetadataExt;
863        let dir = tempfile::tempdir().unwrap();
864        let path = dir.path().join("config.yaml");
865        wide_file(&path, "old", 0o644);
866        let before = std::fs::metadata(&path).unwrap().ino();
867
868        write_private_file(&path, b"new").unwrap();
869
870        assert_eq!(std::fs::read_to_string(&path).unwrap(), "new");
871        assert_eq!(mode_of(&path), 0o600);
872        assert_ne!(std::fs::metadata(&path).unwrap().ino(), before);
873    }
874
875    /// Re-runs `test` with `HOME` removed from its environment, on [`run_with_home`]'s terms.
876    fn run_without_home(test: &str) {
877        let exe = std::env::current_exe().expect("test binary path");
878        let output = std::process::Command::new(exe)
879            .args([
880                test,
881                "--exact",
882                "--ignored",
883                "--test-threads=1",
884                "--nocapture",
885            ])
886            .env_remove("HOME")
887            .env(INNER_HOME_ENV, "1")
888            .output()
889            .expect("re-exec the test binary with no HOME");
890        let stdout = String::from_utf8_lossy(&output.stdout);
891        assert!(
892            output.status.success() && stdout.contains("1 passed"),
893            "{test} failed\n{stdout}\n{}",
894            String::from_utf8_lossy(&output.stderr)
895        );
896    }
897
898    /// Each way `HOME` cannot hold `~/.murmur` names `HOME` and its cause, and creates nothing.
899    #[test]
900    fn an_unusable_home_names_home_and_its_cause() {
901        run_without_home("murmur_home::tests::an_unset_home_inner");
902        let relative = std::path::Path::new("relative-home");
903        run_with_home("murmur_home::tests::a_relative_home_inner", relative);
904        let closed = tempfile::tempdir().unwrap();
905        run_with_home("murmur_home::tests::a_closed_home_inner", closed.path());
906    }
907
908    #[test]
909    #[ignore = "run by an_unusable_home_names_home_and_its_cause"]
910    fn an_unset_home_inner() {
911        if !in_scratch_home() {
912            return;
913        }
914        let error = usable_murmur_home().unwrap_err();
915        assert_eq!(error, HomeUnusable::NotSet);
916        assert_eq!(
917            error.to_string(),
918            "HOME is not set, so there is no ~/.murmur to hold this session's runtime and \
919             records directories"
920        );
921        assert!(crate::runtime_dir::sessions_dir().is_err());
922        assert!(crate::records_dir::records_group(Path::new("/w")).is_err());
923    }
924
925    #[test]
926    #[ignore = "run by an_unusable_home_names_home_and_its_cause"]
927    fn a_relative_home_inner() {
928        if !in_scratch_home() {
929            return;
930        }
931        let error = usable_murmur_home().unwrap_err();
932        assert_eq!(
933            error.to_string(),
934            "HOME is 'relative-home', which is not an absolute path, so there is no ~/.murmur to \
935             hold this session's runtime and records directories"
936        );
937        assert!(!Path::new("relative-home").exists());
938    }
939
940    #[test]
941    #[ignore = "run by an_unusable_home_names_home_and_its_cause"]
942    fn a_closed_home_inner() {
943        if !in_scratch_home() {
944            return;
945        }
946        let home = PathBuf::from(std::env::var_os("HOME").unwrap());
947        let murmur = home.join(".murmur");
948        std::fs::create_dir(&murmur).unwrap();
949        std::fs::set_permissions(&murmur, std::fs::Permissions::from_mode(0o500)).unwrap();
950        // Root writes through any mode, and then there is nothing to refuse.
951        if std::fs::write(murmur.join("probe"), b"x").is_err() {
952            let error = usable_murmur_home().unwrap_err();
953            let text = error.to_string();
954            assert!(
955                text.starts_with(&format!(
956                    "~/.murmur ({}) cannot hold this session's runtime and records directories: ",
957                    murmur.display()
958                )),
959                "{text}"
960            );
961            assert!(text.contains("cannot be written"), "{text}");
962        }
963        std::fs::set_permissions(&murmur, std::fs::Permissions::from_mode(0o700)).unwrap();
964        assert_eq!(usable_murmur_home().unwrap(), murmur);
965    }
966
967    #[test]
968    fn murmur_home_audit_reports_absent_entries_in_kind_order() {
969        let dir = tempfile::tempdir().unwrap();
970        let home = dir.path().join(".murmur");
971
972        let reports = audit_murmur_home(&home);
973
974        assert_eq!(reports.len(), 18);
975        assert_eq!(reports[0].kind, HomeEntryKind::Home);
976        assert_eq!(reports[1].kind, HomeEntryKind::Config);
977        assert_eq!(reports[10].kind, HomeEntryKind::CompiledForms);
978        assert_eq!(reports[11].kind, HomeEntryKind::HostFacts);
979        assert_eq!(reports[11].path, home.join("host_facts"));
980        assert_eq!(reports[12].kind, HomeEntryKind::BinaryCache);
981        assert_eq!(reports[13].kind, HomeEntryKind::Formations);
982        assert_eq!(reports[14].kind, HomeEntryKind::Sessions);
983        assert_eq!(reports[15].kind, HomeEntryKind::Records);
984        assert_eq!(reports[16].kind, HomeEntryKind::Programs);
985        assert_eq!(reports[16].path, home.join("programs"));
986        assert_eq!(reports[17].kind, HomeEntryKind::Keys);
987        assert_eq!(reports[17].path, home.join("keys"));
988        assert!(reports
989            .iter()
990            .all(|report| report.state == HomeEntryState::Absent));
991    }
992
993    /// `programs/` holds pinned program downloads, is kept until removed by hand, and its
994    /// executable files may be `0700`: a store entry as `mur install` writes it is not wide, a
995    /// group-readable file under it is.
996    #[test]
997    fn murmur_home_programs_entry_names_the_store_and_allows_owner_executables() {
998        use std::os::unix::fs::PermissionsExt;
999        assert_eq!(HomeEntryKind::Programs.file_name(), Some("programs"));
1000        assert_eq!(HomeEntryKind::Programs.holds(), "pinned program downloads");
1001        assert_eq!(HomeEntryKind::Programs.kept(), None);
1002        assert!(HomeEntryKind::Programs.expected_private());
1003        assert!(!HomeEntryKind::Programs.safe_to_delete());
1004
1005        let dir = tempfile::tempdir().unwrap();
1006        let home = dir.path().join(".murmur");
1007        let files = home.join("programs/abc/files");
1008        std::fs::create_dir_all(&files).unwrap();
1009        for path in [
1010            home.clone(),
1011            home.join("programs"),
1012            home.join("programs/abc"),
1013            files.clone(),
1014        ] {
1015            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap();
1016        }
1017        for (name, mode) in [("prog", 0o700), ("data.txt", 0o600)] {
1018            std::fs::write(files.join(name), "x").unwrap();
1019            std::fs::set_permissions(files.join(name), std::fs::Permissions::from_mode(mode))
1020                .unwrap();
1021        }
1022        std::os::unix::fs::symlink("../files/prog", home.join("programs/abc/prog-link")).unwrap();
1023        let programs = |reports: Vec<HomeEntryReport>| {
1024            reports
1025                .into_iter()
1026                .find(|report| report.kind == HomeEntryKind::Programs)
1027                .unwrap()
1028                .state
1029        };
1030        assert!(matches!(
1031            programs(audit_murmur_home(&home)),
1032            HomeEntryState::Present { wide: false, wide_descendants, .. } if wide_descendants.is_empty()
1033        ));
1034        std::fs::set_permissions(
1035            files.join("data.txt"),
1036            std::fs::Permissions::from_mode(0o644),
1037        )
1038        .unwrap();
1039        assert!(matches!(
1040            programs(audit_murmur_home(&home)),
1041            HomeEntryState::Present { wide_descendants, .. }
1042                if wide_descendants.len() == 1 && wide_descendants[0].path == files.join("data.txt")
1043        ));
1044    }
1045
1046    /// Only `formations/`, `sessions/` and `records/` say how long they are kept, and only
1047    /// `compiled/` and `host_facts/` are safe to delete: a formation directory and a session's
1048    /// records are removed by retention, and what they hold is never written again; a session's
1049    /// runtime directory is removed when it ends.
1050    #[test]
1051    fn murmur_home_kinds_say_how_formations_are_kept_and_only_the_caches_are_safe_to_delete() {
1052        let every = HomeEntryKind::KNOWN
1053            .into_iter()
1054            .chain([HomeEntryKind::Home, HomeEntryKind::Unrecognised("x".into())]);
1055        for kind in every {
1056            assert_eq!(
1057                kind.kept().is_some(),
1058                matches!(
1059                    kind,
1060                    HomeEntryKind::Formations | HomeEntryKind::Sessions | HomeEntryKind::Records
1061                ),
1062                "{kind:?}"
1063            );
1064            assert_eq!(
1065                kind.safe_to_delete(),
1066                matches!(
1067                    kind,
1068                    HomeEntryKind::CompiledForms | HomeEntryKind::HostFacts
1069                ),
1070                "{kind:?}"
1071            );
1072            if kind.safe_to_delete() {
1073                assert!(kind.expected_private(), "{kind:?}");
1074            }
1075        }
1076        assert_eq!(
1077            HomeEntryKind::HostFacts.holds(),
1078            "facts about allowlisted host binaries"
1079        );
1080        assert_eq!(
1081            HomeEntryKind::Formations.kept(),
1082            Some(
1083                "each kept until its formation has ended and the entry member's trace.retain \
1084                 expires it"
1085            )
1086        );
1087    }
1088
1089    #[test]
1090    fn owned_within_refuses_an_owner_other_than_the_expected_uid() {
1091        let temp = tempfile::tempdir().unwrap();
1092        let path = temp.path().join("form.cwasm");
1093        std::fs::write(&path, b"form").unwrap();
1094        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
1095        let metadata = std::fs::metadata(&path).unwrap();
1096
1097        assert!(owned_within(&metadata, current_euid(), 0o600));
1098        assert!(!owned_within(
1099            &metadata,
1100            current_euid().wrapping_add(1),
1101            0o600
1102        ));
1103    }
1104
1105    #[test]
1106    fn held_private_accepts_only_an_owner_only_directory_reached_without_a_symlink() {
1107        let temp = tempfile::tempdir().unwrap();
1108        let dir = temp.path().join("forms");
1109        std::fs::create_dir(&dir).unwrap();
1110        std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap();
1111        assert!(held_private(&dir));
1112
1113        let link = temp.path().join("link");
1114        std::os::unix::fs::symlink(&dir, &link).unwrap();
1115        assert!(!held_private(&link));
1116
1117        std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o750)).unwrap();
1118        assert!(!held_private(&dir));
1119    }
1120
1121    #[test]
1122    fn a_directory_beneath_the_capsule_workdir_is_reachable_from_it() {
1123        let temp = tempfile::tempdir().unwrap();
1124        let workdir = temp.path().join("work");
1125        let inside = workdir.join(".murmur");
1126        let outside = temp.path().join("home");
1127        std::fs::create_dir_all(&inside).unwrap();
1128        std::fs::create_dir(&outside).unwrap();
1129
1130        assert!(reachable_from(&workdir, &workdir));
1131        assert!(reachable_from(&inside, &workdir));
1132        assert!(!reachable_from(&outside, &workdir));
1133        // A workdir staging has not created yet cannot contain an existing directory.
1134        assert!(!reachable_from(&outside, &temp.path().join("not-created")));
1135        // A directory that cannot be resolved is never used.
1136        assert!(reachable_from(&temp.path().join("missing"), &workdir));
1137    }
1138
1139    /// `formations/` holds members' working directories: known, owner-only down to each member's
1140    /// directory, and never offered for deletion. What a member's sessions write beneath its
1141    /// directory, at the umask's modes, is not reported.
1142    #[test]
1143    fn murmur_home_audit_knows_formations_as_owner_only() {
1144        let dir = tempfile::tempdir().unwrap();
1145        let home = dir.path().join(".murmur");
1146        wide_dir(&home, 0o700);
1147        wide_dir(&home.join("formations"), 0o700);
1148        wide_dir(&home.join("formations/frm_x"), 0o700);
1149        wide_dir(&home.join("formations/frm_x/worker"), 0o755);
1150        wide_dir(&home.join("formations/frm_x/worker/.murmur/ses_1"), 0o775);
1151        wide_file(
1152            &home.join("formations/frm_x/worker/.murmur/ses_1/trace.jsonl"),
1153            "{}\n",
1154            0o664,
1155        );
1156        wide_file(&home.join("formations/frm_x/worker/task.md"), "t", 0o664);
1157
1158        let report = audit_murmur_home(&home)
1159            .into_iter()
1160            .find(|report| report.kind == HomeEntryKind::Formations)
1161            .unwrap();
1162        assert_eq!(report.path, home.join("formations"));
1163        assert_eq!(
1164            report.kind.holds(),
1165            "formation members' working directories"
1166        );
1167        assert!(report.kind.expected_private());
1168        assert!(!report.kind.safe_to_delete());
1169        match report.state {
1170            HomeEntryState::Present {
1171                wide,
1172                wide_descendants,
1173                ..
1174            } => {
1175                assert!(!wide);
1176                assert_eq!(wide_descendants.len(), 1);
1177                assert!(wide_descendants[0].path.ends_with("frm_x/worker"));
1178            }
1179            other => panic!("{other:?}"),
1180        }
1181    }
1182
1183    #[test]
1184    fn murmur_home_audit_flags_wide_private_entries_and_never_non_private_ones() {
1185        let dir = tempfile::tempdir().unwrap();
1186        let home = dir.path().join(".murmur");
1187        wide_dir(&home, 0o755);
1188        wide_file(&home.join("config.yaml"), "credentials: {}\n", 0o644);
1189        wide_dir(&home.join("artifacts"), 0o777);
1190        wide_file(&home.join("artifacts/loose"), "x", 0o666);
1191        wide_dir(&home.join("spend"), 0o700);
1192        wide_file(&home.join("spend/2026-09-13.jsonl"), "", 0o600);
1193        wide_file(&home.join("nexus-config.json"), "{}", 0o644);
1194
1195        let reports = audit_murmur_home(&home);
1196        let find = |kind: &HomeEntryKind| {
1197            reports
1198                .iter()
1199                .find(|report| &report.kind == kind)
1200                .unwrap()
1201                .state
1202                .clone()
1203        };
1204        let wide = |state: HomeEntryState| match state {
1205            HomeEntryState::Present {
1206                wide,
1207                wide_descendants,
1208                ..
1209            } => (wide, wide_descendants.len()),
1210            other => panic!("{other:?}"),
1211        };
1212
1213        assert_eq!(wide(find(&HomeEntryKind::Home)), (true, 0));
1214        assert_eq!(wide(find(&HomeEntryKind::Config)), (true, 0));
1215        assert_eq!(wide(find(&HomeEntryKind::Artifacts)), (false, 0));
1216        assert_eq!(wide(find(&HomeEntryKind::Spend)), (false, 0));
1217        let last = reports.last().unwrap();
1218        assert_eq!(
1219            last.kind,
1220            HomeEntryKind::Unrecognised("nexus-config.json".to_string())
1221        );
1222        assert_eq!(wide(last.state.clone()), (false, 0));
1223        assert_eq!(mode_of(&home), 0o755, "the audit changes nothing");
1224    }
1225
1226    #[test]
1227    fn murmur_home_audit_does_not_follow_symlinks() {
1228        let dir = tempfile::tempdir().unwrap();
1229        let home = dir.path().join(".murmur");
1230        wide_dir(&home.join("state"), 0o700);
1231        let outside = dir.path().join("outside");
1232        wide_dir(&outside, 0o777);
1233        wide_file(&outside.join("secret"), "x", 0o666);
1234        std::os::unix::fs::symlink(&outside, home.join("state/link")).unwrap();
1235
1236        let reports = audit_murmur_home(&home);
1237        let state = &reports
1238            .iter()
1239            .find(|report| report.kind == HomeEntryKind::State)
1240            .unwrap()
1241            .state;
1242        assert!(
1243            matches!(state, HomeEntryState::Present { wide: false, wide_descendants, .. } if wide_descendants.is_empty()),
1244            "{state:?}"
1245        );
1246    }
1247
1248    #[test]
1249    fn murmur_home_audit_caps_wide_descendants_and_counts_the_rest() {
1250        let dir = tempfile::tempdir().unwrap();
1251        let home = dir.path().join(".murmur");
1252        let keys = home.join("deploy_keys");
1253        wide_dir(&keys, 0o700);
1254        for index in 0..25 {
1255            wide_file(&keys.join(format!("key_{index:02}")), "x", 0o644);
1256        }
1257
1258        let reports = audit_murmur_home(&home);
1259        let state = &reports
1260            .iter()
1261            .find(|report| report.kind == HomeEntryKind::DeployKeys)
1262            .unwrap()
1263            .state;
1264        match state {
1265            HomeEntryState::Present {
1266                wide,
1267                wide_descendants,
1268                wide_descendants_omitted,
1269                ..
1270            } => {
1271                assert!(!wide);
1272                assert_eq!(wide_descendants.len(), WIDE_DESCENDANT_CAP);
1273                assert_eq!(*wide_descendants_omitted, 5);
1274                assert!(wide_descendants
1275                    .iter()
1276                    .all(|entry| entry.mode == 0o644 && entry.allowed == 0o600));
1277            }
1278            other => panic!("{other:?}"),
1279        }
1280    }
1281
1282    #[test]
1283    fn murmur_home_wide_credential_file_warning_names_the_path_and_mode_only() {
1284        let path = Path::new("/home/someone/.murmur/config.yaml");
1285        assert_eq!(
1286            wide_credential_file_warning(path, "PROVIDER_KEY", 0o600),
1287            None
1288        );
1289        assert_eq!(
1290            wide_credential_file_warning(path, "PROVIDER_KEY", 0o100600),
1291            None
1292        );
1293
1294        let warning = wide_credential_file_warning(path, "PROVIDER_KEY", 0o100644).unwrap();
1295        assert!(warning.contains("warning[W-SEC-028]"), "{warning}");
1296        assert!(warning.contains("credentials.PROVIDER_KEY"), "{warning}");
1297        assert!(
1298            warning.contains("/home/someone/.murmur/config.yaml"),
1299            "{warning}"
1300        );
1301        assert!(warning.contains("mode 0644"), "{warning}");
1302        assert!(
1303            warning.contains("`chmod 600 /home/someone/.murmur/config.yaml`"),
1304            "{warning}"
1305        );
1306        assert!(warning.ends_with("#w-sec-028)"), "{warning}");
1307    }
1308
1309    #[test]
1310    fn murmur_home_wide_entry_warning_prints_the_chmod_for_the_allowed_mode() {
1311        let warning = wide_entry_warning(Path::new("/h/.murmur"), "the murmur home", 0o755, 0o700);
1312        assert_eq!(
1313            warning,
1314            "[capsule-runtime] warning[W-SEC-028]: /h/.murmur holds the murmur home and is mode \
1315             0755, which other accounts on this host can read; run `chmod 700 /h/.murmur` \
1316             (https://docs.murmur.nexus/reference/diagnostics/#w-sec-028)"
1317        );
1318    }
1319}