1use std::path::{Path, PathBuf};
13
14use murmur_artifact::{security_warning_link, W_SEC_028};
15
16pub const MURMUR_HOME_DIR_MODE: u32 = 0o700;
18
19pub const PRIVATE_FILE_MODE: u32 = 0o600;
21
22const WIDE_DESCENDANT_CAP: usize = 20;
25
26pub 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
58pub fn hold_private_dir(path: &Path) -> Result<(), String> {
61 crate::state_store::ensure_private_dir(path, MURMUR_HOME_DIR_MODE)
62}
63
64#[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
84pub 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
88pub 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
119pub(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
134fn 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
148pub 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#[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
215pub fn is_wider_than(mode: u32, allowed: u32) -> bool {
217 (mode & 0o777) & !allowed != 0
218}
219
220#[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 Unrecognised(String),
240}
241
242impl HomeEntryKind {
243 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 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 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 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 pub fn safe_to_delete(&self) -> bool {
329 matches!(self, Self::CompiledForms)
330 }
331
332 pub fn expected_private(&self) -> bool {
335 !matches!(
336 self,
337 Self::Artifacts | Self::BinaryCache | Self::Unrecognised(_)
338 )
339 }
340
341 pub fn private_depth(&self) -> Option<usize> {
349 match self {
350 Self::Formations => Some(2),
351 Self::Sessions => Some(2),
354 Self::Records => Some(1),
359 _ => None,
360 }
361 }
362}
363
364#[derive(Debug, Clone, PartialEq, Eq)]
366pub struct WideEntry {
367 pub path: PathBuf,
368 pub mode: u32,
370 pub allowed: u32,
372}
373
374#[derive(Debug, Clone, PartialEq, Eq)]
375pub enum HomeEntryState {
376 Absent,
377 Present {
378 mode: u32,
380 wide: bool,
383 wide_descendants: Vec<WideEntry>,
386 wide_descendants_omitted: usize,
388 },
389 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 pub fn allowed(&self) -> u32 {
406 allowed_for(&self.path)
407 }
408}
409
410pub 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 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
500fn 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
559pub 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
572pub 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#[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#[cfg(test)]
628pub(crate) const INNER_HOME_ENV: &str = "MURMUR_TEST_INNER_HOME";
629
630#[cfg(test)]
632pub(crate) fn in_scratch_home() -> bool {
633 std::env::var_os(INNER_HOME_ENV).is_some()
634}
635
636#[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#[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#[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 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 #[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 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 #[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 #[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}