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