1use std::fmt;
25use std::path::{Path, PathBuf};
26
27use serde::{Deserialize, Serialize};
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
31#[serde(rename_all = "kebab-case")]
32pub enum HostPathAccess {
33 Read,
35 ReadWrite,
37}
38
39impl HostPathAccess {
40 #[must_use]
42 pub fn wire_name(self) -> &'static str {
43 match self {
44 Self::Read => "read",
45 Self::ReadWrite => "read-write",
46 }
47 }
48
49 #[must_use]
51 pub fn parse(value: &str) -> Option<Self> {
52 match value {
53 "read" => Some(Self::Read),
54 "read-write" => Some(Self::ReadWrite),
55 _ => None,
56 }
57 }
58}
59
60impl fmt::Display for HostPathAccess {
61 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62 f.write_str(self.wire_name())
63 }
64}
65
66#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
70pub struct HostPathEntry {
71 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub path: Option<String>,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub access: Option<String>,
75}
76
77#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct DeclaredHostPath {
81 pub index: usize,
83 pub declared: String,
85 pub expanded: PathBuf,
87 pub access: HostPathAccess,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct HostPath {
93 pub declared: String,
95 pub path: PathBuf,
97 pub access: HostPathAccess,
98 pub credential_locations: Vec<String>,
101}
102
103impl HostPath {
104 #[must_use]
106 pub fn credential_location(&self) -> Option<String> {
107 (!self.credential_locations.is_empty()).then(|| self.credential_locations.join(", "))
108 }
109}
110
111#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct HostPathRefusal {
115 pub index: usize,
116 pub declared: String,
118 pub resolved: Option<PathBuf>,
119 pub reason: String,
120 pub missing: bool,
123}
124
125impl fmt::Display for HostPathRefusal {
126 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
127 write!(f, "host_paths[{}] ({}", self.index, self.declared)?;
128 if let Some(resolved) = &self.resolved {
129 if Path::new(&self.declared) != resolved.as_path() {
130 write!(f, ", resolved to {}", resolved.display())?;
131 }
132 }
133 write!(f, "): {}", self.reason)
134 }
135}
136
137impl std::error::Error for HostPathRefusal {}
138
139#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
141#[serde(rename_all = "lowercase")]
142pub enum HostPathMechanism {
143 Landlock,
145 Bind,
147 None,
149}
150
151impl HostPathMechanism {
152 #[must_use]
155 pub fn for_enforcement_tier(enforcement_tier: &str) -> Self {
156 match enforcement_tier {
157 "mountns+pivot_root+landlock+seccomp" => Self::Bind,
158 "landlock+seccomp" => Self::Landlock,
159 _ => Self::None,
160 }
161 }
162
163 #[must_use]
164 pub fn wire_name(self) -> &'static str {
165 match self {
166 Self::Landlock => "landlock",
167 Self::Bind => "bind",
168 Self::None => "none",
169 }
170 }
171
172 #[must_use]
174 pub fn describe(self, access: HostPathAccess) -> &'static str {
175 match (self, access) {
176 (Self::Landlock, HostPathAccess::Read) => {
177 "a read-only Landlock rule over the host's own folder"
178 }
179 (Self::Landlock, HostPathAccess::ReadWrite) => {
180 "a read-write Landlock rule over the host's own folder, never executable"
181 }
182 (Self::Bind, HostPathAccess::Read) => {
183 "a read-only bind at its own path in the composed root, plus a read-only Landlock rule"
184 }
185 (Self::Bind, HostPathAccess::ReadWrite) => {
186 "a read-write bind at its own path in the composed root, plus a read-write \
187 Landlock rule, never executable"
188 }
189 (Self::None, _) => "nothing: this tier scopes no filesystem, so every host folder is already reachable",
190 }
191 }
192}
193
194#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
197pub struct HostPathReport {
198 pub path: String,
200 pub declared: String,
202 pub access: HostPathAccess,
203 pub mechanism: HostPathMechanism,
204 pub credential_location: Option<String>,
206}
207
208impl HostPathReport {
209 #[must_use]
211 pub fn render(&self) -> String {
212 let mut line = format!("{} → {} ({})", self.declared, self.path, self.access);
213 if let Some(location) = &self.credential_location {
214 line.push_str(&format!(", reaches credential location {location}"));
215 }
216 line
217 }
218}
219
220#[must_use]
222pub fn host_path_reports(
223 host_paths: &[HostPath],
224 mechanism: HostPathMechanism,
225) -> Vec<HostPathReport> {
226 host_paths
227 .iter()
228 .map(|host_path| HostPathReport {
229 path: host_path.path.display().to_string(),
230 declared: host_path.declared.clone(),
231 access: host_path.access,
232 mechanism,
233 credential_location: host_path.credential_location(),
234 })
235 .collect()
236}
237
238#[derive(Debug, Clone, Copy, PartialEq, Eq)]
241pub struct CredentialLocation {
242 pub path: &'static str,
244 pub backstop: &'static [&'static str],
245}
246
247pub const CREDENTIAL_LOCATIONS: &[CredentialLocation] = &[
249 CredentialLocation {
250 path: ".ssh",
251 backstop: &[],
252 },
253 CredentialLocation {
254 path: ".gnupg",
255 backstop: &[],
256 },
257 CredentialLocation {
258 path: ".aws",
259 backstop: &["AWS_*"],
260 },
261 CredentialLocation {
262 path: ".config/gh",
263 backstop: &["GITHUB_TOKEN", "GH_TOKEN"],
264 },
265 CredentialLocation {
266 path: ".docker",
267 backstop: &["DOCKER_*"],
268 },
269 CredentialLocation {
270 path: ".kube",
271 backstop: &["KUBECONFIG"],
272 },
273 CredentialLocation {
274 path: ".npmrc",
275 backstop: &["NPM_TOKEN"],
276 },
277 CredentialLocation {
278 path: ".pypirc",
279 backstop: &["PYPI_TOKEN"],
280 },
281 CredentialLocation {
282 path: ".cargo/credentials.toml",
283 backstop: &["CARGO_REGISTRY_TOKEN"],
284 },
285 CredentialLocation {
286 path: ".cargo/credentials",
287 backstop: &["CARGO_REGISTRY_TOKEN"],
288 },
289 CredentialLocation {
290 path: ".config/gcloud",
291 backstop: &["GOOGLE_API_KEY"],
292 },
293 CredentialLocation {
294 path: ".cache/huggingface",
295 backstop: &["HUGGING_FACE_HUB_TOKEN"],
296 },
297 CredentialLocation {
298 path: ".netrc",
299 backstop: &[],
300 },
301 CredentialLocation {
302 path: ".git-credentials",
303 backstop: &[],
304 },
305 CredentialLocation {
307 path: ".murmur",
308 backstop: &[],
309 },
310];
311
312const SYSTEM_PATHS: &[&str] = &["/etc", "/proc", "/sys", "/dev"];
316
317fn system_paths() -> Vec<PathBuf> {
319 let mut paths: Vec<PathBuf> = crate::sealed::SEALED_RUNTIME_PATHS
320 .iter()
321 .chain(SYSTEM_PATHS)
322 .map(PathBuf::from)
323 .collect();
324 paths.push(PathBuf::from("/tmp"));
325 paths.push(crate::temp_dir::canonical_tmp());
326 let canonical: Vec<PathBuf> = paths
327 .iter()
328 .filter_map(|path| std::fs::canonicalize(path).ok())
329 .collect();
330 for path in canonical {
331 if !paths.contains(&path) {
332 paths.push(path);
333 }
334 }
335 paths
336}
337
338pub fn declare_host_paths(
344 entries: &[HostPathEntry],
345 home: Option<&Path>,
346) -> Result<Vec<DeclaredHostPath>, HostPathRefusal> {
347 let mut declared: Vec<DeclaredHostPath> = Vec::with_capacity(entries.len());
348 for (index, entry) in entries.iter().enumerate() {
349 let written = entry.path.clone().unwrap_or_else(|| "<none>".to_string());
350 let refuse = |reason: String| HostPathRefusal {
351 index,
352 declared: written.clone(),
353 resolved: None,
354 reason,
355 missing: false,
356 };
357 let Some(path) = entry.path.as_deref().filter(|path| !path.trim().is_empty()) else {
358 return Err(refuse(
359 "the entry has no path (an unquoted `~` is YAML null: write `path: \"~\"`)"
360 .to_string(),
361 ));
362 };
363 let access = match entry.access.as_deref() {
364 None => {
365 return Err(refuse(
366 "access is required: write `access: read` or `access: read-write`".to_string(),
367 ))
368 }
369 Some(value) => HostPathAccess::parse(value).ok_or_else(|| {
370 refuse(format!(
371 "access '{value}' is neither `read` nor `read-write`"
372 ))
373 })?,
374 };
375 let expanded = expand_home(path, home).map_err(refuse)?;
376 let expanded = crate::protected_paths::normalize_absolute(&expanded);
377 if expanded == Path::new("/") {
378 return Err(refuse(
379 "names /, the whole host filesystem; name the folder the capsule needs".to_string(),
380 ));
381 }
382 if let Some(earlier) = declared.iter().find(|other| other.expanded == expanded) {
383 return Err(refuse(format!(
384 "names the same directory as host_paths[{}]",
385 earlier.index
386 )));
387 }
388 declared.push(DeclaredHostPath {
389 index,
390 declared: path.to_string(),
391 expanded,
392 access,
393 });
394 }
395 Ok(declared)
396}
397
398pub(crate) fn expand_and_fold(path: &str, home: Option<&Path>) -> Result<PathBuf, String> {
403 Ok(crate::protected_paths::normalize_absolute(&expand_home(
404 path, home,
405 )?))
406}
407
408fn expand_home(path: &str, home: Option<&Path>) -> Result<PathBuf, String> {
410 let rest = if path == "~" {
411 Some("")
412 } else {
413 path.strip_prefix("~/")
414 };
415 if let Some(rest) = rest {
416 let home = home
417 .filter(|home| home.is_absolute())
418 .ok_or_else(|| "starts with ~, and HOME is not set to an absolute path".to_string())?;
419 return Ok(home.join(rest));
420 }
421 if path.starts_with('~') {
422 return Err(
423 "is relative: ~user is not expanded; write an absolute path or ~/…".to_string(),
424 );
425 }
426 let path = PathBuf::from(path);
427 if !path.is_absolute() {
428 return Err(
429 "is relative; write an absolute path or ~/… (environment variables are not expanded)"
430 .to_string(),
431 );
432 }
433 Ok(path)
434}
435
436fn resolve_through_existing_ancestor(path: &Path) -> PathBuf {
440 crate::protected_paths::canonicalize_existing_prefix(
441 &crate::protected_paths::normalize_absolute(path),
442 )
443}
444
445#[derive(Debug, Clone, Copy)]
447pub struct HostPathContext<'a> {
448 pub home: Option<&'a Path>,
450 pub murmur_home: Option<&'a Path>,
453 pub workdir: &'a Path,
455}
456
457pub fn resolve_host_paths(
464 declared: &[DeclaredHostPath],
465 context: &HostPathContext<'_>,
466) -> Result<Vec<HostPath>, HostPathRefusal> {
467 let system = system_paths();
468 let murmur_home = context.murmur_home.map(resolve_through_existing_ancestor);
469 let workdir = resolve_through_existing_ancestor(context.workdir);
470 let mut resolved: Vec<HostPath> = Vec::with_capacity(declared.len());
471 let mut indices: Vec<usize> = Vec::with_capacity(declared.len());
472 for entry in declared {
473 let refuse = |resolved: Option<PathBuf>, reason: String| HostPathRefusal {
474 index: entry.index,
475 declared: entry.declared.clone(),
476 resolved,
477 reason,
478 missing: false,
479 };
480 let path = match std::fs::canonicalize(&entry.expanded) {
481 Ok(path) => path,
482 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
483 return Err(HostPathRefusal {
484 missing: true,
485 ..refuse(None, format!("{} does not exist", entry.expanded.display()))
486 })
487 }
488 Err(error) => {
489 return Err(refuse(
490 None,
491 format!("{} cannot be resolved: {error}", entry.expanded.display()),
492 ))
493 }
494 };
495 let is_dir = std::fs::metadata(&path).map(|meta| meta.is_dir());
496 if !matches!(is_dir, Ok(true)) {
497 return Err(refuse(
498 Some(path),
499 "is not a directory; host_paths names folders only".to_string(),
500 ));
501 }
502 if path == Path::new("/") {
503 return Err(refuse(
504 Some(path),
505 "resolves to /, the whole host filesystem".to_string(),
506 ));
507 }
508 if let Some(system_path) = system.iter().find(|system| path.starts_with(system)) {
509 return Err(refuse(
510 Some(path.clone()),
511 format!(
512 "lies under {}, which the runtime replaces with its own copy in a composed \
513 root (and a private /tmp replaces /tmp), so the entry would not reach the \
514 host's folder on every tier",
515 system_path.display()
516 ),
517 ));
518 }
519 if entry.access == HostPathAccess::ReadWrite {
520 if let Some(murmur_home) = murmur_home.as_deref() {
521 if path.starts_with(murmur_home) || murmur_home.starts_with(&path) {
522 return Err(refuse(
523 Some(path.clone()),
524 format!(
525 "is read-write over murmur's own home {}, which holds this config \
526 file and every session's records; a capsule that could write it \
527 would widen what the next capsule gets. Use `access: read`, or \
528 name a folder that does not contain it",
529 murmur_home.display()
530 ),
531 ));
532 }
533 }
534 }
535 if let Some(murmur_home) = murmur_home.as_deref() {
536 let keys = crate::record_seal::keys_dir(murmur_home);
537 if path.starts_with(&keys) || keys.starts_with(&path) {
538 return Err(refuse(
539 Some(path.clone()),
540 format!(
541 "is or contains {}, which holds the record-seal key that signs every \
542 session's records; no capsule may reach it, at any access. Name a \
543 folder that does not contain it, such as {}",
544 keys.display(),
545 murmur_home
546 .join(crate::records_dir::RECORDS_DIR_NAME)
547 .display()
548 ),
549 ));
550 }
551 }
552 if path.starts_with(&workdir) {
553 return Err(refuse(
554 Some(path.clone()),
555 format!(
556 "lies inside the work folder {}, which the capsule already reaches read-write",
557 workdir.display()
558 ),
559 ));
560 }
561 if let Some(position) = resolved.iter().position(|other| other.path == path) {
562 return Err(refuse(
563 Some(path),
564 format!(
565 "resolves to the same directory as host_paths[{}]",
566 indices[position]
567 ),
568 ));
569 }
570 let credential_locations = context
571 .home
572 .map(|home| credential_locations(&path, home))
573 .unwrap_or_default();
574 indices.push(entry.index);
575 resolved.push(HostPath {
576 declared: entry.declared.clone(),
577 path,
578 access: entry.access,
579 credential_locations,
580 });
581 }
582 Ok(resolved)
583}
584
585#[must_use]
588pub fn credential_locations(path: &Path, home: &Path) -> Vec<String> {
589 let home = std::fs::canonicalize(home).unwrap_or_else(|_| home.to_path_buf());
590 CREDENTIAL_LOCATIONS
591 .iter()
592 .filter(|location| {
593 let lexical = home.join(location.path);
594 if std::fs::symlink_metadata(&lexical).is_err() {
595 return false;
596 }
597 let canonical = std::fs::canonicalize(&lexical).unwrap_or_else(|_| lexical.clone());
598 [lexical, canonical]
599 .iter()
600 .any(|location| path.starts_with(location) || location.starts_with(path))
601 })
602 .map(|location| format!("~/{}", location.path))
603 .collect()
604}
605
606#[must_use]
609pub fn credential_warning(host_path: &HostPath) -> Option<String> {
610 use murmur_artifact::{security_warning_link, W_SEC_035};
611 let locations = host_path.credential_location()?;
612 let verb = match host_path.access {
613 HostPathAccess::Read => "read",
614 HostPathAccess::ReadWrite => "read and change",
615 };
616 Some(format!(
617 "warning[{W_SEC_035}]: host_paths entry {} ({}) reaches the credential location {}, so \
618 every scoped or sealed capsule on this host can {verb} it ({})",
619 host_path.declared,
620 host_path.access,
621 locations,
622 security_warning_link(W_SEC_035)
623 ))
624}
625
626#[derive(Debug, Clone, PartialEq, Eq)]
629pub struct ExecuteRoot {
630 pub dir: PathBuf,
631 pub because: PathBuf,
633}
634
635pub fn check_execute_roots(
643 host_paths: &[HostPath],
644 roots: &[ExecuteRoot],
645 mechanism: HostPathMechanism,
646) -> Result<Vec<PathBuf>, HostPathRefusal> {
647 if mechanism == HostPathMechanism::None {
648 return Ok(Vec::new());
649 }
650 let mut protected: Vec<PathBuf> = Vec::new();
651 for (index, host_path) in host_paths.iter().enumerate() {
652 if host_path.access != HostPathAccess::ReadWrite {
653 continue;
654 }
655 for root in roots {
656 let refuse = |reason: String| HostPathRefusal {
657 index,
658 declared: host_path.declared.clone(),
659 resolved: Some(host_path.path.clone()),
660 reason,
661 missing: false,
662 };
663 if host_path.path.starts_with(&root.dir) {
664 return Err(refuse(format!(
665 "is read-write inside {}, a folder this session may run {} from; name a \
666 folder outside it, or make the entry `access: read`",
667 root.dir.display(),
668 root.because.display()
669 )));
670 }
671 if root.dir.starts_with(&host_path.path) {
672 if mechanism == HostPathMechanism::Landlock {
673 return Err(refuse(format!(
674 "is read-write over {}, a folder this session may run {} from, so the \
675 capsule could rewrite what it runs; at scoped nothing prevents that. \
676 Name a folder that does not contain it, make the entry `access: read`, \
677 or declare containment: sealed, which binds it read-only",
678 root.dir.display(),
679 root.because.display()
680 )));
681 }
682 if !protected.contains(&root.dir) {
683 protected.push(root.dir.clone());
684 }
685 }
686 }
687 }
688 Ok(protected)
689}
690
691pub fn check_policy_execute_roots(
695 policy: &crate::types::CapabilityPolicy,
696 mechanism: HostPathMechanism,
697) -> Result<(), HostPathRefusal> {
698 if mechanism == HostPathMechanism::None
699 || !policy
700 .host_paths
701 .iter()
702 .any(|host_path| host_path.access == HostPathAccess::ReadWrite)
703 {
704 return Ok(());
705 }
706 check_execute_roots(
707 &policy.host_paths,
708 &crate::sandbox::host_path_execute_roots(policy),
709 mechanism,
710 )
711 .map(|_| ())
712}
713
714#[cfg(test)]
715mod tests {
716 use super::*;
717
718 fn entry(path: &str, access: &str) -> HostPathEntry {
719 HostPathEntry {
720 path: Some(path.to_string()),
721 access: Some(access.to_string()),
722 }
723 }
724
725 fn refusal(entries: &[HostPathEntry]) -> HostPathRefusal {
726 declare_host_paths(entries, Some(Path::new("/home/op"))).unwrap_err()
727 }
728
729 fn scratch() -> tempfile::TempDir {
732 let target = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target");
733 let base = if target.is_dir() {
734 target
735 } else {
736 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
737 };
738 tempfile::tempdir_in(base).unwrap()
739 }
740
741 #[test]
742 fn host_paths_two_entries_parse_and_tilde_expands_against_home() {
743 let declared = declare_host_paths(
744 &[entry("~", "read"), entry("~/x/./y/../z", "read-write")],
745 Some(Path::new("/home/op")),
746 )
747 .unwrap();
748 assert_eq!(declared[0].expanded, PathBuf::from("/home/op"));
749 assert_eq!(declared[0].access, HostPathAccess::Read);
750 assert_eq!(declared[1].expanded, PathBuf::from("/home/op/x/z"));
751 assert_eq!(declared[1].access, HostPathAccess::ReadWrite);
752 assert_eq!(declared[1].declared, "~/x/./y/../z");
753 assert_eq!(declared[1].index, 1);
754 }
755
756 #[test]
757 fn host_paths_shape_refusals_name_the_index_and_the_path_as_written() {
758 let cases: Vec<(Vec<HostPathEntry>, &str, &str)> = vec![
759 (
760 vec![entry("relative/dir", "read")],
761 "host_paths[0] (relative/dir)",
762 "is relative",
763 ),
764 (
765 vec![entry("~user/x", "read")],
766 "host_paths[0] (~user/x)",
767 "~user is not expanded",
768 ),
769 (
770 vec![entry("$HOME/x", "read")],
771 "host_paths[0] ($HOME/x)",
772 "is relative",
773 ),
774 (vec![entry("/", "read")], "host_paths[0] (/)", "names /"),
775 (
776 vec![entry("/home/..", "read")],
777 "host_paths[0] (/home/..)",
778 "names /",
779 ),
780 (
781 vec![HostPathEntry {
782 path: Some("/srv".to_string()),
783 access: None,
784 }],
785 "host_paths[0] (/srv)",
786 "access is required",
787 ),
788 (
789 vec![entry("/srv", "write")],
790 "host_paths[0] (/srv)",
791 "access 'write'",
792 ),
793 (
794 vec![entry("/srv", "read"), entry("/srv/", "read-write")],
795 "host_paths[1] (/srv/)",
796 "same directory as host_paths[0]",
797 ),
798 (
799 vec![HostPathEntry {
800 path: None,
801 access: Some("read".to_string()),
802 }],
803 "host_paths[0] (<none>)",
804 "no path",
805 ),
806 ];
807 for (entries, head, reason) in cases {
808 let text = refusal(&entries).to_string();
809 assert!(text.starts_with(head), "{text}");
810 assert!(text.contains(reason), "{text}");
811 }
812 }
813
814 #[test]
815 fn host_paths_tilde_needs_an_absolute_home() {
816 let error = declare_host_paths(&[entry("~/x", "read")], None).unwrap_err();
817 assert!(error.reason.contains("HOME"), "{error}");
818 }
819
820 fn resolve_one(
821 path: &Path,
822 access: HostPathAccess,
823 murmur_home: Option<&Path>,
824 workdir: &Path,
825 ) -> Result<Vec<HostPath>, HostPathRefusal> {
826 let declared = vec![DeclaredHostPath {
827 index: 0,
828 declared: path.display().to_string(),
829 expanded: path.to_path_buf(),
830 access,
831 }];
832 resolve_host_paths(
833 &declared,
834 &HostPathContext {
835 home: None,
836 murmur_home,
837 workdir,
838 },
839 )
840 }
841
842 #[test]
843 fn host_paths_launch_refusals_name_the_entry_its_resolution_and_the_reason() {
844 let dir = scratch();
845 let root = dir.path().canonicalize().unwrap();
846 let workdir = root.join("project/workdir");
847 std::fs::create_dir_all(workdir.join("inner")).unwrap();
848 let home = root.join("home");
849 let murmur_home = home.join(".murmur");
850 std::fs::create_dir_all(murmur_home.join("records")).unwrap();
851 let file = root.join("file.txt");
852 std::fs::write(&file, "x").unwrap();
853 let to_root = root.join("to-root");
854 std::os::unix::fs::symlink("/", &to_root).unwrap();
855 let tmp_dir = tempfile::tempdir_in("/tmp").unwrap();
856
857 let read = HostPathAccess::Read;
858 let read_write = HostPathAccess::ReadWrite;
859 let cases: Vec<(PathBuf, HostPathAccess, &str)> = vec![
860 (root.join("missing"), read, "does not exist"),
861 (to_root.clone(), read, "resolves to /"),
862 (file.clone(), read, "is not a directory"),
863 (PathBuf::from("/usr/share"), read, "lies under /usr"),
864 (PathBuf::from("/etc"), read, "lies under /etc"),
865 (PathBuf::from("/proc"), read, "lies under /proc"),
866 (tmp_dir.path().to_path_buf(), read, "/tmp"),
867 (murmur_home.clone(), read_write, "murmur's own home"),
868 (murmur_home.join("records"), read_write, "murmur's own home"),
869 (home.clone(), read_write, "murmur's own home"),
870 (workdir.join("inner"), read, "inside the work folder"),
871 (workdir.clone(), read, "inside the work folder"),
872 ];
873 for (path, access, reason) in cases {
874 let error = resolve_one(&path, access, Some(&murmur_home), &workdir)
875 .expect_err(&format!("{} should be refused", path.display()));
876 let text = error.to_string();
877 assert!(
878 text.starts_with(&format!("host_paths[0] ({}", path.display())),
879 "{text}"
880 );
881 assert!(text.contains(reason), "{reason}: {text}");
882 }
883 let error = resolve_one(&to_root, read, None, &workdir).unwrap_err();
885 assert_eq!(error.resolved.as_deref(), Some(Path::new("/")));
886 assert!(error.to_string().contains("resolved to /"), "{error}");
887 }
888
889 #[test]
890 fn host_paths_murmur_home_read_and_a_symlinked_entry_resolve() {
891 let dir = scratch();
892 let root = dir.path().canonicalize().unwrap();
893 let murmur_home = root.join("home/.murmur");
894 std::fs::create_dir_all(&murmur_home).unwrap();
895 let target = root.join("real");
896 std::fs::create_dir(&target).unwrap();
897 let link = root.join("link");
898 std::os::unix::fs::symlink(&target, &link).unwrap();
899 let workdir = root.join("project/workdir");
900
901 std::fs::create_dir_all(murmur_home.join("records")).unwrap();
902 let resolved = resolve_one(
903 &murmur_home.join("records"),
904 HostPathAccess::Read,
905 Some(&murmur_home),
906 &workdir,
907 )
908 .unwrap();
909 assert_eq!(resolved[0].path, murmur_home.join("records"));
910
911 let resolved = resolve_one(&link, HostPathAccess::ReadWrite, None, &workdir).unwrap();
912 assert_eq!(resolved[0].path, target);
913 assert_eq!(resolved[0].declared, link.display().to_string());
914 }
915
916 #[test]
919 fn host_paths_an_entry_reaching_the_record_seal_key_is_refused_at_any_access() {
920 let dir = scratch();
921 let root = dir.path().canonicalize().unwrap();
922 let home = root.join("home");
923 let murmur_home = home.join(".murmur");
924 std::fs::create_dir_all(murmur_home.join("keys")).unwrap();
925 std::fs::create_dir_all(murmur_home.join("records")).unwrap();
926 let workdir = root.join("project/workdir");
927 for (path, access) in [
928 (murmur_home.clone(), HostPathAccess::Read),
929 (murmur_home.join("keys"), HostPathAccess::Read),
930 (home.clone(), HostPathAccess::Read),
931 ] {
932 let error = resolve_one(&path, access, Some(&murmur_home), &workdir)
933 .expect_err(&format!("{} should be refused", path.display()));
934 let text = error.to_string();
935 assert!(text.contains("record-seal key"), "{text}");
936 assert!(
937 text.contains(&murmur_home.join("keys").display().to_string()),
938 "{text}"
939 );
940 }
941 assert!(resolve_one(
943 &murmur_home.join("keys"),
944 HostPathAccess::ReadWrite,
945 Some(&murmur_home),
946 &workdir
947 )
948 .is_err());
949 let resolved = resolve_one(
950 &murmur_home.join("records"),
951 HostPathAccess::Read,
952 Some(&murmur_home),
953 &workdir,
954 )
955 .unwrap();
956 assert_eq!(resolved[0].path, murmur_home.join("records"));
957 }
958
959 #[test]
960 fn host_paths_two_entries_resolving_to_one_directory_are_refused() {
961 let dir = scratch();
962 let root = dir.path().canonicalize().unwrap();
963 let target = root.join("real");
964 std::fs::create_dir(&target).unwrap();
965 let link = root.join("link");
966 std::os::unix::fs::symlink(&target, &link).unwrap();
967 let declared = declare_host_paths(
968 &[
969 entry(&target.display().to_string(), "read"),
970 entry(&link.display().to_string(), "read"),
971 ],
972 None,
973 )
974 .unwrap();
975 let error = resolve_host_paths(
976 &declared,
977 &HostPathContext {
978 home: None,
979 murmur_home: None,
980 workdir: &root.join("workdir"),
981 },
982 )
983 .unwrap_err();
984 assert_eq!(error.index, 1);
985 assert!(error.reason.contains("host_paths[0]"), "{error}");
986 }
987
988 #[test]
989 fn host_paths_credential_locations_warn_when_is_under_or_contains() {
990 let dir = scratch();
991 let home = dir.path().canonicalize().unwrap();
992 std::fs::create_dir_all(home.join(".ssh/keys")).unwrap();
993 std::fs::create_dir_all(home.join(".cargo")).unwrap();
994 std::fs::write(home.join(".cargo/credentials.toml"), "").unwrap();
995 std::fs::create_dir_all(home.join("plain")).unwrap();
996
997 assert_eq!(credential_locations(&home.join(".ssh"), &home), ["~/.ssh"]);
999 assert_eq!(
1001 credential_locations(&home.join(".ssh/keys"), &home),
1002 ["~/.ssh"]
1003 );
1004 assert_eq!(
1006 credential_locations(&home.join(".cargo"), &home),
1007 ["~/.cargo/credentials.toml"]
1008 );
1009 assert_eq!(
1010 credential_locations(&home, &home),
1011 ["~/.ssh", "~/.cargo/credentials.toml"]
1012 );
1013 assert!(credential_locations(&home.join("plain"), &home).is_empty());
1015 assert!(!credential_locations(&home, &home).contains(&"~/.aws".to_string()));
1017
1018 let host_path = HostPath {
1019 declared: "~/.ssh".to_string(),
1020 path: home.join(".ssh"),
1021 access: HostPathAccess::Read,
1022 credential_locations: vec!["~/.ssh".to_string()],
1023 };
1024 let warning = credential_warning(&host_path).unwrap();
1025 assert!(warning.starts_with("warning[W-SEC-035]: host_paths entry ~/.ssh (read)"));
1026 assert!(warning.contains("diagnostics/#w-sec-035"), "{warning}");
1027 let silent = HostPath {
1028 credential_locations: Vec::new(),
1029 ..host_path
1030 };
1031 assert!(credential_warning(&silent).is_none());
1032 }
1033
1034 #[test]
1035 fn host_paths_credential_backstops_are_credential_patterns() {
1036 for location in CREDENTIAL_LOCATIONS {
1037 for pattern in location.backstop {
1038 assert!(
1039 crate::shell::CREDENTIAL_ENV_PATTERNS.contains(pattern),
1040 "{pattern} for ~/{} is not a credential backstop pattern",
1041 location.path
1042 );
1043 }
1044 }
1045 }
1046
1047 fn host_path(path: &str, access: HostPathAccess) -> HostPath {
1048 HostPath {
1049 declared: path.to_string(),
1050 path: PathBuf::from(path),
1051 access,
1052 credential_locations: Vec::new(),
1053 }
1054 }
1055
1056 fn root(dir: &str, because: &str) -> ExecuteRoot {
1057 ExecuteRoot {
1058 dir: PathBuf::from(dir),
1059 because: PathBuf::from(because),
1060 }
1061 }
1062
1063 #[test]
1064 fn host_paths_execute_root_rules_per_mechanism() {
1065 let roots = [root("/opt/tools/bin", "/opt/tools/bin/tool")];
1066 let rw = HostPathAccess::ReadWrite;
1067 let tools = [host_path("/opt/tools", rw)];
1069 let error = check_execute_roots(&tools, &roots, HostPathMechanism::Landlock).unwrap_err();
1070 assert!(error.to_string().contains("/opt/tools/bin/tool"), "{error}");
1071 assert_eq!(
1072 check_execute_roots(&tools, &roots, HostPathMechanism::Bind).unwrap(),
1073 [PathBuf::from("/opt/tools/bin")]
1074 );
1075 for path in ["/opt/tools/bin", "/opt/tools/bin/sub"] {
1077 let entry = [host_path(path, rw)];
1078 for mechanism in [HostPathMechanism::Landlock, HostPathMechanism::Bind] {
1079 let error = check_execute_roots(&entry, &roots, mechanism).unwrap_err();
1080 assert!(error.reason.contains("/opt/tools/bin"), "{error}");
1081 }
1082 }
1083 let read = [host_path("/opt/tools/bin", HostPathAccess::Read)];
1085 assert!(
1086 check_execute_roots(&read, &roots, HostPathMechanism::Landlock)
1087 .unwrap()
1088 .is_empty()
1089 );
1090 assert!(check_execute_roots(&tools, &roots, HostPathMechanism::None)
1091 .unwrap()
1092 .is_empty());
1093 let other = [host_path("/srv/data", rw)];
1095 assert!(
1096 check_execute_roots(&other, &roots, HostPathMechanism::Landlock)
1097 .unwrap()
1098 .is_empty()
1099 );
1100 }
1101
1102 #[test]
1103 fn host_paths_reports_carry_every_key_in_config_order() {
1104 let paths = [
1105 HostPath {
1106 declared: "~/.cargo".to_string(),
1107 path: PathBuf::from("/home/op/.cargo"),
1108 access: HostPathAccess::ReadWrite,
1109 credential_locations: vec!["~/.cargo/credentials.toml".to_string()],
1110 },
1111 host_path("/srv/data", HostPathAccess::Read),
1112 ];
1113 let reports = host_path_reports(&paths, HostPathMechanism::Landlock);
1114 let json = serde_json::to_value(&reports).unwrap();
1115 assert_eq!(
1116 json,
1117 serde_json::json!([
1118 {
1119 "path": "/home/op/.cargo",
1120 "declared": "~/.cargo",
1121 "access": "read-write",
1122 "mechanism": "landlock",
1123 "credential_location": "~/.cargo/credentials.toml"
1124 },
1125 {
1126 "path": "/srv/data",
1127 "declared": "/srv/data",
1128 "access": "read",
1129 "mechanism": "landlock",
1130 "credential_location": null
1131 }
1132 ])
1133 );
1134 assert_eq!(
1135 reports[0].render(),
1136 "~/.cargo → /home/op/.cargo (read-write), reaches credential location \
1137 ~/.cargo/credentials.toml"
1138 );
1139 for (tier, mechanism) in [
1140 ("mountns+pivot_root+landlock+seccomp", "bind"),
1141 ("landlock+seccomp", "landlock"),
1142 ("seccomp-only", "none"),
1143 ("none", "none"),
1144 ] {
1145 assert_eq!(
1146 HostPathMechanism::for_enforcement_tier(tier).wire_name(),
1147 mechanism
1148 );
1149 }
1150 }
1151}