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
64pub fn write_private_file(target: &Path, contents: &[u8]) -> Result<(), String> {
71 crate::retention::StagedRewrite::stage_with_mode(target, contents, Some(PRIVATE_FILE_MODE))?
72 .commit()
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub(crate) struct FileStamp {
88 dev: u64,
89 ino: u64,
90 len: u64,
91 uid: u32,
92 pub(crate) mode: u32,
93 mtime: (i64, i64),
94 ctime: (i64, i64),
95}
96
97impl FileStamp {
98 pub(crate) fn of(metadata: &std::fs::Metadata) -> Self {
99 use std::os::unix::fs::MetadataExt;
100
101 Self {
102 dev: metadata.dev(),
103 ino: metadata.ino(),
104 len: metadata.len(),
105 uid: metadata.uid(),
106 mode: metadata.mode(),
107 mtime: (metadata.mtime(), metadata.mtime_nsec()),
108 ctime: (metadata.ctime(), metadata.ctime_nsec()),
109 }
110 }
111}
112
113impl std::fmt::Display for FileStamp {
114 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
115 write!(
116 f,
117 "{} {} {} {} {:o} {}.{:09} {}.{:09}",
118 self.dev,
119 self.ino,
120 self.len,
121 self.uid,
122 self.mode,
123 self.mtime.0,
124 self.mtime.1,
125 self.ctime.0,
126 self.ctime.1,
127 )
128 }
129}
130
131pub fn is_wider_than(mode: u32, allowed: u32) -> bool {
133 (mode & 0o777) & !allowed != 0
134}
135
136#[derive(Debug, Clone, PartialEq, Eq)]
138pub enum HomeEntryKind {
139 Home,
140 Config,
141 DeployKeys,
142 DeployStaging,
143 Deployments,
144 Spend,
145 Conversations,
146 Running,
147 State,
148 Artifacts,
149 CompiledForms,
150 BinaryCache,
151 Unrecognised(String),
153}
154
155impl HomeEntryKind {
156 const KNOWN: [HomeEntryKind; 11] = [
158 Self::Config,
159 Self::DeployKeys,
160 Self::DeployStaging,
161 Self::Deployments,
162 Self::Spend,
163 Self::Conversations,
164 Self::Running,
165 Self::State,
166 Self::Artifacts,
167 Self::CompiledForms,
168 Self::BinaryCache,
169 ];
170
171 pub fn file_name(&self) -> Option<&str> {
173 Some(match self {
174 Self::Home => return None,
175 Self::Config => "config.yaml",
176 Self::DeployKeys => "deploy_keys",
177 Self::DeployStaging => "deploy_staging",
178 Self::Deployments => "deployments.json",
179 Self::Spend => "spend",
180 Self::Conversations => "conversations",
181 Self::Running => "running",
182 Self::State => "state",
183 Self::Artifacts => "artifacts",
184 Self::CompiledForms => "compiled",
185 Self::BinaryCache => "bin",
186 Self::Unrecognised(name) => name,
187 })
188 }
189
190 pub fn holds(&self) -> &'static str {
192 match self {
193 Self::Home => "the murmur home",
194 Self::Config => "provider credentials",
195 Self::DeployKeys => "SSH private keys",
196 Self::DeployStaging => "deployment staging copies",
197 Self::Deployments => "deployment records",
198 Self::Spend => "the machine spend ledger",
199 Self::Conversations => "conversation records",
200 Self::Running => "running-capsule records",
201 Self::State => "capsule state stores",
202 Self::Artifacts => "installed artifacts",
203 Self::CompiledForms => "compiled WASM cache",
204 Self::BinaryCache => "cached mur binaries",
205 Self::Unrecognised(_) => "something not recognised by this build",
206 }
207 }
208
209 pub fn safe_to_delete(&self) -> bool {
211 matches!(self, Self::CompiledForms)
212 }
213
214 pub fn expected_private(&self) -> bool {
216 !matches!(
217 self,
218 Self::Artifacts | Self::BinaryCache | Self::Unrecognised(_)
219 )
220 }
221}
222
223#[derive(Debug, Clone, PartialEq, Eq)]
225pub struct WideEntry {
226 pub path: PathBuf,
227 pub mode: u32,
229 pub allowed: u32,
231}
232
233#[derive(Debug, Clone, PartialEq, Eq)]
234pub enum HomeEntryState {
235 Absent,
236 Present {
237 mode: u32,
239 wide: bool,
242 wide_descendants: Vec<WideEntry>,
245 wide_descendants_omitted: usize,
247 },
248 Unreadable {
250 error: String,
251 },
252}
253
254#[derive(Debug, Clone, PartialEq, Eq)]
255pub struct HomeEntryReport {
256 pub path: PathBuf,
257 pub kind: HomeEntryKind,
258 pub state: HomeEntryState,
259}
260
261impl HomeEntryReport {
262 pub fn allowed(&self) -> u32 {
265 allowed_for(&self.path)
266 }
267}
268
269pub fn audit_murmur_home(murmur_home: &Path) -> Vec<HomeEntryReport> {
276 let mut reports = vec![report_entry(murmur_home.to_path_buf(), HomeEntryKind::Home)];
277
278 for kind in HomeEntryKind::KNOWN {
279 let name = kind.file_name().expect("a known kind has a fixed name");
280 reports.push(report_entry(murmur_home.join(name), kind));
281 }
282
283 let mut others: Vec<String> = std::fs::read_dir(murmur_home)
284 .map(|entries| {
285 entries
286 .filter_map(Result::ok)
287 .map(|entry| entry.file_name().to_string_lossy().into_owned())
288 .filter(|name| {
289 !HomeEntryKind::KNOWN
290 .iter()
291 .any(|kind| kind.file_name() == Some(name.as_str()))
292 })
293 .collect()
294 })
295 .unwrap_or_default();
296 others.sort();
297 for name in others {
298 reports.push(report_entry(
299 murmur_home.join(&name),
300 HomeEntryKind::Unrecognised(name),
301 ));
302 }
303
304 reports
305}
306
307fn report_entry(path: PathBuf, kind: HomeEntryKind) -> HomeEntryReport {
308 use std::os::unix::fs::PermissionsExt;
309
310 let metadata = match std::fs::symlink_metadata(&path) {
311 Ok(metadata) => metadata,
312 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
313 return HomeEntryReport {
314 path,
315 kind,
316 state: HomeEntryState::Absent,
317 }
318 }
319 Err(err) => {
320 return HomeEntryReport {
321 path,
322 kind,
323 state: HomeEntryState::Unreadable {
324 error: err.to_string(),
325 },
326 }
327 }
328 };
329 let mode = metadata.permissions().mode() & 0o777;
330 let private = kind.expected_private();
331 let file_type = metadata.file_type();
332 let wide =
333 private && !file_type.is_symlink() && is_wider_than(mode, allowed_for_type(file_type));
334
335 let mut wide_descendants = Vec::new();
336 let mut omitted = 0;
337 if private && file_type.is_dir() && kind != HomeEntryKind::Home {
339 collect_wide_descendants(&path, &mut wide_descendants, &mut omitted);
340 }
341
342 HomeEntryReport {
343 path,
344 kind,
345 state: HomeEntryState::Present {
346 mode,
347 wide,
348 wide_descendants,
349 wide_descendants_omitted: omitted,
350 },
351 }
352}
353
354fn collect_wide_descendants(dir: &Path, found: &mut Vec<WideEntry>, omitted: &mut usize) {
355 use std::os::unix::fs::PermissionsExt;
356
357 let Ok(entries) = std::fs::read_dir(dir) else {
358 return;
359 };
360 let mut paths: Vec<PathBuf> = entries.filter_map(Result::ok).map(|e| e.path()).collect();
361 paths.sort();
362 for path in paths {
363 let Ok(metadata) = std::fs::symlink_metadata(&path) else {
364 continue;
365 };
366 let file_type = metadata.file_type();
367 if file_type.is_symlink() || !(file_type.is_dir() || file_type.is_file()) {
368 continue;
369 }
370 let mode = metadata.permissions().mode() & 0o777;
371 let allowed = allowed_for_type(file_type);
372 if is_wider_than(mode, allowed) {
373 if found.len() < WIDE_DESCENDANT_CAP {
374 found.push(WideEntry {
375 path: path.clone(),
376 mode,
377 allowed,
378 });
379 } else {
380 *omitted += 1;
381 }
382 }
383 if file_type.is_dir() {
384 collect_wide_descendants(&path, found, omitted);
385 }
386 }
387}
388
389fn allowed_for_type(file_type: std::fs::FileType) -> u32 {
390 if file_type.is_dir() {
391 MURMUR_HOME_DIR_MODE
392 } else {
393 PRIVATE_FILE_MODE
394 }
395}
396
397fn allowed_for(path: &Path) -> u32 {
398 match std::fs::symlink_metadata(path) {
399 Ok(metadata) => allowed_for_type(metadata.file_type()),
400 Err(_) => PRIVATE_FILE_MODE,
401 }
402}
403
404pub fn wide_entry_warning(path: &Path, holds: &str, mode: u32, allowed: u32) -> String {
407 let path = path.display();
408 format!(
409 "[capsule-runtime] warning[{W_SEC_028}]: {path} holds {holds} and is mode {:04o}, which \
410 other accounts on this host can read; run `chmod {:o} {path}` ({})",
411 mode & 0o777,
412 allowed,
413 security_warning_link(W_SEC_028)
414 )
415}
416
417pub fn warn_on_wide_credential_file(path: &Path, name: &str, mode: u32) {
420 if let Some(warning) = wide_credential_file_warning(path, name, mode) {
421 crate::runtime_err!("{warning}");
422 }
423}
424
425fn wide_credential_file_warning(path: &Path, name: &str, mode: u32) -> Option<String> {
426 if !is_wider_than(mode, PRIVATE_FILE_MODE) {
427 return None;
428 }
429 let path = path.display();
430 Some(format!(
431 "[capsule-runtime] warning[{W_SEC_028}]: the credential credentials.{name} is \
432 read from {path}, which is mode {:04o} and readable by other accounts on this host; run \
433 `chmod 600 {path}` ({})",
434 mode & 0o777,
435 security_warning_link(W_SEC_028)
436 ))
437}
438
439#[cfg(test)]
445pub(crate) fn run_with_home(test: &str, home: &Path) {
446 let exe = std::env::current_exe().expect("test binary path");
447 let output = std::process::Command::new(exe)
448 .args([
449 test,
450 "--exact",
451 "--ignored",
452 "--test-threads=1",
453 "--nocapture",
454 ])
455 .env("HOME", home)
456 .env(INNER_HOME_ENV, "1")
457 .output()
458 .expect("re-exec the test binary with a scratch HOME");
459 let stdout = String::from_utf8_lossy(&output.stdout);
460 let stderr = String::from_utf8_lossy(&output.stderr);
461 assert!(
462 output.status.success(),
463 "{test} failed\n--- stdout ---\n{stdout}\n--- stderr ---\n{stderr}"
464 );
465 assert!(
466 stdout.contains("1 passed"),
467 "{test} was not selected\n--- stdout ---\n{stdout}"
468 );
469}
470
471#[cfg(test)]
473pub(crate) const INNER_HOME_ENV: &str = "MURMUR_TEST_INNER_HOME";
474
475#[cfg(test)]
477pub(crate) fn in_scratch_home() -> bool {
478 std::env::var_os(INNER_HOME_ENV).is_some()
479}
480
481#[cfg(test)]
483pub(crate) fn mode_of(path: &Path) -> u32 {
484 use std::os::unix::fs::PermissionsExt;
485 std::fs::symlink_metadata(path)
486 .unwrap_or_else(|err| panic!("{}: {err}", path.display()))
487 .permissions()
488 .mode()
489 & 0o777
490}
491
492#[cfg(test)]
494pub(crate) fn wide_dir(path: &Path, mode: u32) {
495 use std::os::unix::fs::PermissionsExt;
496 std::fs::create_dir_all(path).unwrap();
497 std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).unwrap();
498}
499
500#[cfg(test)]
502pub(crate) fn wide_file(path: &Path, contents: &str, mode: u32) {
503 use std::os::unix::fs::PermissionsExt;
504 std::fs::write(path, contents).unwrap();
505 std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).unwrap();
506}
507
508#[cfg(test)]
509mod tests {
510 use super::*;
511
512 #[test]
513 fn murmur_home_is_wider_than_truth_table() {
514 for (mode, allowed, wide) in [
515 (0o600, 0o600, false),
516 (0o644, 0o600, true),
517 (0o640, 0o600, true),
518 (0o700, 0o700, false),
519 (0o711, 0o700, true),
520 (0o400, 0o600, false),
521 ] {
522 assert_eq!(
523 is_wider_than(mode, allowed),
524 wide,
525 "{mode:04o} against {allowed:04o}"
526 );
527 }
528 }
529
530 #[test]
531 fn ensure_murmur_home_creates_and_tightens_the_home_directory() {
532 let wide = tempfile::tempdir().unwrap();
533 wide_dir(&wide.path().join(".murmur"), 0o755);
534 run_with_home(
535 "murmur_home::tests::inner_ensure_murmur_home_holds_the_home_at_0700",
536 wide.path(),
537 );
538 assert_eq!(mode_of(&wide.path().join(".murmur")), 0o700);
539
540 let missing = tempfile::tempdir().unwrap();
541 run_with_home(
542 "murmur_home::tests::inner_ensure_murmur_home_holds_the_home_at_0700",
543 missing.path(),
544 );
545 assert_eq!(mode_of(&missing.path().join(".murmur")), 0o700);
546
547 let closed = tempfile::tempdir().unwrap();
548 wide_dir(&closed.path().join(".murmur"), 0o555);
549 run_with_home(
550 "murmur_home::tests::inner_ensure_murmur_home_holds_the_home_at_0700",
551 closed.path(),
552 );
553 assert_eq!(
554 mode_of(&closed.path().join(".murmur")),
555 0o500,
556 "an owner-closed home loses group and other bits and is not reopened"
557 );
558 }
559
560 #[test]
561 #[ignore = "run by ensure_murmur_home_creates_and_tightens_the_home_directory"]
562 fn inner_ensure_murmur_home_holds_the_home_at_0700() {
563 if !in_scratch_home() {
564 return;
565 }
566 let home = ensure_murmur_home().unwrap();
567 assert!(home.ends_with(".murmur"));
568 assert_eq!(mode_of(&home) & 0o077, 0);
569 }
570
571 #[test]
572 fn murmur_home_write_private_file_replaces_a_wide_file_owner_only() {
573 use std::os::unix::fs::MetadataExt;
574 let dir = tempfile::tempdir().unwrap();
575 let path = dir.path().join("config.yaml");
576 wide_file(&path, "old", 0o644);
577 let before = std::fs::metadata(&path).unwrap().ino();
578
579 write_private_file(&path, b"new").unwrap();
580
581 assert_eq!(std::fs::read_to_string(&path).unwrap(), "new");
582 assert_eq!(mode_of(&path), 0o600);
583 assert_ne!(std::fs::metadata(&path).unwrap().ino(), before);
584 }
585
586 #[test]
587 fn murmur_home_audit_reports_absent_entries_in_kind_order() {
588 let dir = tempfile::tempdir().unwrap();
589 let home = dir.path().join(".murmur");
590
591 let reports = audit_murmur_home(&home);
592
593 assert_eq!(reports.len(), 12);
594 assert_eq!(reports[0].kind, HomeEntryKind::Home);
595 assert_eq!(reports[1].kind, HomeEntryKind::Config);
596 assert_eq!(reports[10].kind, HomeEntryKind::CompiledForms);
597 assert_eq!(reports[11].kind, HomeEntryKind::BinaryCache);
598 assert!(reports
599 .iter()
600 .all(|report| report.state == HomeEntryState::Absent));
601 }
602
603 #[test]
604 fn murmur_home_audit_flags_wide_private_entries_and_never_non_private_ones() {
605 let dir = tempfile::tempdir().unwrap();
606 let home = dir.path().join(".murmur");
607 wide_dir(&home, 0o755);
608 wide_file(&home.join("config.yaml"), "credentials: {}\n", 0o644);
609 wide_dir(&home.join("artifacts"), 0o777);
610 wide_file(&home.join("artifacts/loose"), "x", 0o666);
611 wide_dir(&home.join("spend"), 0o700);
612 wide_file(&home.join("spend/2026-09-13.jsonl"), "", 0o600);
613 wide_file(&home.join("nexus-config.json"), "{}", 0o644);
614
615 let reports = audit_murmur_home(&home);
616 let find = |kind: &HomeEntryKind| {
617 reports
618 .iter()
619 .find(|report| &report.kind == kind)
620 .unwrap()
621 .state
622 .clone()
623 };
624 let wide = |state: HomeEntryState| match state {
625 HomeEntryState::Present {
626 wide,
627 wide_descendants,
628 ..
629 } => (wide, wide_descendants.len()),
630 other => panic!("{other:?}"),
631 };
632
633 assert_eq!(wide(find(&HomeEntryKind::Home)), (true, 0));
634 assert_eq!(wide(find(&HomeEntryKind::Config)), (true, 0));
635 assert_eq!(wide(find(&HomeEntryKind::Artifacts)), (false, 0));
636 assert_eq!(wide(find(&HomeEntryKind::Spend)), (false, 0));
637 let last = reports.last().unwrap();
638 assert_eq!(
639 last.kind,
640 HomeEntryKind::Unrecognised("nexus-config.json".to_string())
641 );
642 assert_eq!(wide(last.state.clone()), (false, 0));
643 assert_eq!(mode_of(&home), 0o755, "the audit changes nothing");
644 }
645
646 #[test]
647 fn murmur_home_audit_does_not_follow_symlinks() {
648 let dir = tempfile::tempdir().unwrap();
649 let home = dir.path().join(".murmur");
650 wide_dir(&home.join("state"), 0o700);
651 let outside = dir.path().join("outside");
652 wide_dir(&outside, 0o777);
653 wide_file(&outside.join("secret"), "x", 0o666);
654 std::os::unix::fs::symlink(&outside, home.join("state/link")).unwrap();
655
656 let reports = audit_murmur_home(&home);
657 let state = &reports
658 .iter()
659 .find(|report| report.kind == HomeEntryKind::State)
660 .unwrap()
661 .state;
662 assert!(
663 matches!(state, HomeEntryState::Present { wide: false, wide_descendants, .. } if wide_descendants.is_empty()),
664 "{state:?}"
665 );
666 }
667
668 #[test]
669 fn murmur_home_audit_caps_wide_descendants_and_counts_the_rest() {
670 let dir = tempfile::tempdir().unwrap();
671 let home = dir.path().join(".murmur");
672 let keys = home.join("deploy_keys");
673 wide_dir(&keys, 0o700);
674 for index in 0..25 {
675 wide_file(&keys.join(format!("key_{index:02}")), "x", 0o644);
676 }
677
678 let reports = audit_murmur_home(&home);
679 let state = &reports
680 .iter()
681 .find(|report| report.kind == HomeEntryKind::DeployKeys)
682 .unwrap()
683 .state;
684 match state {
685 HomeEntryState::Present {
686 wide,
687 wide_descendants,
688 wide_descendants_omitted,
689 ..
690 } => {
691 assert!(!wide);
692 assert_eq!(wide_descendants.len(), WIDE_DESCENDANT_CAP);
693 assert_eq!(*wide_descendants_omitted, 5);
694 assert!(wide_descendants
695 .iter()
696 .all(|entry| entry.mode == 0o644 && entry.allowed == 0o600));
697 }
698 other => panic!("{other:?}"),
699 }
700 }
701
702 #[test]
703 fn murmur_home_wide_credential_file_warning_names_the_path_and_mode_only() {
704 let path = Path::new("/home/someone/.murmur/config.yaml");
705 assert_eq!(
706 wide_credential_file_warning(path, "PROVIDER_KEY", 0o600),
707 None
708 );
709 assert_eq!(
710 wide_credential_file_warning(path, "PROVIDER_KEY", 0o100600),
711 None
712 );
713
714 let warning = wide_credential_file_warning(path, "PROVIDER_KEY", 0o100644).unwrap();
715 assert!(warning.contains("warning[W-SEC-028]"), "{warning}");
716 assert!(warning.contains("credentials.PROVIDER_KEY"), "{warning}");
717 assert!(
718 warning.contains("/home/someone/.murmur/config.yaml"),
719 "{warning}"
720 );
721 assert!(warning.contains("mode 0644"), "{warning}");
722 assert!(
723 warning.contains("`chmod 600 /home/someone/.murmur/config.yaml`"),
724 "{warning}"
725 );
726 assert!(warning.ends_with("#w-sec-028)"), "{warning}");
727 }
728
729 #[test]
730 fn murmur_home_wide_entry_warning_prints_the_chmod_for_the_allowed_mode() {
731 let warning = wide_entry_warning(Path::new("/h/.murmur"), "the murmur home", 0o755, 0o700);
732 assert_eq!(
733 warning,
734 "[capsule-runtime] warning[W-SEC-028]: /h/.murmur holds the murmur home and is mode \
735 0755, which other accounts on this host can read; run `chmod 700 /h/.murmur` \
736 (https://docs.murmur.nexus/murmur-nexus/murmur/reference/diagnostics/#w-sec-028)"
737 );
738 }
739}