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