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    /// A top-level name this build writes nothing under.
337    Unrecognised(String),
338}
339
340impl HomeEntryKind {
341    /// Every kind with a fixed name, in the order the audit reports them after the home itself.
342    const KNOWN: [HomeEntryKind; 15] = [
343        Self::Config,
344        Self::DeployKeys,
345        Self::DeployStaging,
346        Self::Deployments,
347        Self::Spend,
348        Self::Conversations,
349        Self::Running,
350        Self::State,
351        Self::Artifacts,
352        Self::CompiledForms,
353        Self::HostFacts,
354        Self::BinaryCache,
355        Self::Formations,
356        Self::Sessions,
357        Self::Records,
358    ];
359
360    /// The entry's name under `~/.murmur`, or `None` for the home itself.
361    pub fn file_name(&self) -> Option<&str> {
362        Some(match self {
363            Self::Home => return None,
364            Self::Config => "config.yaml",
365            Self::DeployKeys => "deploy_keys",
366            Self::DeployStaging => "deploy_staging",
367            Self::Deployments => "deployments.json",
368            Self::Spend => "spend",
369            Self::Conversations => "conversations",
370            Self::Running => "running",
371            Self::State => "state",
372            Self::Artifacts => "artifacts",
373            Self::CompiledForms => "compiled",
374            Self::HostFacts => "host_facts",
375            Self::BinaryCache => "bin",
376            Self::Formations => crate::formation_launch::FORMATIONS_DIR,
377            Self::Sessions => crate::runtime_dir::SESSIONS_DIR_NAME,
378            Self::Records => crate::records_dir::RECORDS_DIR_NAME,
379            Self::Unrecognised(name) => name,
380        })
381    }
382
383    /// What the entry holds, as a noun phrase that completes "`<path>` holds …".
384    pub fn holds(&self) -> &'static str {
385        match self {
386            Self::Home => "the murmur home",
387            Self::Config => "provider credentials",
388            Self::DeployKeys => "SSH private keys",
389            Self::DeployStaging => "deployment staging copies",
390            Self::Deployments => "deployment records",
391            Self::Spend => "the machine spend ledger",
392            Self::Conversations => "conversation records",
393            Self::Running => "running-capsule records",
394            Self::State => "capsule state stores",
395            Self::Artifacts => "installed artifacts",
396            Self::CompiledForms => "compiled WASM cache",
397            Self::HostFacts => "facts about allowlisted host binaries",
398            Self::BinaryCache => "cached mur binaries",
399            Self::Formations => "formation members' working directories",
400            Self::Sessions => "running sessions' runtime directories",
401            Self::Records => "every session's trace, logs, eval scores and delegation outcome",
402            Self::Unrecognised(_) => "something not recognised by this build",
403        }
404    }
405
406    /// How long what the entry holds is kept, as a clause that follows [`Self::holds`] after a
407    /// comma, or `None` for an entry `mur` never removes on its own.
408    pub fn kept(&self) -> Option<&'static str> {
409        match self {
410            Self::Formations => Some(
411                "each kept until its formation has ended and the entry member's trace.retain \
412                 expires it",
413            ),
414            Self::Sessions => Some(
415                "each removed when its session ends, and by the next launch when a session \
416                 ended without removing its own",
417            ),
418            Self::Records => Some(
419                "each kept after its session ends, until its capsule's trace.retain expires it",
420            ),
421            _ => None,
422        }
423    }
424
425    /// Whether deleting the entry loses nothing, because `mur` writes it again when it needs it.
426    ///
427    /// `formations/` is not: deleting an ended formation's directory stops nothing, but its peers'
428    /// traces go with it and are never written again.
429    pub fn safe_to_delete(&self) -> bool {
430        matches!(self, Self::CompiledForms | Self::HostFacts)
431    }
432
433    /// Whether the entry, and everything beneath it down to [`Self::private_depth`], is expected
434    /// to be owner-only.
435    pub fn expected_private(&self) -> bool {
436        !matches!(
437            self,
438            Self::Artifacts | Self::BinaryCache | Self::Unrecognised(_)
439        )
440    }
441
442    /// How many directory levels below the entry the audit holds owner-only, or `None` for every
443    /// level.
444    ///
445    /// `formations/<frm_id>/<member>/` is held at two: the launcher makes those directories
446    /// owner-only, and below them is a member's ordinary working directory, whose sessions are
447    /// written at the umask's modes like any `--workdir`. The owner-only member directory is what
448    /// keeps them from other accounts, so walking further reports every file a session writes.
449    pub fn private_depth(&self) -> Option<usize> {
450        match self {
451            Self::Formations => Some(2),
452            // `sessions/<id>/` and the three directories in it are created owner-only; below
453            // them are the runtime's own files, written at the umask's modes.
454            Self::Sessions => Some(2),
455            // `records/<first component of a workdir root>/` is held owner-only; the rest of the
456            // mirrored path and each `<session-id>/` are created owner-only too, at a depth that
457            // varies with the workdir, and below them are the runtime's own files at the umask's
458            // modes. The owner-only `records/` is what keeps them from other accounts.
459            Self::Records => Some(1),
460            _ => None,
461        }
462    }
463}
464
465/// A path under an owner-only entry whose mode grants more than it should.
466#[derive(Debug, Clone, PartialEq, Eq)]
467pub struct WideEntry {
468    pub path: PathBuf,
469    /// Permission bits only, `& 0o777`.
470    pub mode: u32,
471    /// [`MURMUR_HOME_DIR_MODE`] for a directory, [`PRIVATE_FILE_MODE`] for a file.
472    pub allowed: u32,
473}
474
475#[derive(Debug, Clone, PartialEq, Eq)]
476pub enum HomeEntryState {
477    Absent,
478    Present {
479        /// Permission bits only, `& 0o777`, from `lstat`.
480        mode: u32,
481        /// Whether the entry itself is wider than its kind allows. Always `false` for a kind that
482        /// is not [`HomeEntryKind::expected_private`], and for a symlink.
483        wide: bool,
484        /// Wide paths beneath an owner-only directory, depth first, at most
485        /// [`WIDE_DESCENDANT_CAP`].
486        wide_descendants: Vec<WideEntry>,
487        /// Wide paths found beyond the cap.
488        wide_descendants_omitted: usize,
489    },
490    /// `lstat` failed for a reason other than the entry not existing.
491    Unreadable {
492        error: String,
493    },
494}
495
496#[derive(Debug, Clone, PartialEq, Eq)]
497pub struct HomeEntryReport {
498    pub path: PathBuf,
499    pub kind: HomeEntryKind,
500    pub state: HomeEntryState,
501}
502
503impl HomeEntryReport {
504    /// The mode this entry is held to: [`MURMUR_HOME_DIR_MODE`] for a directory,
505    /// [`PRIVATE_FILE_MODE`] otherwise.
506    pub fn allowed(&self) -> u32 {
507        allowed_for(&self.path)
508    }
509}
510
511/// Report `murmur_home` and every entry in it.
512///
513/// The home first, then every [`HomeEntryKind`] with a fixed name, absent ones included, then
514/// every other top-level name, sorted. Owner-only directories are walked without following
515/// symlinks; entries that are not expected to be owner-only get their mode and nothing else.
516/// Reads metadata only and changes nothing on disk.
517pub fn audit_murmur_home(murmur_home: &Path) -> Vec<HomeEntryReport> {
518    let mut reports = vec![report_entry(murmur_home.to_path_buf(), HomeEntryKind::Home)];
519
520    for kind in HomeEntryKind::KNOWN {
521        let name = kind.file_name().expect("a known kind has a fixed name");
522        reports.push(report_entry(murmur_home.join(name), kind));
523    }
524
525    let mut others: Vec<String> = std::fs::read_dir(murmur_home)
526        .map(|entries| {
527            entries
528                .filter_map(Result::ok)
529                .map(|entry| entry.file_name().to_string_lossy().into_owned())
530                .filter(|name| {
531                    !HomeEntryKind::KNOWN
532                        .iter()
533                        .any(|kind| kind.file_name() == Some(name.as_str()))
534                })
535                .collect()
536        })
537        .unwrap_or_default();
538    others.sort();
539    for name in others {
540        reports.push(report_entry(
541            murmur_home.join(&name),
542            HomeEntryKind::Unrecognised(name),
543        ));
544    }
545
546    reports
547}
548
549fn report_entry(path: PathBuf, kind: HomeEntryKind) -> HomeEntryReport {
550    use std::os::unix::fs::PermissionsExt;
551
552    let metadata = match std::fs::symlink_metadata(&path) {
553        Ok(metadata) => metadata,
554        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
555            return HomeEntryReport {
556                path,
557                kind,
558                state: HomeEntryState::Absent,
559            }
560        }
561        Err(err) => {
562            return HomeEntryReport {
563                path,
564                kind,
565                state: HomeEntryState::Unreadable {
566                    error: err.to_string(),
567                },
568            }
569        }
570    };
571    let mode = metadata.permissions().mode() & 0o777;
572    let private = kind.expected_private();
573    let file_type = metadata.file_type();
574    let wide =
575        private && !file_type.is_symlink() && is_wider_than(mode, allowed_for_type(file_type));
576
577    let mut wide_descendants = Vec::new();
578    let mut omitted = 0;
579    // The home's own entries are reported one by one, so only an entry below it is walked.
580    if private && file_type.is_dir() && kind != HomeEntryKind::Home {
581        collect_wide_descendants(
582            &path,
583            kind.private_depth(),
584            &mut wide_descendants,
585            &mut omitted,
586        );
587    }
588
589    HomeEntryReport {
590        path,
591        kind,
592        state: HomeEntryState::Present {
593            mode,
594            wide,
595            wide_descendants,
596            wide_descendants_omitted: omitted,
597        },
598    }
599}
600
601/// Wide paths beneath `dir`, `depth` levels down, or every level for `None`.
602fn collect_wide_descendants(
603    dir: &Path,
604    depth: Option<usize>,
605    found: &mut Vec<WideEntry>,
606    omitted: &mut usize,
607) {
608    use std::os::unix::fs::PermissionsExt;
609
610    if depth == Some(0) {
611        return;
612    }
613    let Ok(entries) = std::fs::read_dir(dir) else {
614        return;
615    };
616    let mut paths: Vec<PathBuf> = entries.filter_map(Result::ok).map(|e| e.path()).collect();
617    paths.sort();
618    for path in paths {
619        let Ok(metadata) = std::fs::symlink_metadata(&path) else {
620            continue;
621        };
622        let file_type = metadata.file_type();
623        if file_type.is_symlink() || !(file_type.is_dir() || file_type.is_file()) {
624            continue;
625        }
626        let mode = metadata.permissions().mode() & 0o777;
627        let allowed = allowed_for_type(file_type);
628        if is_wider_than(mode, allowed) {
629            if found.len() < WIDE_DESCENDANT_CAP {
630                found.push(WideEntry {
631                    path: path.clone(),
632                    mode,
633                    allowed,
634                });
635            } else {
636                *omitted += 1;
637            }
638        }
639        if file_type.is_dir() {
640            collect_wide_descendants(&path, depth.map(|depth| depth - 1), found, omitted);
641        }
642    }
643}
644
645fn allowed_for_type(file_type: std::fs::FileType) -> u32 {
646    if file_type.is_dir() {
647        MURMUR_HOME_DIR_MODE
648    } else {
649        PRIVATE_FILE_MODE
650    }
651}
652
653fn allowed_for(path: &Path) -> u32 {
654    match std::fs::symlink_metadata(path) {
655        Ok(metadata) => allowed_for_type(metadata.file_type()),
656        Err(_) => PRIVATE_FILE_MODE,
657    }
658}
659
660/// The `W-SEC-028` line for a path under `~/.murmur` that holds `holds` and is wider than
661/// `allowed`. Names the path and its mode, never its contents.
662pub fn wide_entry_warning(path: &Path, holds: &str, mode: u32, allowed: u32) -> String {
663    let path = path.display();
664    format!(
665        "[capsule-runtime] warning[{W_SEC_028}]: {path} holds {holds} and is mode {:04o}, which \
666         other accounts on this host can read; run `chmod {:o} {path}` ({})",
667        mode & 0o777,
668        allowed,
669        security_warning_link(W_SEC_028)
670    )
671}
672
673/// Print `W-SEC-028` when the config file an inference credential was just read from grants any
674/// group or other bit. Never a refusal, and never names the value.
675pub fn warn_on_wide_credential_file(path: &Path, name: &str, mode: u32) {
676    if let Some(warning) = wide_credential_file_warning(path, name, mode) {
677        crate::runtime_err!("{warning}");
678    }
679}
680
681fn wide_credential_file_warning(path: &Path, name: &str, mode: u32) -> Option<String> {
682    if !is_wider_than(mode, PRIVATE_FILE_MODE) {
683        return None;
684    }
685    let path = path.display();
686    Some(format!(
687        "[capsule-runtime] warning[{W_SEC_028}]: the credential credentials.{name} is \
688         read from {path}, which is mode {:04o} and readable by other accounts on this host; run \
689         `chmod 600 {path}` ({})",
690        mode & 0o777,
691        security_warning_link(W_SEC_028)
692    ))
693}
694
695/// Runs one `#[ignore]`d test of this crate in a child process with `HOME` set to `home`.
696///
697/// `HOME` is process-global, and every staging test in this binary resolves `~/.murmur` from it,
698/// so a test that needs a scratch home gets its own process rather than a lock no other test
699/// takes. The child test returns without asserting unless [`INNER_HOME_ENV`] is set.
700#[cfg(test)]
701pub(crate) fn run_with_home(test: &str, home: &Path) {
702    let exe = std::env::current_exe().expect("test binary path");
703    let output = std::process::Command::new(exe)
704        .args([
705            test,
706            "--exact",
707            "--ignored",
708            "--test-threads=1",
709            "--nocapture",
710        ])
711        .env("HOME", home)
712        .env(INNER_HOME_ENV, "1")
713        .output()
714        .expect("re-exec the test binary with a scratch HOME");
715    let stdout = String::from_utf8_lossy(&output.stdout);
716    let stderr = String::from_utf8_lossy(&output.stderr);
717    assert!(
718        output.status.success(),
719        "{test} failed\n--- stdout ---\n{stdout}\n--- stderr ---\n{stderr}"
720    );
721    assert!(
722        stdout.contains("1 passed"),
723        "{test} was not selected\n--- stdout ---\n{stdout}"
724    );
725}
726
727/// Set only in the child [`run_with_home`] starts.
728#[cfg(test)]
729pub(crate) const INNER_HOME_ENV: &str = "MURMUR_TEST_INNER_HOME";
730
731/// Whether this process is a [`run_with_home`] child.
732#[cfg(test)]
733pub(crate) fn in_scratch_home() -> bool {
734    std::env::var_os(INNER_HOME_ENV).is_some()
735}
736
737/// `path`'s permission bits.
738#[cfg(test)]
739pub(crate) fn mode_of(path: &Path) -> u32 {
740    use std::os::unix::fs::PermissionsExt;
741    std::fs::symlink_metadata(path)
742        .unwrap_or_else(|err| panic!("{}: {err}", path.display()))
743        .permissions()
744        .mode()
745        & 0o777
746}
747
748/// Creates `path` as a directory at `mode`.
749#[cfg(test)]
750pub(crate) fn wide_dir(path: &Path, mode: u32) {
751    use std::os::unix::fs::PermissionsExt;
752    std::fs::create_dir_all(path).unwrap();
753    std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).unwrap();
754}
755
756/// Writes `path` with `contents` at `mode`.
757#[cfg(test)]
758pub(crate) fn wide_file(path: &Path, contents: &str, mode: u32) {
759    use std::os::unix::fs::PermissionsExt;
760    std::fs::write(path, contents).unwrap();
761    std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).unwrap();
762}
763
764#[cfg(test)]
765mod tests {
766    use super::*;
767    use std::os::unix::fs::PermissionsExt;
768
769    #[test]
770    fn murmur_home_is_wider_than_truth_table() {
771        for (mode, allowed, wide) in [
772            (0o600, 0o600, false),
773            (0o644, 0o600, true),
774            (0o640, 0o600, true),
775            (0o700, 0o700, false),
776            (0o711, 0o700, true),
777            (0o400, 0o600, false),
778        ] {
779            assert_eq!(
780                is_wider_than(mode, allowed),
781                wide,
782                "{mode:04o} against {allowed:04o}"
783            );
784        }
785    }
786
787    #[test]
788    fn ensure_murmur_home_creates_and_tightens_the_home_directory() {
789        let wide = tempfile::tempdir().unwrap();
790        wide_dir(&wide.path().join(".murmur"), 0o755);
791        run_with_home(
792            "murmur_home::tests::inner_ensure_murmur_home_holds_the_home_at_0700",
793            wide.path(),
794        );
795        assert_eq!(mode_of(&wide.path().join(".murmur")), 0o700);
796
797        let missing = tempfile::tempdir().unwrap();
798        run_with_home(
799            "murmur_home::tests::inner_ensure_murmur_home_holds_the_home_at_0700",
800            missing.path(),
801        );
802        assert_eq!(mode_of(&missing.path().join(".murmur")), 0o700);
803
804        let closed = tempfile::tempdir().unwrap();
805        wide_dir(&closed.path().join(".murmur"), 0o555);
806        run_with_home(
807            "murmur_home::tests::inner_ensure_murmur_home_holds_the_home_at_0700",
808            closed.path(),
809        );
810        assert_eq!(
811            mode_of(&closed.path().join(".murmur")),
812            0o500,
813            "an owner-closed home loses group and other bits and is not reopened"
814        );
815    }
816
817    #[test]
818    #[ignore = "run by ensure_murmur_home_creates_and_tightens_the_home_directory"]
819    fn inner_ensure_murmur_home_holds_the_home_at_0700() {
820        if !in_scratch_home() {
821            return;
822        }
823        let home = ensure_murmur_home().unwrap();
824        assert!(home.ends_with(".murmur"));
825        assert_eq!(mode_of(&home) & 0o077, 0);
826    }
827
828    #[test]
829    fn murmur_home_write_private_file_replaces_a_wide_file_owner_only() {
830        use std::os::unix::fs::MetadataExt;
831        let dir = tempfile::tempdir().unwrap();
832        let path = dir.path().join("config.yaml");
833        wide_file(&path, "old", 0o644);
834        let before = std::fs::metadata(&path).unwrap().ino();
835
836        write_private_file(&path, b"new").unwrap();
837
838        assert_eq!(std::fs::read_to_string(&path).unwrap(), "new");
839        assert_eq!(mode_of(&path), 0o600);
840        assert_ne!(std::fs::metadata(&path).unwrap().ino(), before);
841    }
842
843    /// Re-runs `test` with `HOME` removed from its environment, on [`run_with_home`]'s terms.
844    fn run_without_home(test: &str) {
845        let exe = std::env::current_exe().expect("test binary path");
846        let output = std::process::Command::new(exe)
847            .args([
848                test,
849                "--exact",
850                "--ignored",
851                "--test-threads=1",
852                "--nocapture",
853            ])
854            .env_remove("HOME")
855            .env(INNER_HOME_ENV, "1")
856            .output()
857            .expect("re-exec the test binary with no HOME");
858        let stdout = String::from_utf8_lossy(&output.stdout);
859        assert!(
860            output.status.success() && stdout.contains("1 passed"),
861            "{test} failed\n{stdout}\n{}",
862            String::from_utf8_lossy(&output.stderr)
863        );
864    }
865
866    /// Each way `HOME` cannot hold `~/.murmur` names `HOME` and its cause, and creates nothing.
867    #[test]
868    fn an_unusable_home_names_home_and_its_cause() {
869        run_without_home("murmur_home::tests::an_unset_home_inner");
870        let relative = std::path::Path::new("relative-home");
871        run_with_home("murmur_home::tests::a_relative_home_inner", relative);
872        let closed = tempfile::tempdir().unwrap();
873        run_with_home("murmur_home::tests::a_closed_home_inner", closed.path());
874    }
875
876    #[test]
877    #[ignore = "run by an_unusable_home_names_home_and_its_cause"]
878    fn an_unset_home_inner() {
879        if !in_scratch_home() {
880            return;
881        }
882        let error = usable_murmur_home().unwrap_err();
883        assert_eq!(error, HomeUnusable::NotSet);
884        assert_eq!(
885            error.to_string(),
886            "HOME is not set, so there is no ~/.murmur to hold this session's runtime and \
887             records directories"
888        );
889        assert!(crate::runtime_dir::sessions_dir().is_err());
890        assert!(crate::records_dir::records_group(Path::new("/w")).is_err());
891    }
892
893    #[test]
894    #[ignore = "run by an_unusable_home_names_home_and_its_cause"]
895    fn a_relative_home_inner() {
896        if !in_scratch_home() {
897            return;
898        }
899        let error = usable_murmur_home().unwrap_err();
900        assert_eq!(
901            error.to_string(),
902            "HOME is 'relative-home', which is not an absolute path, so there is no ~/.murmur to \
903             hold this session's runtime and records directories"
904        );
905        assert!(!Path::new("relative-home").exists());
906    }
907
908    #[test]
909    #[ignore = "run by an_unusable_home_names_home_and_its_cause"]
910    fn a_closed_home_inner() {
911        if !in_scratch_home() {
912            return;
913        }
914        let home = PathBuf::from(std::env::var_os("HOME").unwrap());
915        let murmur = home.join(".murmur");
916        std::fs::create_dir(&murmur).unwrap();
917        std::fs::set_permissions(&murmur, std::fs::Permissions::from_mode(0o500)).unwrap();
918        // Root writes through any mode, and then there is nothing to refuse.
919        if std::fs::write(murmur.join("probe"), b"x").is_err() {
920            let error = usable_murmur_home().unwrap_err();
921            let text = error.to_string();
922            assert!(
923                text.starts_with(&format!(
924                    "~/.murmur ({}) cannot hold this session's runtime and records directories: ",
925                    murmur.display()
926                )),
927                "{text}"
928            );
929            assert!(text.contains("cannot be written"), "{text}");
930        }
931        std::fs::set_permissions(&murmur, std::fs::Permissions::from_mode(0o700)).unwrap();
932        assert_eq!(usable_murmur_home().unwrap(), murmur);
933    }
934
935    #[test]
936    fn murmur_home_audit_reports_absent_entries_in_kind_order() {
937        let dir = tempfile::tempdir().unwrap();
938        let home = dir.path().join(".murmur");
939
940        let reports = audit_murmur_home(&home);
941
942        assert_eq!(reports.len(), 16);
943        assert_eq!(reports[0].kind, HomeEntryKind::Home);
944        assert_eq!(reports[1].kind, HomeEntryKind::Config);
945        assert_eq!(reports[10].kind, HomeEntryKind::CompiledForms);
946        assert_eq!(reports[11].kind, HomeEntryKind::HostFacts);
947        assert_eq!(reports[11].path, home.join("host_facts"));
948        assert_eq!(reports[12].kind, HomeEntryKind::BinaryCache);
949        assert_eq!(reports[13].kind, HomeEntryKind::Formations);
950        assert_eq!(reports[14].kind, HomeEntryKind::Sessions);
951        assert_eq!(reports[15].kind, HomeEntryKind::Records);
952        assert!(reports
953            .iter()
954            .all(|report| report.state == HomeEntryState::Absent));
955    }
956
957    /// Only `formations/`, `sessions/` and `records/` say how long they are kept, and only
958    /// `compiled/` and `host_facts/` are safe to delete: a formation directory and a session's
959    /// records are removed by retention, and what they hold is never written again; a session's
960    /// runtime directory is removed when it ends.
961    #[test]
962    fn murmur_home_kinds_say_how_formations_are_kept_and_only_the_caches_are_safe_to_delete() {
963        let every = HomeEntryKind::KNOWN
964            .into_iter()
965            .chain([HomeEntryKind::Home, HomeEntryKind::Unrecognised("x".into())]);
966        for kind in every {
967            assert_eq!(
968                kind.kept().is_some(),
969                matches!(
970                    kind,
971                    HomeEntryKind::Formations | HomeEntryKind::Sessions | HomeEntryKind::Records
972                ),
973                "{kind:?}"
974            );
975            assert_eq!(
976                kind.safe_to_delete(),
977                matches!(
978                    kind,
979                    HomeEntryKind::CompiledForms | HomeEntryKind::HostFacts
980                ),
981                "{kind:?}"
982            );
983            if kind.safe_to_delete() {
984                assert!(kind.expected_private(), "{kind:?}");
985            }
986        }
987        assert_eq!(
988            HomeEntryKind::HostFacts.holds(),
989            "facts about allowlisted host binaries"
990        );
991        assert_eq!(
992            HomeEntryKind::Formations.kept(),
993            Some(
994                "each kept until its formation has ended and the entry member's trace.retain \
995                 expires it"
996            )
997        );
998    }
999
1000    #[test]
1001    fn owned_within_refuses_an_owner_other_than_the_expected_uid() {
1002        let temp = tempfile::tempdir().unwrap();
1003        let path = temp.path().join("form.cwasm");
1004        std::fs::write(&path, b"form").unwrap();
1005        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
1006        let metadata = std::fs::metadata(&path).unwrap();
1007
1008        assert!(owned_within(&metadata, current_euid(), 0o600));
1009        assert!(!owned_within(
1010            &metadata,
1011            current_euid().wrapping_add(1),
1012            0o600
1013        ));
1014    }
1015
1016    #[test]
1017    fn held_private_accepts_only_an_owner_only_directory_reached_without_a_symlink() {
1018        let temp = tempfile::tempdir().unwrap();
1019        let dir = temp.path().join("forms");
1020        std::fs::create_dir(&dir).unwrap();
1021        std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap();
1022        assert!(held_private(&dir));
1023
1024        let link = temp.path().join("link");
1025        std::os::unix::fs::symlink(&dir, &link).unwrap();
1026        assert!(!held_private(&link));
1027
1028        std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o750)).unwrap();
1029        assert!(!held_private(&dir));
1030    }
1031
1032    #[test]
1033    fn a_directory_beneath_the_capsule_workdir_is_reachable_from_it() {
1034        let temp = tempfile::tempdir().unwrap();
1035        let workdir = temp.path().join("work");
1036        let inside = workdir.join(".murmur");
1037        let outside = temp.path().join("home");
1038        std::fs::create_dir_all(&inside).unwrap();
1039        std::fs::create_dir(&outside).unwrap();
1040
1041        assert!(reachable_from(&workdir, &workdir));
1042        assert!(reachable_from(&inside, &workdir));
1043        assert!(!reachable_from(&outside, &workdir));
1044        // A workdir staging has not created yet cannot contain an existing directory.
1045        assert!(!reachable_from(&outside, &temp.path().join("not-created")));
1046        // A directory that cannot be resolved is never used.
1047        assert!(reachable_from(&temp.path().join("missing"), &workdir));
1048    }
1049
1050    /// `formations/` holds members' working directories: known, owner-only down to each member's
1051    /// directory, and never offered for deletion. What a member's sessions write beneath its
1052    /// directory, at the umask's modes, is not reported.
1053    #[test]
1054    fn murmur_home_audit_knows_formations_as_owner_only() {
1055        let dir = tempfile::tempdir().unwrap();
1056        let home = dir.path().join(".murmur");
1057        wide_dir(&home, 0o700);
1058        wide_dir(&home.join("formations"), 0o700);
1059        wide_dir(&home.join("formations/frm_x"), 0o700);
1060        wide_dir(&home.join("formations/frm_x/worker"), 0o755);
1061        wide_dir(&home.join("formations/frm_x/worker/.murmur/ses_1"), 0o775);
1062        wide_file(
1063            &home.join("formations/frm_x/worker/.murmur/ses_1/trace.jsonl"),
1064            "{}\n",
1065            0o664,
1066        );
1067        wide_file(&home.join("formations/frm_x/worker/task.md"), "t", 0o664);
1068
1069        let report = audit_murmur_home(&home)
1070            .into_iter()
1071            .find(|report| report.kind == HomeEntryKind::Formations)
1072            .unwrap();
1073        assert_eq!(report.path, home.join("formations"));
1074        assert_eq!(
1075            report.kind.holds(),
1076            "formation members' working directories"
1077        );
1078        assert!(report.kind.expected_private());
1079        assert!(!report.kind.safe_to_delete());
1080        match report.state {
1081            HomeEntryState::Present {
1082                wide,
1083                wide_descendants,
1084                ..
1085            } => {
1086                assert!(!wide);
1087                assert_eq!(wide_descendants.len(), 1);
1088                assert!(wide_descendants[0].path.ends_with("frm_x/worker"));
1089            }
1090            other => panic!("{other:?}"),
1091        }
1092    }
1093
1094    #[test]
1095    fn murmur_home_audit_flags_wide_private_entries_and_never_non_private_ones() {
1096        let dir = tempfile::tempdir().unwrap();
1097        let home = dir.path().join(".murmur");
1098        wide_dir(&home, 0o755);
1099        wide_file(&home.join("config.yaml"), "credentials: {}\n", 0o644);
1100        wide_dir(&home.join("artifacts"), 0o777);
1101        wide_file(&home.join("artifacts/loose"), "x", 0o666);
1102        wide_dir(&home.join("spend"), 0o700);
1103        wide_file(&home.join("spend/2026-09-13.jsonl"), "", 0o600);
1104        wide_file(&home.join("nexus-config.json"), "{}", 0o644);
1105
1106        let reports = audit_murmur_home(&home);
1107        let find = |kind: &HomeEntryKind| {
1108            reports
1109                .iter()
1110                .find(|report| &report.kind == kind)
1111                .unwrap()
1112                .state
1113                .clone()
1114        };
1115        let wide = |state: HomeEntryState| match state {
1116            HomeEntryState::Present {
1117                wide,
1118                wide_descendants,
1119                ..
1120            } => (wide, wide_descendants.len()),
1121            other => panic!("{other:?}"),
1122        };
1123
1124        assert_eq!(wide(find(&HomeEntryKind::Home)), (true, 0));
1125        assert_eq!(wide(find(&HomeEntryKind::Config)), (true, 0));
1126        assert_eq!(wide(find(&HomeEntryKind::Artifacts)), (false, 0));
1127        assert_eq!(wide(find(&HomeEntryKind::Spend)), (false, 0));
1128        let last = reports.last().unwrap();
1129        assert_eq!(
1130            last.kind,
1131            HomeEntryKind::Unrecognised("nexus-config.json".to_string())
1132        );
1133        assert_eq!(wide(last.state.clone()), (false, 0));
1134        assert_eq!(mode_of(&home), 0o755, "the audit changes nothing");
1135    }
1136
1137    #[test]
1138    fn murmur_home_audit_does_not_follow_symlinks() {
1139        let dir = tempfile::tempdir().unwrap();
1140        let home = dir.path().join(".murmur");
1141        wide_dir(&home.join("state"), 0o700);
1142        let outside = dir.path().join("outside");
1143        wide_dir(&outside, 0o777);
1144        wide_file(&outside.join("secret"), "x", 0o666);
1145        std::os::unix::fs::symlink(&outside, home.join("state/link")).unwrap();
1146
1147        let reports = audit_murmur_home(&home);
1148        let state = &reports
1149            .iter()
1150            .find(|report| report.kind == HomeEntryKind::State)
1151            .unwrap()
1152            .state;
1153        assert!(
1154            matches!(state, HomeEntryState::Present { wide: false, wide_descendants, .. } if wide_descendants.is_empty()),
1155            "{state:?}"
1156        );
1157    }
1158
1159    #[test]
1160    fn murmur_home_audit_caps_wide_descendants_and_counts_the_rest() {
1161        let dir = tempfile::tempdir().unwrap();
1162        let home = dir.path().join(".murmur");
1163        let keys = home.join("deploy_keys");
1164        wide_dir(&keys, 0o700);
1165        for index in 0..25 {
1166            wide_file(&keys.join(format!("key_{index:02}")), "x", 0o644);
1167        }
1168
1169        let reports = audit_murmur_home(&home);
1170        let state = &reports
1171            .iter()
1172            .find(|report| report.kind == HomeEntryKind::DeployKeys)
1173            .unwrap()
1174            .state;
1175        match state {
1176            HomeEntryState::Present {
1177                wide,
1178                wide_descendants,
1179                wide_descendants_omitted,
1180                ..
1181            } => {
1182                assert!(!wide);
1183                assert_eq!(wide_descendants.len(), WIDE_DESCENDANT_CAP);
1184                assert_eq!(*wide_descendants_omitted, 5);
1185                assert!(wide_descendants
1186                    .iter()
1187                    .all(|entry| entry.mode == 0o644 && entry.allowed == 0o600));
1188            }
1189            other => panic!("{other:?}"),
1190        }
1191    }
1192
1193    #[test]
1194    fn murmur_home_wide_credential_file_warning_names_the_path_and_mode_only() {
1195        let path = Path::new("/home/someone/.murmur/config.yaml");
1196        assert_eq!(
1197            wide_credential_file_warning(path, "PROVIDER_KEY", 0o600),
1198            None
1199        );
1200        assert_eq!(
1201            wide_credential_file_warning(path, "PROVIDER_KEY", 0o100600),
1202            None
1203        );
1204
1205        let warning = wide_credential_file_warning(path, "PROVIDER_KEY", 0o100644).unwrap();
1206        assert!(warning.contains("warning[W-SEC-028]"), "{warning}");
1207        assert!(warning.contains("credentials.PROVIDER_KEY"), "{warning}");
1208        assert!(
1209            warning.contains("/home/someone/.murmur/config.yaml"),
1210            "{warning}"
1211        );
1212        assert!(warning.contains("mode 0644"), "{warning}");
1213        assert!(
1214            warning.contains("`chmod 600 /home/someone/.murmur/config.yaml`"),
1215            "{warning}"
1216        );
1217        assert!(warning.ends_with("#w-sec-028)"), "{warning}");
1218    }
1219
1220    #[test]
1221    fn murmur_home_wide_entry_warning_prints_the_chmod_for_the_allowed_mode() {
1222        let warning = wide_entry_warning(Path::new("/h/.murmur"), "the murmur home", 0o755, 0o700);
1223        assert_eq!(
1224            warning,
1225            "[capsule-runtime] warning[W-SEC-028]: /h/.murmur holds the murmur home and is mode \
1226             0755, which other accounts on this host can read; run `chmod 700 /h/.murmur` \
1227             (https://docs.murmur.nexus/reference/diagnostics/#w-sec-028)"
1228        );
1229    }
1230}