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 Programs,
337 Keys,
338 Unrecognised(String),
340}
341
342impl HomeEntryKind {
343 const KNOWN: [HomeEntryKind; 17] = [
345 Self::Config,
346 Self::DeployKeys,
347 Self::DeployStaging,
348 Self::Deployments,
349 Self::Spend,
350 Self::Conversations,
351 Self::Running,
352 Self::State,
353 Self::Artifacts,
354 Self::CompiledForms,
355 Self::HostFacts,
356 Self::BinaryCache,
357 Self::Formations,
358 Self::Sessions,
359 Self::Records,
360 Self::Programs,
361 Self::Keys,
362 ];
363
364 pub fn file_name(&self) -> Option<&str> {
366 Some(match self {
367 Self::Home => return None,
368 Self::Config => "config.yaml",
369 Self::DeployKeys => "deploy_keys",
370 Self::DeployStaging => "deploy_staging",
371 Self::Deployments => "deployments.json",
372 Self::Spend => "spend",
373 Self::Conversations => "conversations",
374 Self::Running => "running",
375 Self::State => "state",
376 Self::Artifacts => "artifacts",
377 Self::CompiledForms => "compiled",
378 Self::HostFacts => "host_facts",
379 Self::BinaryCache => "bin",
380 Self::Formations => crate::formation_launch::FORMATIONS_DIR,
381 Self::Sessions => crate::runtime_dir::SESSIONS_DIR_NAME,
382 Self::Records => crate::records_dir::RECORDS_DIR_NAME,
383 Self::Programs => crate::pinned_programs::PROGRAMS_DIR,
384 Self::Keys => crate::record_seal::KEYS_DIR,
385 Self::Unrecognised(name) => name,
386 })
387 }
388
389 pub fn holds(&self) -> &'static str {
391 match self {
392 Self::Home => "the murmur home",
393 Self::Config => "provider credentials",
394 Self::DeployKeys => "SSH private keys",
395 Self::DeployStaging => "deployment staging copies",
396 Self::Deployments => "deployment records",
397 Self::Spend => "the machine spend ledger",
398 Self::Conversations => "conversation records",
399 Self::Running => "running-capsule records",
400 Self::State => "capsule state stores",
401 Self::Artifacts => "installed artifacts",
402 Self::CompiledForms => "compiled WASM cache",
403 Self::HostFacts => "facts about allowlisted host binaries",
404 Self::BinaryCache => "cached mur binaries",
405 Self::Formations => "formation members' working directories",
406 Self::Sessions => "running sessions' runtime directories",
407 Self::Records => "every session's trace, logs, eval scores and delegation outcome",
408 Self::Programs => "pinned program downloads",
409 Self::Keys => "the record-seal key",
410 Self::Unrecognised(_) => "something not recognised by this build",
411 }
412 }
413
414 pub fn kept(&self) -> Option<&'static str> {
417 match self {
418 Self::Formations => Some(
419 "each kept until its formation has ended and the entry member's trace.retain \
420 expires it",
421 ),
422 Self::Sessions => Some(
423 "each removed when its session ends, and by the next launch when a session \
424 ended without removing its own",
425 ),
426 Self::Records => Some(
427 "each kept after its session ends, until its capsule's trace.retain expires it",
428 ),
429 _ => None,
430 }
431 }
432
433 pub fn safe_to_delete(&self) -> bool {
438 matches!(self, Self::CompiledForms | Self::HostFacts)
439 }
440
441 pub fn expected_private(&self) -> bool {
444 !matches!(
445 self,
446 Self::Artifacts | Self::BinaryCache | Self::Unrecognised(_)
447 )
448 }
449
450 pub fn allowed_file_mode(&self) -> u32 {
453 match self {
454 Self::Programs => crate::pinned_programs::EXECUTABLE_FILE_MODE,
455 Self::Keys => crate::record_seal::PUBLIC_KEY_MODE,
458 _ => PRIVATE_FILE_MODE,
459 }
460 }
461
462 pub fn private_depth(&self) -> Option<usize> {
470 match self {
471 Self::Formations => Some(2),
472 Self::Sessions => Some(2),
475 Self::Records => Some(1),
480 _ => None,
481 }
482 }
483}
484
485#[derive(Debug, Clone, PartialEq, Eq)]
487pub struct WideEntry {
488 pub path: PathBuf,
489 pub mode: u32,
491 pub allowed: u32,
493}
494
495#[derive(Debug, Clone, PartialEq, Eq)]
496pub enum HomeEntryState {
497 Absent,
498 Present {
499 mode: u32,
501 wide: bool,
504 wide_descendants: Vec<WideEntry>,
507 wide_descendants_omitted: usize,
509 },
510 Unreadable {
512 error: String,
513 },
514}
515
516#[derive(Debug, Clone, PartialEq, Eq)]
517pub struct HomeEntryReport {
518 pub path: PathBuf,
519 pub kind: HomeEntryKind,
520 pub state: HomeEntryState,
521}
522
523impl HomeEntryReport {
524 pub fn allowed(&self) -> u32 {
527 allowed_for(&self.path)
528 }
529}
530
531pub fn audit_murmur_home(murmur_home: &Path) -> Vec<HomeEntryReport> {
538 let mut reports = vec![report_entry(murmur_home.to_path_buf(), HomeEntryKind::Home)];
539
540 for kind in HomeEntryKind::KNOWN {
541 let name = kind.file_name().expect("a known kind has a fixed name");
542 reports.push(report_entry(murmur_home.join(name), kind));
543 }
544
545 let mut others: Vec<String> = std::fs::read_dir(murmur_home)
546 .map(|entries| {
547 entries
548 .filter_map(Result::ok)
549 .map(|entry| entry.file_name().to_string_lossy().into_owned())
550 .filter(|name| {
551 !HomeEntryKind::KNOWN
552 .iter()
553 .any(|kind| kind.file_name() == Some(name.as_str()))
554 })
555 .collect()
556 })
557 .unwrap_or_default();
558 others.sort();
559 for name in others {
560 reports.push(report_entry(
561 murmur_home.join(&name),
562 HomeEntryKind::Unrecognised(name),
563 ));
564 }
565
566 reports
567}
568
569fn report_entry(path: PathBuf, kind: HomeEntryKind) -> HomeEntryReport {
570 use std::os::unix::fs::PermissionsExt;
571
572 let metadata = match std::fs::symlink_metadata(&path) {
573 Ok(metadata) => metadata,
574 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
575 return HomeEntryReport {
576 path,
577 kind,
578 state: HomeEntryState::Absent,
579 }
580 }
581 Err(err) => {
582 return HomeEntryReport {
583 path,
584 kind,
585 state: HomeEntryState::Unreadable {
586 error: err.to_string(),
587 },
588 }
589 }
590 };
591 let mode = metadata.permissions().mode() & 0o777;
592 let private = kind.expected_private();
593 let file_type = metadata.file_type();
594 let wide =
595 private && !file_type.is_symlink() && is_wider_than(mode, allowed_for_type(file_type));
596
597 let mut wide_descendants = Vec::new();
598 let mut omitted = 0;
599 if private && file_type.is_dir() && kind != HomeEntryKind::Home {
601 collect_wide_descendants(
602 &path,
603 kind.private_depth(),
604 kind.allowed_file_mode(),
605 &mut wide_descendants,
606 &mut omitted,
607 );
608 }
609
610 HomeEntryReport {
611 path,
612 kind,
613 state: HomeEntryState::Present {
614 mode,
615 wide,
616 wide_descendants,
617 wide_descendants_omitted: omitted,
618 },
619 }
620}
621
622fn collect_wide_descendants(
624 dir: &Path,
625 depth: Option<usize>,
626 file_allowed: u32,
627 found: &mut Vec<WideEntry>,
628 omitted: &mut usize,
629) {
630 use std::os::unix::fs::PermissionsExt;
631
632 if depth == Some(0) {
633 return;
634 }
635 let Ok(entries) = std::fs::read_dir(dir) else {
636 return;
637 };
638 let mut paths: Vec<PathBuf> = entries.filter_map(Result::ok).map(|e| e.path()).collect();
639 paths.sort();
640 for path in paths {
641 let Ok(metadata) = std::fs::symlink_metadata(&path) else {
642 continue;
643 };
644 let file_type = metadata.file_type();
645 if file_type.is_symlink() || !(file_type.is_dir() || file_type.is_file()) {
646 continue;
647 }
648 let mode = metadata.permissions().mode() & 0o777;
649 let allowed = if file_type.is_dir() {
650 MURMUR_HOME_DIR_MODE
651 } else {
652 file_allowed
653 };
654 if is_wider_than(mode, allowed) {
655 if found.len() < WIDE_DESCENDANT_CAP {
656 found.push(WideEntry {
657 path: path.clone(),
658 mode,
659 allowed,
660 });
661 } else {
662 *omitted += 1;
663 }
664 }
665 if file_type.is_dir() {
666 collect_wide_descendants(
667 &path,
668 depth.map(|depth| depth - 1),
669 file_allowed,
670 found,
671 omitted,
672 );
673 }
674 }
675}
676
677fn allowed_for_type(file_type: std::fs::FileType) -> u32 {
678 if file_type.is_dir() {
679 MURMUR_HOME_DIR_MODE
680 } else {
681 PRIVATE_FILE_MODE
682 }
683}
684
685fn allowed_for(path: &Path) -> u32 {
686 match std::fs::symlink_metadata(path) {
687 Ok(metadata) => allowed_for_type(metadata.file_type()),
688 Err(_) => PRIVATE_FILE_MODE,
689 }
690}
691
692pub fn wide_entry_warning(path: &Path, holds: &str, mode: u32, allowed: u32) -> String {
695 let path = path.display();
696 format!(
697 "[capsule-runtime] warning[{W_SEC_028}]: {path} holds {holds} and is mode {:04o}, which \
698 other accounts on this host can read; run `chmod {:o} {path}` ({})",
699 mode & 0o777,
700 allowed,
701 security_warning_link(W_SEC_028)
702 )
703}
704
705pub fn warn_on_wide_credential_file(path: &Path, name: &str, mode: u32) {
708 if let Some(warning) = wide_credential_file_warning(path, name, mode) {
709 crate::runtime_err!("{warning}");
710 }
711}
712
713fn wide_credential_file_warning(path: &Path, name: &str, mode: u32) -> Option<String> {
714 if !is_wider_than(mode, PRIVATE_FILE_MODE) {
715 return None;
716 }
717 let path = path.display();
718 Some(format!(
719 "[capsule-runtime] warning[{W_SEC_028}]: the credential credentials.{name} is \
720 read from {path}, which is mode {:04o} and readable by other accounts on this host; run \
721 `chmod 600 {path}` ({})",
722 mode & 0o777,
723 security_warning_link(W_SEC_028)
724 ))
725}
726
727#[cfg(test)]
733pub(crate) fn run_with_home(test: &str, home: &Path) {
734 let exe = std::env::current_exe().expect("test binary path");
735 let output = std::process::Command::new(exe)
736 .args([
737 test,
738 "--exact",
739 "--ignored",
740 "--test-threads=1",
741 "--nocapture",
742 ])
743 .env("HOME", home)
744 .env(INNER_HOME_ENV, "1")
745 .output()
746 .expect("re-exec the test binary with a scratch HOME");
747 let stdout = String::from_utf8_lossy(&output.stdout);
748 let stderr = String::from_utf8_lossy(&output.stderr);
749 assert!(
750 output.status.success(),
751 "{test} failed\n--- stdout ---\n{stdout}\n--- stderr ---\n{stderr}"
752 );
753 assert!(
754 stdout.contains("1 passed"),
755 "{test} was not selected\n--- stdout ---\n{stdout}"
756 );
757}
758
759#[cfg(test)]
761pub(crate) const INNER_HOME_ENV: &str = "MURMUR_TEST_INNER_HOME";
762
763#[cfg(test)]
765pub(crate) fn in_scratch_home() -> bool {
766 std::env::var_os(INNER_HOME_ENV).is_some()
767}
768
769#[cfg(test)]
771pub(crate) fn mode_of(path: &Path) -> u32 {
772 use std::os::unix::fs::PermissionsExt;
773 std::fs::symlink_metadata(path)
774 .unwrap_or_else(|err| panic!("{}: {err}", path.display()))
775 .permissions()
776 .mode()
777 & 0o777
778}
779
780#[cfg(test)]
782pub(crate) fn wide_dir(path: &Path, mode: u32) {
783 use std::os::unix::fs::PermissionsExt;
784 std::fs::create_dir_all(path).unwrap();
785 std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).unwrap();
786}
787
788#[cfg(test)]
790pub(crate) fn wide_file(path: &Path, contents: &str, mode: u32) {
791 use std::os::unix::fs::PermissionsExt;
792 std::fs::write(path, contents).unwrap();
793 std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).unwrap();
794}
795
796#[cfg(test)]
797mod tests {
798 use super::*;
799 use std::os::unix::fs::PermissionsExt;
800
801 #[test]
802 fn murmur_home_is_wider_than_truth_table() {
803 for (mode, allowed, wide) in [
804 (0o600, 0o600, false),
805 (0o644, 0o600, true),
806 (0o640, 0o600, true),
807 (0o700, 0o700, false),
808 (0o711, 0o700, true),
809 (0o400, 0o600, false),
810 ] {
811 assert_eq!(
812 is_wider_than(mode, allowed),
813 wide,
814 "{mode:04o} against {allowed:04o}"
815 );
816 }
817 }
818
819 #[test]
820 fn ensure_murmur_home_creates_and_tightens_the_home_directory() {
821 let wide = tempfile::tempdir().unwrap();
822 wide_dir(&wide.path().join(".murmur"), 0o755);
823 run_with_home(
824 "murmur_home::tests::inner_ensure_murmur_home_holds_the_home_at_0700",
825 wide.path(),
826 );
827 assert_eq!(mode_of(&wide.path().join(".murmur")), 0o700);
828
829 let missing = tempfile::tempdir().unwrap();
830 run_with_home(
831 "murmur_home::tests::inner_ensure_murmur_home_holds_the_home_at_0700",
832 missing.path(),
833 );
834 assert_eq!(mode_of(&missing.path().join(".murmur")), 0o700);
835
836 let closed = tempfile::tempdir().unwrap();
837 wide_dir(&closed.path().join(".murmur"), 0o555);
838 run_with_home(
839 "murmur_home::tests::inner_ensure_murmur_home_holds_the_home_at_0700",
840 closed.path(),
841 );
842 assert_eq!(
843 mode_of(&closed.path().join(".murmur")),
844 0o500,
845 "an owner-closed home loses group and other bits and is not reopened"
846 );
847 }
848
849 #[test]
850 #[ignore = "run by ensure_murmur_home_creates_and_tightens_the_home_directory"]
851 fn inner_ensure_murmur_home_holds_the_home_at_0700() {
852 if !in_scratch_home() {
853 return;
854 }
855 let home = ensure_murmur_home().unwrap();
856 assert!(home.ends_with(".murmur"));
857 assert_eq!(mode_of(&home) & 0o077, 0);
858 }
859
860 #[test]
861 fn murmur_home_write_private_file_replaces_a_wide_file_owner_only() {
862 use std::os::unix::fs::MetadataExt;
863 let dir = tempfile::tempdir().unwrap();
864 let path = dir.path().join("config.yaml");
865 wide_file(&path, "old", 0o644);
866 let before = std::fs::metadata(&path).unwrap().ino();
867
868 write_private_file(&path, b"new").unwrap();
869
870 assert_eq!(std::fs::read_to_string(&path).unwrap(), "new");
871 assert_eq!(mode_of(&path), 0o600);
872 assert_ne!(std::fs::metadata(&path).unwrap().ino(), before);
873 }
874
875 fn run_without_home(test: &str) {
877 let exe = std::env::current_exe().expect("test binary path");
878 let output = std::process::Command::new(exe)
879 .args([
880 test,
881 "--exact",
882 "--ignored",
883 "--test-threads=1",
884 "--nocapture",
885 ])
886 .env_remove("HOME")
887 .env(INNER_HOME_ENV, "1")
888 .output()
889 .expect("re-exec the test binary with no HOME");
890 let stdout = String::from_utf8_lossy(&output.stdout);
891 assert!(
892 output.status.success() && stdout.contains("1 passed"),
893 "{test} failed\n{stdout}\n{}",
894 String::from_utf8_lossy(&output.stderr)
895 );
896 }
897
898 #[test]
900 fn an_unusable_home_names_home_and_its_cause() {
901 run_without_home("murmur_home::tests::an_unset_home_inner");
902 let relative = std::path::Path::new("relative-home");
903 run_with_home("murmur_home::tests::a_relative_home_inner", relative);
904 let closed = tempfile::tempdir().unwrap();
905 run_with_home("murmur_home::tests::a_closed_home_inner", closed.path());
906 }
907
908 #[test]
909 #[ignore = "run by an_unusable_home_names_home_and_its_cause"]
910 fn an_unset_home_inner() {
911 if !in_scratch_home() {
912 return;
913 }
914 let error = usable_murmur_home().unwrap_err();
915 assert_eq!(error, HomeUnusable::NotSet);
916 assert_eq!(
917 error.to_string(),
918 "HOME is not set, so there is no ~/.murmur to hold this session's runtime and \
919 records directories"
920 );
921 assert!(crate::runtime_dir::sessions_dir().is_err());
922 assert!(crate::records_dir::records_group(Path::new("/w")).is_err());
923 }
924
925 #[test]
926 #[ignore = "run by an_unusable_home_names_home_and_its_cause"]
927 fn a_relative_home_inner() {
928 if !in_scratch_home() {
929 return;
930 }
931 let error = usable_murmur_home().unwrap_err();
932 assert_eq!(
933 error.to_string(),
934 "HOME is 'relative-home', which is not an absolute path, so there is no ~/.murmur to \
935 hold this session's runtime and records directories"
936 );
937 assert!(!Path::new("relative-home").exists());
938 }
939
940 #[test]
941 #[ignore = "run by an_unusable_home_names_home_and_its_cause"]
942 fn a_closed_home_inner() {
943 if !in_scratch_home() {
944 return;
945 }
946 let home = PathBuf::from(std::env::var_os("HOME").unwrap());
947 let murmur = home.join(".murmur");
948 std::fs::create_dir(&murmur).unwrap();
949 std::fs::set_permissions(&murmur, std::fs::Permissions::from_mode(0o500)).unwrap();
950 if std::fs::write(murmur.join("probe"), b"x").is_err() {
952 let error = usable_murmur_home().unwrap_err();
953 let text = error.to_string();
954 assert!(
955 text.starts_with(&format!(
956 "~/.murmur ({}) cannot hold this session's runtime and records directories: ",
957 murmur.display()
958 )),
959 "{text}"
960 );
961 assert!(text.contains("cannot be written"), "{text}");
962 }
963 std::fs::set_permissions(&murmur, std::fs::Permissions::from_mode(0o700)).unwrap();
964 assert_eq!(usable_murmur_home().unwrap(), murmur);
965 }
966
967 #[test]
968 fn murmur_home_audit_reports_absent_entries_in_kind_order() {
969 let dir = tempfile::tempdir().unwrap();
970 let home = dir.path().join(".murmur");
971
972 let reports = audit_murmur_home(&home);
973
974 assert_eq!(reports.len(), 18);
975 assert_eq!(reports[0].kind, HomeEntryKind::Home);
976 assert_eq!(reports[1].kind, HomeEntryKind::Config);
977 assert_eq!(reports[10].kind, HomeEntryKind::CompiledForms);
978 assert_eq!(reports[11].kind, HomeEntryKind::HostFacts);
979 assert_eq!(reports[11].path, home.join("host_facts"));
980 assert_eq!(reports[12].kind, HomeEntryKind::BinaryCache);
981 assert_eq!(reports[13].kind, HomeEntryKind::Formations);
982 assert_eq!(reports[14].kind, HomeEntryKind::Sessions);
983 assert_eq!(reports[15].kind, HomeEntryKind::Records);
984 assert_eq!(reports[16].kind, HomeEntryKind::Programs);
985 assert_eq!(reports[16].path, home.join("programs"));
986 assert_eq!(reports[17].kind, HomeEntryKind::Keys);
987 assert_eq!(reports[17].path, home.join("keys"));
988 assert!(reports
989 .iter()
990 .all(|report| report.state == HomeEntryState::Absent));
991 }
992
993 #[test]
997 fn murmur_home_programs_entry_names_the_store_and_allows_owner_executables() {
998 use std::os::unix::fs::PermissionsExt;
999 assert_eq!(HomeEntryKind::Programs.file_name(), Some("programs"));
1000 assert_eq!(HomeEntryKind::Programs.holds(), "pinned program downloads");
1001 assert_eq!(HomeEntryKind::Programs.kept(), None);
1002 assert!(HomeEntryKind::Programs.expected_private());
1003 assert!(!HomeEntryKind::Programs.safe_to_delete());
1004
1005 let dir = tempfile::tempdir().unwrap();
1006 let home = dir.path().join(".murmur");
1007 let files = home.join("programs/abc/files");
1008 std::fs::create_dir_all(&files).unwrap();
1009 for path in [
1010 home.clone(),
1011 home.join("programs"),
1012 home.join("programs/abc"),
1013 files.clone(),
1014 ] {
1015 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap();
1016 }
1017 for (name, mode) in [("prog", 0o700), ("data.txt", 0o600)] {
1018 std::fs::write(files.join(name), "x").unwrap();
1019 std::fs::set_permissions(files.join(name), std::fs::Permissions::from_mode(mode))
1020 .unwrap();
1021 }
1022 std::os::unix::fs::symlink("../files/prog", home.join("programs/abc/prog-link")).unwrap();
1023 let programs = |reports: Vec<HomeEntryReport>| {
1024 reports
1025 .into_iter()
1026 .find(|report| report.kind == HomeEntryKind::Programs)
1027 .unwrap()
1028 .state
1029 };
1030 assert!(matches!(
1031 programs(audit_murmur_home(&home)),
1032 HomeEntryState::Present { wide: false, wide_descendants, .. } if wide_descendants.is_empty()
1033 ));
1034 std::fs::set_permissions(
1035 files.join("data.txt"),
1036 std::fs::Permissions::from_mode(0o644),
1037 )
1038 .unwrap();
1039 assert!(matches!(
1040 programs(audit_murmur_home(&home)),
1041 HomeEntryState::Present { wide_descendants, .. }
1042 if wide_descendants.len() == 1 && wide_descendants[0].path == files.join("data.txt")
1043 ));
1044 }
1045
1046 #[test]
1051 fn murmur_home_kinds_say_how_formations_are_kept_and_only_the_caches_are_safe_to_delete() {
1052 let every = HomeEntryKind::KNOWN
1053 .into_iter()
1054 .chain([HomeEntryKind::Home, HomeEntryKind::Unrecognised("x".into())]);
1055 for kind in every {
1056 assert_eq!(
1057 kind.kept().is_some(),
1058 matches!(
1059 kind,
1060 HomeEntryKind::Formations | HomeEntryKind::Sessions | HomeEntryKind::Records
1061 ),
1062 "{kind:?}"
1063 );
1064 assert_eq!(
1065 kind.safe_to_delete(),
1066 matches!(
1067 kind,
1068 HomeEntryKind::CompiledForms | HomeEntryKind::HostFacts
1069 ),
1070 "{kind:?}"
1071 );
1072 if kind.safe_to_delete() {
1073 assert!(kind.expected_private(), "{kind:?}");
1074 }
1075 }
1076 assert_eq!(
1077 HomeEntryKind::HostFacts.holds(),
1078 "facts about allowlisted host binaries"
1079 );
1080 assert_eq!(
1081 HomeEntryKind::Formations.kept(),
1082 Some(
1083 "each kept until its formation has ended and the entry member's trace.retain \
1084 expires it"
1085 )
1086 );
1087 }
1088
1089 #[test]
1090 fn owned_within_refuses_an_owner_other_than_the_expected_uid() {
1091 let temp = tempfile::tempdir().unwrap();
1092 let path = temp.path().join("form.cwasm");
1093 std::fs::write(&path, b"form").unwrap();
1094 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
1095 let metadata = std::fs::metadata(&path).unwrap();
1096
1097 assert!(owned_within(&metadata, current_euid(), 0o600));
1098 assert!(!owned_within(
1099 &metadata,
1100 current_euid().wrapping_add(1),
1101 0o600
1102 ));
1103 }
1104
1105 #[test]
1106 fn held_private_accepts_only_an_owner_only_directory_reached_without_a_symlink() {
1107 let temp = tempfile::tempdir().unwrap();
1108 let dir = temp.path().join("forms");
1109 std::fs::create_dir(&dir).unwrap();
1110 std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap();
1111 assert!(held_private(&dir));
1112
1113 let link = temp.path().join("link");
1114 std::os::unix::fs::symlink(&dir, &link).unwrap();
1115 assert!(!held_private(&link));
1116
1117 std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o750)).unwrap();
1118 assert!(!held_private(&dir));
1119 }
1120
1121 #[test]
1122 fn a_directory_beneath_the_capsule_workdir_is_reachable_from_it() {
1123 let temp = tempfile::tempdir().unwrap();
1124 let workdir = temp.path().join("work");
1125 let inside = workdir.join(".murmur");
1126 let outside = temp.path().join("home");
1127 std::fs::create_dir_all(&inside).unwrap();
1128 std::fs::create_dir(&outside).unwrap();
1129
1130 assert!(reachable_from(&workdir, &workdir));
1131 assert!(reachable_from(&inside, &workdir));
1132 assert!(!reachable_from(&outside, &workdir));
1133 assert!(!reachable_from(&outside, &temp.path().join("not-created")));
1135 assert!(reachable_from(&temp.path().join("missing"), &workdir));
1137 }
1138
1139 #[test]
1143 fn murmur_home_audit_knows_formations_as_owner_only() {
1144 let dir = tempfile::tempdir().unwrap();
1145 let home = dir.path().join(".murmur");
1146 wide_dir(&home, 0o700);
1147 wide_dir(&home.join("formations"), 0o700);
1148 wide_dir(&home.join("formations/frm_x"), 0o700);
1149 wide_dir(&home.join("formations/frm_x/worker"), 0o755);
1150 wide_dir(&home.join("formations/frm_x/worker/.murmur/ses_1"), 0o775);
1151 wide_file(
1152 &home.join("formations/frm_x/worker/.murmur/ses_1/trace.jsonl"),
1153 "{}\n",
1154 0o664,
1155 );
1156 wide_file(&home.join("formations/frm_x/worker/task.md"), "t", 0o664);
1157
1158 let report = audit_murmur_home(&home)
1159 .into_iter()
1160 .find(|report| report.kind == HomeEntryKind::Formations)
1161 .unwrap();
1162 assert_eq!(report.path, home.join("formations"));
1163 assert_eq!(
1164 report.kind.holds(),
1165 "formation members' working directories"
1166 );
1167 assert!(report.kind.expected_private());
1168 assert!(!report.kind.safe_to_delete());
1169 match report.state {
1170 HomeEntryState::Present {
1171 wide,
1172 wide_descendants,
1173 ..
1174 } => {
1175 assert!(!wide);
1176 assert_eq!(wide_descendants.len(), 1);
1177 assert!(wide_descendants[0].path.ends_with("frm_x/worker"));
1178 }
1179 other => panic!("{other:?}"),
1180 }
1181 }
1182
1183 #[test]
1184 fn murmur_home_audit_flags_wide_private_entries_and_never_non_private_ones() {
1185 let dir = tempfile::tempdir().unwrap();
1186 let home = dir.path().join(".murmur");
1187 wide_dir(&home, 0o755);
1188 wide_file(&home.join("config.yaml"), "credentials: {}\n", 0o644);
1189 wide_dir(&home.join("artifacts"), 0o777);
1190 wide_file(&home.join("artifacts/loose"), "x", 0o666);
1191 wide_dir(&home.join("spend"), 0o700);
1192 wide_file(&home.join("spend/2026-09-13.jsonl"), "", 0o600);
1193 wide_file(&home.join("nexus-config.json"), "{}", 0o644);
1194
1195 let reports = audit_murmur_home(&home);
1196 let find = |kind: &HomeEntryKind| {
1197 reports
1198 .iter()
1199 .find(|report| &report.kind == kind)
1200 .unwrap()
1201 .state
1202 .clone()
1203 };
1204 let wide = |state: HomeEntryState| match state {
1205 HomeEntryState::Present {
1206 wide,
1207 wide_descendants,
1208 ..
1209 } => (wide, wide_descendants.len()),
1210 other => panic!("{other:?}"),
1211 };
1212
1213 assert_eq!(wide(find(&HomeEntryKind::Home)), (true, 0));
1214 assert_eq!(wide(find(&HomeEntryKind::Config)), (true, 0));
1215 assert_eq!(wide(find(&HomeEntryKind::Artifacts)), (false, 0));
1216 assert_eq!(wide(find(&HomeEntryKind::Spend)), (false, 0));
1217 let last = reports.last().unwrap();
1218 assert_eq!(
1219 last.kind,
1220 HomeEntryKind::Unrecognised("nexus-config.json".to_string())
1221 );
1222 assert_eq!(wide(last.state.clone()), (false, 0));
1223 assert_eq!(mode_of(&home), 0o755, "the audit changes nothing");
1224 }
1225
1226 #[test]
1227 fn murmur_home_audit_does_not_follow_symlinks() {
1228 let dir = tempfile::tempdir().unwrap();
1229 let home = dir.path().join(".murmur");
1230 wide_dir(&home.join("state"), 0o700);
1231 let outside = dir.path().join("outside");
1232 wide_dir(&outside, 0o777);
1233 wide_file(&outside.join("secret"), "x", 0o666);
1234 std::os::unix::fs::symlink(&outside, home.join("state/link")).unwrap();
1235
1236 let reports = audit_murmur_home(&home);
1237 let state = &reports
1238 .iter()
1239 .find(|report| report.kind == HomeEntryKind::State)
1240 .unwrap()
1241 .state;
1242 assert!(
1243 matches!(state, HomeEntryState::Present { wide: false, wide_descendants, .. } if wide_descendants.is_empty()),
1244 "{state:?}"
1245 );
1246 }
1247
1248 #[test]
1249 fn murmur_home_audit_caps_wide_descendants_and_counts_the_rest() {
1250 let dir = tempfile::tempdir().unwrap();
1251 let home = dir.path().join(".murmur");
1252 let keys = home.join("deploy_keys");
1253 wide_dir(&keys, 0o700);
1254 for index in 0..25 {
1255 wide_file(&keys.join(format!("key_{index:02}")), "x", 0o644);
1256 }
1257
1258 let reports = audit_murmur_home(&home);
1259 let state = &reports
1260 .iter()
1261 .find(|report| report.kind == HomeEntryKind::DeployKeys)
1262 .unwrap()
1263 .state;
1264 match state {
1265 HomeEntryState::Present {
1266 wide,
1267 wide_descendants,
1268 wide_descendants_omitted,
1269 ..
1270 } => {
1271 assert!(!wide);
1272 assert_eq!(wide_descendants.len(), WIDE_DESCENDANT_CAP);
1273 assert_eq!(*wide_descendants_omitted, 5);
1274 assert!(wide_descendants
1275 .iter()
1276 .all(|entry| entry.mode == 0o644 && entry.allowed == 0o600));
1277 }
1278 other => panic!("{other:?}"),
1279 }
1280 }
1281
1282 #[test]
1283 fn murmur_home_wide_credential_file_warning_names_the_path_and_mode_only() {
1284 let path = Path::new("/home/someone/.murmur/config.yaml");
1285 assert_eq!(
1286 wide_credential_file_warning(path, "PROVIDER_KEY", 0o600),
1287 None
1288 );
1289 assert_eq!(
1290 wide_credential_file_warning(path, "PROVIDER_KEY", 0o100600),
1291 None
1292 );
1293
1294 let warning = wide_credential_file_warning(path, "PROVIDER_KEY", 0o100644).unwrap();
1295 assert!(warning.contains("warning[W-SEC-028]"), "{warning}");
1296 assert!(warning.contains("credentials.PROVIDER_KEY"), "{warning}");
1297 assert!(
1298 warning.contains("/home/someone/.murmur/config.yaml"),
1299 "{warning}"
1300 );
1301 assert!(warning.contains("mode 0644"), "{warning}");
1302 assert!(
1303 warning.contains("`chmod 600 /home/someone/.murmur/config.yaml`"),
1304 "{warning}"
1305 );
1306 assert!(warning.ends_with("#w-sec-028)"), "{warning}");
1307 }
1308
1309 #[test]
1310 fn murmur_home_wide_entry_warning_prints_the_chmod_for_the_allowed_mode() {
1311 let warning = wide_entry_warning(Path::new("/h/.murmur"), "the murmur home", 0o755, 0o700);
1312 assert_eq!(
1313 warning,
1314 "[capsule-runtime] warning[W-SEC-028]: /h/.murmur holds the murmur home and is mode \
1315 0755, which other accounts on this host can read; run `chmod 700 /h/.murmur` \
1316 (https://docs.murmur.nexus/reference/diagnostics/#w-sec-028)"
1317 );
1318 }
1319}