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