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 Formations,
152 Unrecognised(String),
154}
155
156impl HomeEntryKind {
157 const KNOWN: [HomeEntryKind; 12] = [
159 Self::Config,
160 Self::DeployKeys,
161 Self::DeployStaging,
162 Self::Deployments,
163 Self::Spend,
164 Self::Conversations,
165 Self::Running,
166 Self::State,
167 Self::Artifacts,
168 Self::CompiledForms,
169 Self::BinaryCache,
170 Self::Formations,
171 ];
172
173 pub fn file_name(&self) -> Option<&str> {
175 Some(match self {
176 Self::Home => return None,
177 Self::Config => "config.yaml",
178 Self::DeployKeys => "deploy_keys",
179 Self::DeployStaging => "deploy_staging",
180 Self::Deployments => "deployments.json",
181 Self::Spend => "spend",
182 Self::Conversations => "conversations",
183 Self::Running => "running",
184 Self::State => "state",
185 Self::Artifacts => "artifacts",
186 Self::CompiledForms => "compiled",
187 Self::BinaryCache => "bin",
188 Self::Formations => crate::formation_launch::FORMATIONS_DIR,
189 Self::Unrecognised(name) => name,
190 })
191 }
192
193 pub fn holds(&self) -> &'static str {
195 match self {
196 Self::Home => "the murmur home",
197 Self::Config => "provider credentials",
198 Self::DeployKeys => "SSH private keys",
199 Self::DeployStaging => "deployment staging copies",
200 Self::Deployments => "deployment records",
201 Self::Spend => "the machine spend ledger",
202 Self::Conversations => "conversation records",
203 Self::Running => "running-capsule records",
204 Self::State => "capsule state stores",
205 Self::Artifacts => "installed artifacts",
206 Self::CompiledForms => "compiled WASM cache",
207 Self::BinaryCache => "cached mur binaries",
208 Self::Formations => "formation members' working directories",
209 Self::Unrecognised(_) => "something not recognised by this build",
210 }
211 }
212
213 pub fn kept(&self) -> Option<&'static str> {
216 match self {
217 Self::Formations => Some(
218 "each kept until its formation has ended and the entry member's trace.retain \
219 expires it",
220 ),
221 _ => None,
222 }
223 }
224
225 pub fn safe_to_delete(&self) -> bool {
230 matches!(self, Self::CompiledForms)
231 }
232
233 pub fn expected_private(&self) -> bool {
236 !matches!(
237 self,
238 Self::Artifacts | Self::BinaryCache | Self::Unrecognised(_)
239 )
240 }
241
242 pub fn private_depth(&self) -> Option<usize> {
250 match self {
251 Self::Formations => Some(2),
252 _ => None,
253 }
254 }
255}
256
257#[derive(Debug, Clone, PartialEq, Eq)]
259pub struct WideEntry {
260 pub path: PathBuf,
261 pub mode: u32,
263 pub allowed: u32,
265}
266
267#[derive(Debug, Clone, PartialEq, Eq)]
268pub enum HomeEntryState {
269 Absent,
270 Present {
271 mode: u32,
273 wide: bool,
276 wide_descendants: Vec<WideEntry>,
279 wide_descendants_omitted: usize,
281 },
282 Unreadable {
284 error: String,
285 },
286}
287
288#[derive(Debug, Clone, PartialEq, Eq)]
289pub struct HomeEntryReport {
290 pub path: PathBuf,
291 pub kind: HomeEntryKind,
292 pub state: HomeEntryState,
293}
294
295impl HomeEntryReport {
296 pub fn allowed(&self) -> u32 {
299 allowed_for(&self.path)
300 }
301}
302
303pub fn audit_murmur_home(murmur_home: &Path) -> Vec<HomeEntryReport> {
310 let mut reports = vec![report_entry(murmur_home.to_path_buf(), HomeEntryKind::Home)];
311
312 for kind in HomeEntryKind::KNOWN {
313 let name = kind.file_name().expect("a known kind has a fixed name");
314 reports.push(report_entry(murmur_home.join(name), kind));
315 }
316
317 let mut others: Vec<String> = std::fs::read_dir(murmur_home)
318 .map(|entries| {
319 entries
320 .filter_map(Result::ok)
321 .map(|entry| entry.file_name().to_string_lossy().into_owned())
322 .filter(|name| {
323 !HomeEntryKind::KNOWN
324 .iter()
325 .any(|kind| kind.file_name() == Some(name.as_str()))
326 })
327 .collect()
328 })
329 .unwrap_or_default();
330 others.sort();
331 for name in others {
332 reports.push(report_entry(
333 murmur_home.join(&name),
334 HomeEntryKind::Unrecognised(name),
335 ));
336 }
337
338 reports
339}
340
341fn report_entry(path: PathBuf, kind: HomeEntryKind) -> HomeEntryReport {
342 use std::os::unix::fs::PermissionsExt;
343
344 let metadata = match std::fs::symlink_metadata(&path) {
345 Ok(metadata) => metadata,
346 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
347 return HomeEntryReport {
348 path,
349 kind,
350 state: HomeEntryState::Absent,
351 }
352 }
353 Err(err) => {
354 return HomeEntryReport {
355 path,
356 kind,
357 state: HomeEntryState::Unreadable {
358 error: err.to_string(),
359 },
360 }
361 }
362 };
363 let mode = metadata.permissions().mode() & 0o777;
364 let private = kind.expected_private();
365 let file_type = metadata.file_type();
366 let wide =
367 private && !file_type.is_symlink() && is_wider_than(mode, allowed_for_type(file_type));
368
369 let mut wide_descendants = Vec::new();
370 let mut omitted = 0;
371 if private && file_type.is_dir() && kind != HomeEntryKind::Home {
373 collect_wide_descendants(
374 &path,
375 kind.private_depth(),
376 &mut wide_descendants,
377 &mut omitted,
378 );
379 }
380
381 HomeEntryReport {
382 path,
383 kind,
384 state: HomeEntryState::Present {
385 mode,
386 wide,
387 wide_descendants,
388 wide_descendants_omitted: omitted,
389 },
390 }
391}
392
393fn collect_wide_descendants(
395 dir: &Path,
396 depth: Option<usize>,
397 found: &mut Vec<WideEntry>,
398 omitted: &mut usize,
399) {
400 use std::os::unix::fs::PermissionsExt;
401
402 if depth == Some(0) {
403 return;
404 }
405 let Ok(entries) = std::fs::read_dir(dir) else {
406 return;
407 };
408 let mut paths: Vec<PathBuf> = entries.filter_map(Result::ok).map(|e| e.path()).collect();
409 paths.sort();
410 for path in paths {
411 let Ok(metadata) = std::fs::symlink_metadata(&path) else {
412 continue;
413 };
414 let file_type = metadata.file_type();
415 if file_type.is_symlink() || !(file_type.is_dir() || file_type.is_file()) {
416 continue;
417 }
418 let mode = metadata.permissions().mode() & 0o777;
419 let allowed = allowed_for_type(file_type);
420 if is_wider_than(mode, allowed) {
421 if found.len() < WIDE_DESCENDANT_CAP {
422 found.push(WideEntry {
423 path: path.clone(),
424 mode,
425 allowed,
426 });
427 } else {
428 *omitted += 1;
429 }
430 }
431 if file_type.is_dir() {
432 collect_wide_descendants(&path, depth.map(|depth| depth - 1), found, omitted);
433 }
434 }
435}
436
437fn allowed_for_type(file_type: std::fs::FileType) -> u32 {
438 if file_type.is_dir() {
439 MURMUR_HOME_DIR_MODE
440 } else {
441 PRIVATE_FILE_MODE
442 }
443}
444
445fn allowed_for(path: &Path) -> u32 {
446 match std::fs::symlink_metadata(path) {
447 Ok(metadata) => allowed_for_type(metadata.file_type()),
448 Err(_) => PRIVATE_FILE_MODE,
449 }
450}
451
452pub fn wide_entry_warning(path: &Path, holds: &str, mode: u32, allowed: u32) -> String {
455 let path = path.display();
456 format!(
457 "[capsule-runtime] warning[{W_SEC_028}]: {path} holds {holds} and is mode {:04o}, which \
458 other accounts on this host can read; run `chmod {:o} {path}` ({})",
459 mode & 0o777,
460 allowed,
461 security_warning_link(W_SEC_028)
462 )
463}
464
465pub fn warn_on_wide_credential_file(path: &Path, name: &str, mode: u32) {
468 if let Some(warning) = wide_credential_file_warning(path, name, mode) {
469 crate::runtime_err!("{warning}");
470 }
471}
472
473fn wide_credential_file_warning(path: &Path, name: &str, mode: u32) -> Option<String> {
474 if !is_wider_than(mode, PRIVATE_FILE_MODE) {
475 return None;
476 }
477 let path = path.display();
478 Some(format!(
479 "[capsule-runtime] warning[{W_SEC_028}]: the credential credentials.{name} is \
480 read from {path}, which is mode {:04o} and readable by other accounts on this host; run \
481 `chmod 600 {path}` ({})",
482 mode & 0o777,
483 security_warning_link(W_SEC_028)
484 ))
485}
486
487#[cfg(test)]
493pub(crate) fn run_with_home(test: &str, home: &Path) {
494 let exe = std::env::current_exe().expect("test binary path");
495 let output = std::process::Command::new(exe)
496 .args([
497 test,
498 "--exact",
499 "--ignored",
500 "--test-threads=1",
501 "--nocapture",
502 ])
503 .env("HOME", home)
504 .env(INNER_HOME_ENV, "1")
505 .output()
506 .expect("re-exec the test binary with a scratch HOME");
507 let stdout = String::from_utf8_lossy(&output.stdout);
508 let stderr = String::from_utf8_lossy(&output.stderr);
509 assert!(
510 output.status.success(),
511 "{test} failed\n--- stdout ---\n{stdout}\n--- stderr ---\n{stderr}"
512 );
513 assert!(
514 stdout.contains("1 passed"),
515 "{test} was not selected\n--- stdout ---\n{stdout}"
516 );
517}
518
519#[cfg(test)]
521pub(crate) const INNER_HOME_ENV: &str = "MURMUR_TEST_INNER_HOME";
522
523#[cfg(test)]
525pub(crate) fn in_scratch_home() -> bool {
526 std::env::var_os(INNER_HOME_ENV).is_some()
527}
528
529#[cfg(test)]
531pub(crate) fn mode_of(path: &Path) -> u32 {
532 use std::os::unix::fs::PermissionsExt;
533 std::fs::symlink_metadata(path)
534 .unwrap_or_else(|err| panic!("{}: {err}", path.display()))
535 .permissions()
536 .mode()
537 & 0o777
538}
539
540#[cfg(test)]
542pub(crate) fn wide_dir(path: &Path, mode: u32) {
543 use std::os::unix::fs::PermissionsExt;
544 std::fs::create_dir_all(path).unwrap();
545 std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).unwrap();
546}
547
548#[cfg(test)]
550pub(crate) fn wide_file(path: &Path, contents: &str, mode: u32) {
551 use std::os::unix::fs::PermissionsExt;
552 std::fs::write(path, contents).unwrap();
553 std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).unwrap();
554}
555
556#[cfg(test)]
557mod tests {
558 use super::*;
559
560 #[test]
561 fn murmur_home_is_wider_than_truth_table() {
562 for (mode, allowed, wide) in [
563 (0o600, 0o600, false),
564 (0o644, 0o600, true),
565 (0o640, 0o600, true),
566 (0o700, 0o700, false),
567 (0o711, 0o700, true),
568 (0o400, 0o600, false),
569 ] {
570 assert_eq!(
571 is_wider_than(mode, allowed),
572 wide,
573 "{mode:04o} against {allowed:04o}"
574 );
575 }
576 }
577
578 #[test]
579 fn ensure_murmur_home_creates_and_tightens_the_home_directory() {
580 let wide = tempfile::tempdir().unwrap();
581 wide_dir(&wide.path().join(".murmur"), 0o755);
582 run_with_home(
583 "murmur_home::tests::inner_ensure_murmur_home_holds_the_home_at_0700",
584 wide.path(),
585 );
586 assert_eq!(mode_of(&wide.path().join(".murmur")), 0o700);
587
588 let missing = tempfile::tempdir().unwrap();
589 run_with_home(
590 "murmur_home::tests::inner_ensure_murmur_home_holds_the_home_at_0700",
591 missing.path(),
592 );
593 assert_eq!(mode_of(&missing.path().join(".murmur")), 0o700);
594
595 let closed = tempfile::tempdir().unwrap();
596 wide_dir(&closed.path().join(".murmur"), 0o555);
597 run_with_home(
598 "murmur_home::tests::inner_ensure_murmur_home_holds_the_home_at_0700",
599 closed.path(),
600 );
601 assert_eq!(
602 mode_of(&closed.path().join(".murmur")),
603 0o500,
604 "an owner-closed home loses group and other bits and is not reopened"
605 );
606 }
607
608 #[test]
609 #[ignore = "run by ensure_murmur_home_creates_and_tightens_the_home_directory"]
610 fn inner_ensure_murmur_home_holds_the_home_at_0700() {
611 if !in_scratch_home() {
612 return;
613 }
614 let home = ensure_murmur_home().unwrap();
615 assert!(home.ends_with(".murmur"));
616 assert_eq!(mode_of(&home) & 0o077, 0);
617 }
618
619 #[test]
620 fn murmur_home_write_private_file_replaces_a_wide_file_owner_only() {
621 use std::os::unix::fs::MetadataExt;
622 let dir = tempfile::tempdir().unwrap();
623 let path = dir.path().join("config.yaml");
624 wide_file(&path, "old", 0o644);
625 let before = std::fs::metadata(&path).unwrap().ino();
626
627 write_private_file(&path, b"new").unwrap();
628
629 assert_eq!(std::fs::read_to_string(&path).unwrap(), "new");
630 assert_eq!(mode_of(&path), 0o600);
631 assert_ne!(std::fs::metadata(&path).unwrap().ino(), before);
632 }
633
634 #[test]
635 fn murmur_home_audit_reports_absent_entries_in_kind_order() {
636 let dir = tempfile::tempdir().unwrap();
637 let home = dir.path().join(".murmur");
638
639 let reports = audit_murmur_home(&home);
640
641 assert_eq!(reports.len(), 13);
642 assert_eq!(reports[0].kind, HomeEntryKind::Home);
643 assert_eq!(reports[1].kind, HomeEntryKind::Config);
644 assert_eq!(reports[10].kind, HomeEntryKind::CompiledForms);
645 assert_eq!(reports[11].kind, HomeEntryKind::BinaryCache);
646 assert_eq!(reports[12].kind, HomeEntryKind::Formations);
647 assert!(reports
648 .iter()
649 .all(|report| report.state == HomeEntryState::Absent));
650 }
651
652 #[test]
655 fn murmur_home_kinds_say_how_formations_are_kept_and_only_compiled_is_safe_to_delete() {
656 let every = HomeEntryKind::KNOWN
657 .into_iter()
658 .chain([HomeEntryKind::Home, HomeEntryKind::Unrecognised("x".into())]);
659 for kind in every {
660 assert_eq!(
661 kind.kept().is_some(),
662 kind == HomeEntryKind::Formations,
663 "{kind:?}"
664 );
665 assert_eq!(
666 kind.safe_to_delete(),
667 kind == HomeEntryKind::CompiledForms,
668 "{kind:?}"
669 );
670 }
671 assert_eq!(
672 HomeEntryKind::Formations.kept(),
673 Some(
674 "each kept until its formation has ended and the entry member's trace.retain \
675 expires it"
676 )
677 );
678 }
679
680 #[test]
684 fn murmur_home_audit_knows_formations_as_owner_only() {
685 let dir = tempfile::tempdir().unwrap();
686 let home = dir.path().join(".murmur");
687 wide_dir(&home, 0o700);
688 wide_dir(&home.join("formations"), 0o700);
689 wide_dir(&home.join("formations/frm_x"), 0o700);
690 wide_dir(&home.join("formations/frm_x/worker"), 0o755);
691 wide_dir(&home.join("formations/frm_x/worker/.murmur/ses_1"), 0o775);
692 wide_file(
693 &home.join("formations/frm_x/worker/.murmur/ses_1/trace.jsonl"),
694 "{}\n",
695 0o664,
696 );
697 wide_file(&home.join("formations/frm_x/worker/task.md"), "t", 0o664);
698
699 let report = audit_murmur_home(&home)
700 .into_iter()
701 .find(|report| report.kind == HomeEntryKind::Formations)
702 .unwrap();
703 assert_eq!(report.path, home.join("formations"));
704 assert_eq!(
705 report.kind.holds(),
706 "formation members' working directories"
707 );
708 assert!(report.kind.expected_private());
709 assert!(!report.kind.safe_to_delete());
710 match report.state {
711 HomeEntryState::Present {
712 wide,
713 wide_descendants,
714 ..
715 } => {
716 assert!(!wide);
717 assert_eq!(wide_descendants.len(), 1);
718 assert!(wide_descendants[0].path.ends_with("frm_x/worker"));
719 }
720 other => panic!("{other:?}"),
721 }
722 }
723
724 #[test]
725 fn murmur_home_audit_flags_wide_private_entries_and_never_non_private_ones() {
726 let dir = tempfile::tempdir().unwrap();
727 let home = dir.path().join(".murmur");
728 wide_dir(&home, 0o755);
729 wide_file(&home.join("config.yaml"), "credentials: {}\n", 0o644);
730 wide_dir(&home.join("artifacts"), 0o777);
731 wide_file(&home.join("artifacts/loose"), "x", 0o666);
732 wide_dir(&home.join("spend"), 0o700);
733 wide_file(&home.join("spend/2026-09-13.jsonl"), "", 0o600);
734 wide_file(&home.join("nexus-config.json"), "{}", 0o644);
735
736 let reports = audit_murmur_home(&home);
737 let find = |kind: &HomeEntryKind| {
738 reports
739 .iter()
740 .find(|report| &report.kind == kind)
741 .unwrap()
742 .state
743 .clone()
744 };
745 let wide = |state: HomeEntryState| match state {
746 HomeEntryState::Present {
747 wide,
748 wide_descendants,
749 ..
750 } => (wide, wide_descendants.len()),
751 other => panic!("{other:?}"),
752 };
753
754 assert_eq!(wide(find(&HomeEntryKind::Home)), (true, 0));
755 assert_eq!(wide(find(&HomeEntryKind::Config)), (true, 0));
756 assert_eq!(wide(find(&HomeEntryKind::Artifacts)), (false, 0));
757 assert_eq!(wide(find(&HomeEntryKind::Spend)), (false, 0));
758 let last = reports.last().unwrap();
759 assert_eq!(
760 last.kind,
761 HomeEntryKind::Unrecognised("nexus-config.json".to_string())
762 );
763 assert_eq!(wide(last.state.clone()), (false, 0));
764 assert_eq!(mode_of(&home), 0o755, "the audit changes nothing");
765 }
766
767 #[test]
768 fn murmur_home_audit_does_not_follow_symlinks() {
769 let dir = tempfile::tempdir().unwrap();
770 let home = dir.path().join(".murmur");
771 wide_dir(&home.join("state"), 0o700);
772 let outside = dir.path().join("outside");
773 wide_dir(&outside, 0o777);
774 wide_file(&outside.join("secret"), "x", 0o666);
775 std::os::unix::fs::symlink(&outside, home.join("state/link")).unwrap();
776
777 let reports = audit_murmur_home(&home);
778 let state = &reports
779 .iter()
780 .find(|report| report.kind == HomeEntryKind::State)
781 .unwrap()
782 .state;
783 assert!(
784 matches!(state, HomeEntryState::Present { wide: false, wide_descendants, .. } if wide_descendants.is_empty()),
785 "{state:?}"
786 );
787 }
788
789 #[test]
790 fn murmur_home_audit_caps_wide_descendants_and_counts_the_rest() {
791 let dir = tempfile::tempdir().unwrap();
792 let home = dir.path().join(".murmur");
793 let keys = home.join("deploy_keys");
794 wide_dir(&keys, 0o700);
795 for index in 0..25 {
796 wide_file(&keys.join(format!("key_{index:02}")), "x", 0o644);
797 }
798
799 let reports = audit_murmur_home(&home);
800 let state = &reports
801 .iter()
802 .find(|report| report.kind == HomeEntryKind::DeployKeys)
803 .unwrap()
804 .state;
805 match state {
806 HomeEntryState::Present {
807 wide,
808 wide_descendants,
809 wide_descendants_omitted,
810 ..
811 } => {
812 assert!(!wide);
813 assert_eq!(wide_descendants.len(), WIDE_DESCENDANT_CAP);
814 assert_eq!(*wide_descendants_omitted, 5);
815 assert!(wide_descendants
816 .iter()
817 .all(|entry| entry.mode == 0o644 && entry.allowed == 0o600));
818 }
819 other => panic!("{other:?}"),
820 }
821 }
822
823 #[test]
824 fn murmur_home_wide_credential_file_warning_names_the_path_and_mode_only() {
825 let path = Path::new("/home/someone/.murmur/config.yaml");
826 assert_eq!(
827 wide_credential_file_warning(path, "PROVIDER_KEY", 0o600),
828 None
829 );
830 assert_eq!(
831 wide_credential_file_warning(path, "PROVIDER_KEY", 0o100600),
832 None
833 );
834
835 let warning = wide_credential_file_warning(path, "PROVIDER_KEY", 0o100644).unwrap();
836 assert!(warning.contains("warning[W-SEC-028]"), "{warning}");
837 assert!(warning.contains("credentials.PROVIDER_KEY"), "{warning}");
838 assert!(
839 warning.contains("/home/someone/.murmur/config.yaml"),
840 "{warning}"
841 );
842 assert!(warning.contains("mode 0644"), "{warning}");
843 assert!(
844 warning.contains("`chmod 600 /home/someone/.murmur/config.yaml`"),
845 "{warning}"
846 );
847 assert!(warning.ends_with("#w-sec-028)"), "{warning}");
848 }
849
850 #[test]
851 fn murmur_home_wide_entry_warning_prints_the_chmod_for_the_allowed_mode() {
852 let warning = wide_entry_warning(Path::new("/h/.murmur"), "the murmur home", 0o755, 0o700);
853 assert_eq!(
854 warning,
855 "[capsule-runtime] warning[W-SEC-028]: /h/.murmur holds the murmur home and is mode \
856 0755, which other accounts on this host can read; run `chmod 700 /h/.murmur` \
857 (https://docs.murmur.nexus/reference/diagnostics/#w-sec-028)"
858 );
859 }
860}