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(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 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
257pub(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
268pub(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
276pub(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 unsafe { libc::geteuid() }
287}
288
289pub(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
298pub(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
312pub fn is_wider_than(mode: u32, allowed: u32) -> bool {
314 (mode & 0o777) & !allowed != 0
315}
316
317#[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 Unrecognised(String),
338}
339
340impl HomeEntryKind {
341 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 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 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 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 pub fn safe_to_delete(&self) -> bool {
430 matches!(self, Self::CompiledForms | Self::HostFacts)
431 }
432
433 pub fn expected_private(&self) -> bool {
436 !matches!(
437 self,
438 Self::Artifacts | Self::BinaryCache | Self::Unrecognised(_)
439 )
440 }
441
442 pub fn private_depth(&self) -> Option<usize> {
450 match self {
451 Self::Formations => Some(2),
452 Self::Sessions => Some(2),
455 Self::Records => Some(1),
460 _ => None,
461 }
462 }
463}
464
465#[derive(Debug, Clone, PartialEq, Eq)]
467pub struct WideEntry {
468 pub path: PathBuf,
469 pub mode: u32,
471 pub allowed: u32,
473}
474
475#[derive(Debug, Clone, PartialEq, Eq)]
476pub enum HomeEntryState {
477 Absent,
478 Present {
479 mode: u32,
481 wide: bool,
484 wide_descendants: Vec<WideEntry>,
487 wide_descendants_omitted: usize,
489 },
490 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 pub fn allowed(&self) -> u32 {
507 allowed_for(&self.path)
508 }
509}
510
511pub 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 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
601fn 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
660pub 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
673pub 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#[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#[cfg(test)]
729pub(crate) const INNER_HOME_ENV: &str = "MURMUR_TEST_INNER_HOME";
730
731#[cfg(test)]
733pub(crate) fn in_scratch_home() -> bool {
734 std::env::var_os(INNER_HOME_ENV).is_some()
735}
736
737#[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#[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#[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 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 #[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 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 #[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 assert!(!reachable_from(&outside, &temp.path().join("not-created")));
1046 assert!(reachable_from(&temp.path().join("missing"), &workdir));
1048 }
1049
1050 #[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}