1use std::net::IpAddr;
96use std::path::{Path, PathBuf};
97use std::sync::Arc;
98
99use murmur_artifact::security_warnings::{
100 security_warning_link, W_SEC_001, W_SEC_002, W_SEC_005, W_SEC_010,
101};
102use murmur_artifact::InterpreterRuntimeGrant;
103
104use crate::types::CapabilityPolicy;
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub(crate) enum EnforcementTier {
110 KernelSealed,
119 KernelFull,
122 KernelSeccompOnly,
125 EnvironmentOnly,
128 Unconfined,
134}
135
136pub(crate) fn tier_from_probe(
145 is_linux: bool,
146 landlock_fully_enforced: Option<bool>,
147 namespaces: &crate::namespace_probe::NamespaceReading,
148) -> EnforcementTier {
149 if !is_linux {
150 return EnforcementTier::EnvironmentOnly;
151 }
152 match landlock_fully_enforced {
153 Some(true) if namespaces.sealed_root_created() => EnforcementTier::KernelSealed,
154 Some(true) => EnforcementTier::KernelFull,
155 Some(false) | None => EnforcementTier::KernelSeccompOnly,
156 }
157}
158
159pub(crate) fn applied_tier(
171 host_tier: EnforcementTier,
172 declared: murmur_artifact::ContainmentClass,
173) -> EnforcementTier {
174 if declared == murmur_artifact::ContainmentClass::Unconfined {
175 return EnforcementTier::Unconfined;
176 }
177 match host_tier {
178 EnforcementTier::KernelSealed if declared < murmur_artifact::ContainmentClass::Sealed => {
179 EnforcementTier::KernelFull
180 }
181 other => other,
182 }
183}
184
185#[derive(Debug, Clone)]
193pub struct HostProbe {
194 landlock_fully_enforced: Option<bool>,
195 landlock_kernel_abi: Option<u8>,
198 namespaces: crate::namespace_probe::NamespaceReading,
199}
200
201static PROBES_TAKEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
203
204impl HostProbe {
205 pub fn probe() -> HostProbe {
226 PROBES_TAKEN.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
227 #[cfg(target_os = "linux")]
228 let probe = HostProbe {
229 landlock_fully_enforced: linux_enforce::probe_landlock_full_access(),
230 landlock_kernel_abi: linux_enforce::kernel_landlock_abi(),
231 namespaces: crate::namespace_probe::probe_namespaces(),
232 };
233 #[cfg(not(target_os = "linux"))]
234 let probe = HostProbe {
235 landlock_fully_enforced: None,
236 landlock_kernel_abi: None,
237 namespaces: crate::namespace_probe::probe_namespaces(),
238 };
239 probe
240 }
241
242 #[cfg(test)]
245 pub(crate) fn from_parts(
246 landlock_fully_enforced: Option<bool>,
247 namespaces: crate::namespace_probe::NamespaceReading,
248 ) -> HostProbe {
249 HostProbe {
250 landlock_fully_enforced,
251 landlock_kernel_abi: None,
252 namespaces,
253 }
254 }
255
256 #[cfg(test)]
259 pub(crate) fn with_landlock_kernel_abi(self, abi: Option<u8>) -> HostProbe {
260 HostProbe {
261 landlock_kernel_abi: abi,
262 ..self
263 }
264 }
265
266 pub(crate) fn tier(&self) -> EnforcementTier {
268 tier_from_probe(
269 cfg!(target_os = "linux"),
270 self.landlock_fully_enforced,
271 &self.namespaces,
272 )
273 }
274
275 pub fn namespaces(&self) -> &crate::namespace_probe::NamespaceReading {
277 &self.namespaces
278 }
279
280 pub fn egress_namespace_failure(
283 &self,
284 ) -> Option<crate::network_namespace::EgressNamespaceFailure> {
285 self.namespaces.egress_namespace_failure()
286 }
287
288 pub(crate) fn mount_namespace_usable(&self) -> bool {
291 cfg!(target_os = "linux") && self.namespaces.mount_namespace_created()
292 }
293
294 pub fn sealed_shortfall(&self) -> Option<crate::sealed::SealedShortfall> {
297 crate::sealed::sealed_shortfall(
298 cfg!(target_os = "linux"),
299 self.landlock_fully_enforced == Some(true),
300 &self.namespaces,
301 )
302 }
303
304 pub fn userns_grant(&self) -> Option<crate::sealed::UsernsGrant> {
307 if cfg!(target_os = "linux") {
308 Some(self.namespaces.userns_grant)
309 } else {
310 None
311 }
312 }
313
314 pub fn landlock_kernel_abi(&self) -> Option<u8> {
317 self.landlock_kernel_abi
318 }
319
320 pub fn probes_taken() -> u64 {
327 PROBES_TAKEN.load(std::sync::atomic::Ordering::Relaxed)
328 }
329}
330
331#[cfg(all(test, target_os = "linux"))]
334pub(crate) fn detect_enforcement_tier() -> EnforcementTier {
335 HostProbe::probe().tier()
336}
337
338pub fn detect_sealed_shortfall() -> Option<crate::sealed::SealedShortfall> {
345 HostProbe::probe().sealed_shortfall()
346}
347
348pub fn detect_userns_grant() -> Option<crate::sealed::UsernsGrant> {
355 HostProbe::probe().userns_grant()
356}
357
358pub fn detect_landlock_abi() -> Option<u8> {
363 HostProbe::probe().landlock_kernel_abi()
364}
365
366pub(crate) const LANDLOCK_TARGET_ABI: u8 = 3;
374
375pub(crate) fn ruleset_landlock_abi(kernel_abi: Option<u8>, tier: EnforcementTier) -> Option<u8> {
382 match tier {
383 EnforcementTier::KernelFull | EnforcementTier::KernelSealed => kernel_abi
384 .filter(|abi| *abi >= 1)
385 .map(|abi| abi.min(LANDLOCK_TARGET_ABI)),
386 EnforcementTier::KernelSeccompOnly
387 | EnforcementTier::EnvironmentOnly
388 | EnforcementTier::Unconfined => None,
389 }
390}
391
392pub fn landlock_doctor_lines(kernel_abi: Option<u8>) -> Vec<String> {
398 if !cfg!(target_os = "linux") {
399 return vec!["kernel ABI: n/a (Landlock is a Linux mechanism)".to_string()];
400 }
401 let Some(kernel) = kernel_abi.filter(|abi| *abi >= 1) else {
402 return vec![
403 "kernel ABI: none (no Landlock on this host)".to_string(),
404 "ruleset ABI: n/a (no Landlock domain is installed)".to_string(),
405 ];
406 };
407 let built = kernel.min(LANDLOCK_TARGET_ABI);
408 let mut lines = vec![
409 format!("kernel ABI: {kernel}"),
410 format!("ruleset ABI: {built} (the kernel's, capped at {LANDLOCK_TARGET_ABI})"),
411 ];
412 if built < 2 {
413 lines.push(
414 "moving or hard-linking a file between folders inside the work folder fails with \
415 EXDEV (Invalid cross-device link) on this host; Linux 5.19 (Landlock ABI v2) \
416 permits it. Tools that fall back to copy-and-delete (mv, pip, git) still work; uv \
417 and other tools that rename atomically between folders do not."
418 .to_string(),
419 );
420 }
421 if built < 3 {
422 lines.push(
423 "truncate(2) on a file outside the work folder is not mediated below Landlock ABI v3 \
424 (Linux 6.2)."
425 .to_string(),
426 );
427 }
428 lines
429}
430
431pub(crate) fn resolve_network_allowlist_ips(
449 network_allow: &[String],
450) -> Result<Vec<IpAddr>, String> {
451 let rules = crate::network_policy::parse_network_allow_rules(network_allow)
452 .map_err(|error| error.to_string())?;
453
454 let mut ips = std::collections::BTreeSet::new();
455 for rule in &rules {
456 let host = rule.host.as_str();
457 match crate::dns_resolver::resolve(host) {
458 crate::dns_resolver::Resolution::Resolved(addresses) => ips.extend(addresses),
459 crate::dns_resolver::Resolution::DoesNotExist => {}
460 crate::dns_resolver::Resolution::DidNotAnswer(reason) => crate::runtime_err!(
464 "[capsule-runtime] warning: the network allowlist host '{host}' could not be \
465 resolved at launch: {reason} (a subprocess reaching it by literal address is \
466 denied for this run)"
467 ),
468 }
469 }
470
471 Ok(ips.into_iter().collect())
472}
473
474pub(crate) fn resolve_exec_allowlist(shell_allow: &[String]) -> Vec<PathBuf> {
491 let path_dirs: Vec<PathBuf> = std::env::var_os("PATH")
492 .map(|path| std::env::split_paths(&path).collect())
493 .unwrap_or_default();
494 resolve_exec_allowlist_in(shell_allow, &path_dirs)
495}
496
497pub(crate) fn resolve_exec_allowlist_in(
503 shell_allow: &[String],
504 path_dirs: &[PathBuf],
505) -> Vec<PathBuf> {
506 let mut resolved = std::collections::BTreeSet::new();
507 for entry in shell_allow {
508 if entry.is_empty() {
509 continue;
510 }
511 for dir in path_dirs {
512 let candidate = dir.join(entry);
513 if is_executable_file(&candidate) {
514 if let Ok(canonical) = std::fs::canonicalize(&candidate) {
515 resolved.insert(canonical);
516 }
517 }
518 }
519 }
520 resolved.into_iter().collect()
521}
522
523pub(crate) fn resolve_invoked_binary_path(binary: &str) -> String {
538 let path_dirs: Vec<PathBuf> = std::env::var_os("PATH")
539 .map(|path| std::env::split_paths(&path).collect())
540 .unwrap_or_default();
541 resolve_invoked_binary_path_in(binary, &path_dirs)
542}
543
544pub(crate) fn resolve_invoked_binary_path_in(binary: &str, path_dirs: &[PathBuf]) -> String {
549 let fallback = || binary.to_string();
550 if binary.is_empty() {
551 return fallback();
552 }
553 if binary.contains('/') {
554 return match std::fs::canonicalize(binary) {
555 Ok(canonical) => canonical.to_string_lossy().into_owned(),
556 Err(_) => fallback(),
557 };
558 }
559 for dir in path_dirs {
560 let candidate = dir.join(binary);
561 if is_executable_file(&candidate) {
562 if let Ok(canonical) = std::fs::canonicalize(&candidate) {
563 return canonical.to_string_lossy().into_owned();
564 }
565 }
566 }
567 fallback()
568}
569
570pub fn find_on_path(binary: &str) -> Option<PathBuf> {
577 std::env::split_paths(&std::env::var_os("PATH")?)
578 .map(|dir| dir.join(binary))
579 .find(|candidate| is_executable_file(candidate))
580}
581
582pub(crate) fn is_executable_file(path: &Path) -> bool {
583 use std::os::unix::fs::PermissionsExt;
584 std::fs::metadata(path)
585 .map(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0)
586 .unwrap_or(false)
587}
588
589pub(crate) const DEFAULT_LIBRARY_SEARCH_DIRS: &[&str] = &[
618 "/lib",
619 "/lib64",
620 "/usr/lib",
621 "/usr/lib64",
622 "/lib/x86_64-linux-gnu",
623 "/usr/lib/x86_64-linux-gnu",
624 "/lib/aarch64-linux-gnu",
625 "/usr/lib/aarch64-linux-gnu",
626];
627
628#[derive(Debug, Default, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
633#[serde(deny_unknown_fields)]
634pub(crate) struct ElfDependencies {
635 pub(crate) interp: Option<String>,
636 pub(crate) needed: Vec<String>,
637 pub(crate) runpaths: Vec<String>,
638}
639
640pub(crate) trait ElfSource {
644 fn size(&self) -> u64;
646
647 fn read_at(&self, offset: u64, len: u64) -> Option<std::borrow::Cow<'_, [u8]>>;
650}
651
652impl ElfSource for [u8] {
654 fn size(&self) -> u64 {
655 self.len() as u64
656 }
657
658 fn read_at(&self, offset: u64, len: u64) -> Option<std::borrow::Cow<'_, [u8]>> {
659 let start = usize::try_from(offset).ok()?;
660 let end = start.checked_add(usize::try_from(len).ok()?)?;
661 self.get(start..end).map(std::borrow::Cow::Borrowed)
662 }
663}
664
665pub(crate) struct ElfFile {
667 file: std::fs::File,
668 metadata: std::fs::Metadata,
669}
670
671impl ElfFile {
672 pub(crate) fn open_host(path: &Path) -> std::io::Result<Self> {
674 let file = std::fs::File::open(path)?;
675 let metadata = file.metadata()?;
676 Ok(Self { file, metadata })
677 }
678
679 #[cfg(target_os = "linux")]
681 pub(crate) fn metadata(&self) -> &std::fs::Metadata {
682 &self.metadata
683 }
684}
685
686impl ElfSource for ElfFile {
687 fn size(&self) -> u64 {
688 self.metadata.len()
689 }
690
691 fn read_at(&self, offset: u64, len: u64) -> Option<std::borrow::Cow<'_, [u8]>> {
692 use std::os::unix::fs::FileExt;
693
694 if offset.checked_add(len)? > self.size() {
695 return None;
696 }
697 let mut bytes = vec![0; usize::try_from(len).ok()?];
698 self.file.read_exact_at(&mut bytes, offset).ok()?;
699 Some(std::borrow::Cow::Owned(bytes))
700 }
701}
702
703fn read_u16(bytes: &[u8], off: usize, le: bool) -> Option<u16> {
704 let raw: [u8; 2] = bytes.get(off..off + 2)?.try_into().ok()?;
705 Some(if le {
706 u16::from_le_bytes(raw)
707 } else {
708 u16::from_be_bytes(raw)
709 })
710}
711
712fn read_u32(bytes: &[u8], off: usize, le: bool) -> Option<u32> {
713 let raw: [u8; 4] = bytes.get(off..off + 4)?.try_into().ok()?;
714 Some(if le {
715 u32::from_le_bytes(raw)
716 } else {
717 u32::from_be_bytes(raw)
718 })
719}
720
721fn read_u64(bytes: &[u8], off: usize, le: bool) -> Option<u64> {
722 let raw: [u8; 8] = bytes.get(off..off + 8)?.try_into().ok()?;
723 Some(if le {
724 u64::from_le_bytes(raw)
725 } else {
726 u64::from_be_bytes(raw)
727 })
728}
729
730fn elf64_little_endian(header: &[u8]) -> Option<bool> {
733 if header.get(0..4)? != b"\x7fELF" || *header.get(4)? != 2 {
735 return None;
736 }
737 match header.get(5)? {
738 1 => Some(true),
739 2 => Some(false),
740 _ => None,
741 }
742}
743
744fn read_clamped<S: ElfSource + ?Sized>(
747 image: &S,
748 offset: u64,
749 len: u64,
750) -> Option<std::borrow::Cow<'_, [u8]>> {
751 let available = image.size().saturating_sub(offset).min(len);
752 if available == 0 {
753 return Some(std::borrow::Cow::Borrowed(&[]));
754 }
755 image.read_at(offset, available)
756}
757
758fn cstr_at_start(region: &[u8]) -> String {
761 let nul = region.iter().position(|&b| b == 0).unwrap_or(region.len());
762 String::from_utf8_lossy(®ion[..nul]).into_owned()
763}
764
765fn vaddr_to_file_offset(vaddr: u64, loads: &[(u64, u64, u64)]) -> Option<u64> {
768 for &(seg_vaddr, seg_off, seg_filesz) in loads {
769 if vaddr >= seg_vaddr && vaddr < seg_vaddr.checked_add(seg_filesz)? {
770 return Some(seg_off + (vaddr - seg_vaddr));
771 }
772 }
773 None
774}
775
776pub(crate) fn parse_elf_dependencies<S: ElfSource + ?Sized>(image: &S) -> Option<ElfDependencies> {
789 let header = image.read_at(0, 64)?;
791 let le = elf64_little_endian(&header)?;
792
793 let e_phoff = read_u64(&header, 32, le)?;
794 let e_phentsize = u64::from(read_u16(&header, 54, le)?);
795 let e_phnum = u64::from(read_u16(&header, 56, le)?);
796 if e_phentsize < 56 {
798 return None;
799 }
800
801 let mut interp: Option<String> = None;
802 let mut dynamic_region: Option<(u64, u64)> = None; let mut loads: Vec<(u64, u64, u64)> = Vec::new(); let table = match e_phnum.checked_sub(1) {
808 None => std::borrow::Cow::Borrowed(&[][..]),
809 Some(last) => image.read_at(e_phoff, last.checked_mul(e_phentsize)?.checked_add(40)?)?,
810 };
811 for i in 0..e_phnum {
812 let base = usize::try_from(i * e_phentsize).ok()?;
813 let p_type = read_u32(&table, base, le)?;
814 let p_offset = read_u64(&table, base + 8, le)?;
815 let p_vaddr = read_u64(&table, base + 16, le)?;
816 let p_filesz = read_u64(&table, base + 32, le)?;
817 match p_type {
818 1 => loads.push((p_vaddr, p_offset, p_filesz)), 2 => dynamic_region = Some((p_offset, p_filesz)), 3 => {
821 interp = Some(cstr_at_start(&image.read_at(p_offset, p_filesz)?));
823 }
824 _ => {}
825 }
826 }
827
828 let Some((dyn_off, dyn_size)) = dynamic_region else {
830 return Some(ElfDependencies {
831 interp,
832 needed: Vec::new(),
833 runpaths: Vec::new(),
834 });
835 };
836
837 let mut needed_offsets: Vec<u64> = Vec::new();
838 let mut runpath_offsets: Vec<u64> = Vec::new();
839 let mut strtab_vaddr: Option<u64> = None;
840 let mut strsz: Option<u64> = None;
841
842 dyn_off.checked_add(dyn_size)?;
843 let dynamic = read_clamped(image, dyn_off, dyn_size)?;
846 let mut off = 0u64;
847 while off + 16 <= dyn_size {
848 let entry = dynamic.get(usize::try_from(off).ok()?..usize::try_from(off + 16).ok()?)?;
849 let d_tag = read_u64(entry, 0, le)?;
850 let d_val = read_u64(entry, 8, le)?;
851 match d_tag {
852 0 => break, 1 => needed_offsets.push(d_val), 5 => strtab_vaddr = Some(d_val), 10 => strsz = Some(d_val), 15 | 29 => runpath_offsets.push(d_val), _ => {}
858 }
859 off += 16;
860 }
861
862 let mut needed = Vec::new();
863 let mut runpaths = Vec::new();
864 if let (Some(strtab_vaddr), Some(strsz)) = (strtab_vaddr, strsz) {
865 if let Some(strtab_off) = vaddr_to_file_offset(strtab_vaddr, &loads) {
866 let strtab = (strtab_off <= image.size())
869 .then(|| read_clamped(image, strtab_off, strsz))
870 .flatten();
871 if let Some(strtab) = strtab {
872 for offset in needed_offsets {
873 if let Some(region) = strtab.get(offset as usize..) {
874 needed.push(cstr_at_start(region));
875 }
876 }
877 for offset in runpath_offsets {
878 if let Some(region) = strtab.get(offset as usize..) {
879 for part in cstr_at_start(region).split(':') {
880 if !part.is_empty() {
881 runpaths.push(part.to_string());
882 }
883 }
884 }
885 }
886 }
887 }
888 }
889
890 Some(ElfDependencies {
891 interp,
892 needed,
893 runpaths,
894 })
895}
896
897#[cfg(target_os = "linux")]
901pub(crate) fn elf_is_shared_object<S: ElfSource + ?Sized>(image: &S) -> bool {
902 let Some(head) = image.read_at(0, 18) else {
903 return false;
904 };
905 elf64_little_endian(&head).is_some_and(|le| read_u16(&head, 16, le) == Some(3))
906}
907
908#[cfg(target_os = "linux")]
918pub(crate) fn elf_dynamic_symbol_names<S: ElfSource + ?Sized>(image: &S) -> Option<Vec<String>> {
919 let header = image.read_at(0, 64)?;
920 let le = elf64_little_endian(&header)?;
921 let e_shoff = read_u64(&header, 40, le)?;
922 let e_shentsize = u64::from(read_u16(&header, 58, le)?);
923 let e_shnum = u64::from(read_u16(&header, 60, le)?);
924 if e_shentsize < 64 {
926 return None;
927 }
928 let section = |index: u64| -> Option<(u32, u64, u64, u64, u64)> {
930 let base = e_shoff.checked_add(index.checked_mul(e_shentsize)?)?;
931 let entry = image.read_at(base, 64)?;
932 Some((
933 read_u32(&entry, 4, le)?,
934 read_u64(&entry, 24, le)?,
935 read_u64(&entry, 32, le)?,
936 u64::from(read_u32(&entry, 40, le)?),
937 read_u64(&entry, 56, le)?,
938 ))
939 };
940 let (_, sym_off, sym_size, strtab_index, sym_entsize) = (0..e_shnum)
942 .filter_map(section)
943 .find(|(sh_type, ..)| *sh_type == 11)?;
944 if sym_entsize < 24 {
946 return None;
947 }
948 let (_, str_off, str_size, _, _) = section(strtab_index)?;
949 let strtab = image.read_at(str_off, str_size)?;
950 let symbols = image.read_at(sym_off, sym_size)?;
951 let mut names = Vec::new();
952 for symbol in symbols.chunks_exact(usize::try_from(sym_entsize).ok()?) {
953 let st_name = read_u32(symbol, 0, le)? as usize;
954 if st_name == 0 {
955 continue;
956 }
957 if let Some(region) = strtab.get(st_name..) {
958 names.push(cstr_at_start(region));
959 }
960 }
961 Some(names)
962}
963
964fn expand_origin(rpath: &str, origin_dir: &Path) -> PathBuf {
967 if rpath.contains("$ORIGIN") {
968 let origin = origin_dir.to_string_lossy();
969 PathBuf::from(
970 rpath
971 .replace("${ORIGIN}", &origin)
972 .replace("$ORIGIN", &origin),
973 )
974 } else {
975 PathBuf::from(rpath)
976 }
977}
978
979pub(crate) fn resolve_soname(
984 soname: &str,
985 origin_dir: &Path,
986 runpaths: &[String],
987 search_dirs: &[PathBuf],
988) -> Option<PathBuf> {
989 if soname.is_empty() {
990 return None;
991 }
992 if soname.contains('/') {
993 let candidate = if Path::new(soname).is_absolute() {
994 PathBuf::from(soname)
995 } else {
996 origin_dir.join(soname)
997 };
998 return std::fs::canonicalize(&candidate).ok();
999 }
1000 for rpath in runpaths {
1001 let candidate = expand_origin(rpath, origin_dir).join(soname);
1002 if candidate.is_file() {
1003 if let Ok(canonical) = std::fs::canonicalize(&candidate) {
1004 return Some(canonical);
1005 }
1006 }
1007 }
1008 for dir in search_dirs {
1009 let candidate = dir.join(soname);
1010 if candidate.is_file() {
1011 if let Ok(canonical) = std::fs::canonicalize(&candidate) {
1012 return Some(canonical);
1013 }
1014 }
1015 }
1016 None
1017}
1018
1019pub(crate) fn resolve_landlock_grants(exec_allow_paths: &[PathBuf]) -> Vec<PathBuf> {
1028 let search_dirs: Vec<PathBuf> = DEFAULT_LIBRARY_SEARCH_DIRS
1029 .iter()
1030 .map(PathBuf::from)
1031 .collect();
1032 resolve_landlock_grants_in(exec_allow_paths, &search_dirs)
1033}
1034
1035fn resolve_landlock_grants_in(
1038 exec_allow_paths: &[PathBuf],
1039 search_dirs: &[PathBuf],
1040) -> Vec<PathBuf> {
1041 let Ok(grants) = walk_dependency_closure(
1042 exec_allow_paths,
1043 search_dirs,
1044 &[],
1045 |path| -> Result<_, std::convert::Infallible> { Ok(read_elf_dependencies(path)) },
1046 );
1047 grants
1048}
1049
1050fn read_elf_dependencies(path: &Path) -> Option<ElfDependencies> {
1053 parse_elf_dependencies(&ElfFile::open_host(path).ok()?)
1054}
1055
1056pub(crate) fn walk_dependency_closure<E>(
1061 exec_allow_paths: &[PathBuf],
1062 search_dirs: &[PathBuf],
1063 excluded: &[PathBuf],
1064 mut dependencies: impl FnMut(&Path) -> Result<Option<ElfDependencies>, E>,
1065) -> Result<Vec<PathBuf>, E> {
1066 let mut grants: std::collections::BTreeSet<PathBuf> = std::collections::BTreeSet::new();
1067 let mut visited: std::collections::BTreeSet<PathBuf> = std::collections::BTreeSet::new();
1068 let mut queue: std::collections::VecDeque<PathBuf> = std::collections::VecDeque::new();
1069
1070 for path in exec_allow_paths {
1071 if let Ok(canonical) = std::fs::canonicalize(path) {
1076 queue.push_back(canonical);
1077 }
1078 }
1079
1080 while let Some(path) = queue.pop_front() {
1081 if excluded.iter().any(|root| path.starts_with(root)) {
1082 continue;
1083 }
1084 if !visited.insert(path.clone()) {
1085 continue;
1086 }
1087 grants.insert(path.clone());
1090
1091 let Some(deps) = dependencies(&path)? else {
1092 continue;
1093 };
1094 let origin_dir = path.parent().unwrap_or_else(|| Path::new("/"));
1095
1096 if let Some(interp) = &deps.interp {
1097 if let Ok(canonical) = std::fs::canonicalize(interp) {
1098 queue.push_back(canonical);
1099 }
1100 }
1101 for soname in &deps.needed {
1102 if let Some(resolved) = resolve_soname(soname, origin_dir, &deps.runpaths, search_dirs)
1103 {
1104 queue.push_back(resolved);
1105 }
1106 }
1107 }
1108
1109 Ok(grants.into_iter().collect())
1110}
1111
1112#[derive(Debug, Clone, PartialEq, Eq)]
1153pub(crate) struct LandlockGrant {
1154 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1155 pub(crate) path: PathBuf,
1156 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1157 pub(crate) list_dir: bool,
1158 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1159 pub(crate) executable: bool,
1160}
1161
1162impl LandlockGrant {
1163 fn non_listable_files(paths: Vec<PathBuf>) -> Vec<LandlockGrant> {
1167 paths
1168 .into_iter()
1169 .map(|path| LandlockGrant {
1170 path,
1171 list_dir: false,
1172 executable: true,
1173 })
1174 .collect()
1175 }
1176}
1177
1178pub(crate) fn resolve_interpreter_runtime_grants(
1189 grants: &[InterpreterRuntimeGrant],
1190) -> Vec<LandlockGrant> {
1191 grants
1192 .iter()
1193 .flat_map(|grant| {
1194 grant.dirs.iter().map(|dir| LandlockGrant {
1195 path: PathBuf::from(&dir.path),
1196 list_dir: dir.list_dir,
1197 executable: true,
1200 })
1201 })
1202 .collect()
1203}
1204
1205pub(crate) fn resolve_derived_interpreter_grants(
1214 derived: &[crate::interpreter_runtime::DerivedInterpreterRuntime],
1215) -> Vec<LandlockGrant> {
1216 let read_only = |path: &Path, list_dir: bool| LandlockGrant {
1217 path: path.to_path_buf(),
1218 list_dir,
1219 executable: false,
1220 };
1221 derived
1222 .iter()
1223 .flat_map(|runtime| {
1224 runtime
1225 .entries()
1226 .map(|(path, is_dir)| read_only(path, is_dir))
1227 .chain(runtime.libraries.iter().map(|path| read_only(path, false)))
1228 .collect::<Vec<_>>()
1229 })
1230 .collect()
1231}
1232
1233pub(crate) fn resolve_trust_store_grants(
1245 paths: &[crate::trust_store::TrustStorePath],
1246) -> Vec<LandlockGrant> {
1247 paths
1248 .iter()
1249 .map(|entry| LandlockGrant {
1250 path: entry.path.clone(),
1251 list_dir: entry.directory,
1252 executable: false,
1253 })
1254 .collect()
1255}
1256
1257pub(crate) fn resolve_sealed_bind_dirs(
1276 exec_allow_paths: &[PathBuf],
1277 policy: &CapabilityPolicy,
1278) -> Vec<PathBuf> {
1279 let mut dirs: Vec<PathBuf> = Vec::new();
1280 let candidates = exec_allow_paths
1281 .iter()
1282 .filter_map(|binary| binary.parent().map(Path::to_path_buf))
1283 .chain(
1284 policy
1285 .shell_interpreter_runtime
1286 .iter()
1287 .flat_map(|grant| grant.dirs.iter().map(|dir| PathBuf::from(&dir.path))),
1288 )
1289 .chain(
1290 policy
1291 .shell_derived_interpreter_runtime
1292 .iter()
1293 .flat_map(|runtime| {
1294 runtime
1295 .entries()
1296 .filter(|(_, is_dir)| *is_dir)
1297 .map(|(path, _)| path.to_path_buf())
1298 .chain(
1299 runtime
1300 .libraries
1301 .iter()
1302 .filter_map(|library| library.parent().map(Path::to_path_buf)),
1303 )
1304 .collect::<Vec<_>>()
1305 }),
1306 );
1307
1308 for candidate in candidates {
1309 push_sealed_bind_dir(&mut dirs, candidate);
1310 }
1311 dirs
1312}
1313
1314pub(crate) fn host_path_execute_roots(
1320 policy: &CapabilityPolicy,
1321) -> Vec<crate::host_paths::ExecuteRoot> {
1322 let exec_allow_paths = resolve_exec_allowlist(&policy.shell_allow);
1323 execute_roots_from(&resolve_landlock_grants(&exec_allow_paths), policy)
1324}
1325
1326fn execute_roots_from(
1328 closure: &[PathBuf],
1329 policy: &CapabilityPolicy,
1330) -> Vec<crate::host_paths::ExecuteRoot> {
1331 let mut roots: Vec<crate::host_paths::ExecuteRoot> = Vec::new();
1332 let mut push = |dir: PathBuf, because: PathBuf| {
1333 let dir = std::fs::canonicalize(&dir).unwrap_or(dir);
1334 if dir.is_absolute() && !roots.iter().any(|root| root.dir == dir) {
1335 roots.push(crate::host_paths::ExecuteRoot { dir, because });
1336 }
1337 };
1338 for file in closure {
1339 if let Some(parent) = file.parent() {
1340 push(parent.to_path_buf(), file.clone());
1341 }
1342 }
1343 for grant in &policy.shell_interpreter_runtime {
1344 for dir in &grant.dirs {
1345 push(PathBuf::from(&dir.path), PathBuf::from(&dir.path));
1346 }
1347 }
1348 for dir in resolve_staged_runtime_dirs(policy) {
1349 push(dir.clone(), dir);
1350 }
1351 roots
1352}
1353
1354pub(crate) fn host_path_mechanism(tier: EnforcementTier) -> crate::host_paths::HostPathMechanism {
1356 match tier {
1357 EnforcementTier::KernelSealed => crate::host_paths::HostPathMechanism::Bind,
1358 EnforcementTier::KernelFull => crate::host_paths::HostPathMechanism::Landlock,
1359 EnforcementTier::KernelSeccompOnly
1360 | EnforcementTier::EnvironmentOnly
1361 | EnforcementTier::Unconfined => crate::host_paths::HostPathMechanism::None,
1362 }
1363}
1364
1365fn push_sealed_bind_dir(dirs: &mut Vec<PathBuf>, candidate: PathBuf) {
1368 if !candidate.is_absolute() {
1369 return;
1370 }
1371 if crate::sealed::SEALED_RUNTIME_PATHS
1372 .iter()
1373 .any(|root| candidate.starts_with(root))
1374 {
1375 return;
1376 }
1377 if dirs.contains(&candidate) {
1378 return;
1379 }
1380 dirs.push(candidate);
1381}
1382
1383pub(crate) fn resolve_staged_runtime_dirs(policy: &CapabilityPolicy) -> Vec<PathBuf> {
1404 let mut dirs: Vec<PathBuf> = Vec::new();
1405 for grant in &policy.shell_staged_runtime {
1406 let path = PathBuf::from(&grant.source_path);
1407 if !path.is_absolute() || dirs.contains(&path) {
1408 continue;
1409 }
1410 dirs.push(path);
1411 }
1412 dirs
1413}
1414
1415pub(crate) fn resolve_staged_runtime_landlock_grants(
1438 policy: &CapabilityPolicy,
1439) -> Vec<LandlockGrant> {
1440 resolve_staged_runtime_dirs(policy)
1441 .into_iter()
1442 .map(|path| LandlockGrant {
1443 path,
1444 list_dir: true,
1445 executable: true,
1446 })
1447 .collect()
1448}
1449
1450pub(crate) fn resolve_sealed_runtime_landlock_grants(tier: EnforcementTier) -> Vec<LandlockGrant> {
1475 if tier != EnforcementTier::KernelSealed {
1476 return Vec::new();
1477 }
1478 crate::sealed::SEALED_RUNTIME_PATHS
1479 .iter()
1480 .map(|path| LandlockGrant {
1481 path: PathBuf::from(path),
1482 list_dir: true,
1483 executable: false,
1484 })
1485 .collect()
1486}
1487
1488pub(crate) fn resolve_sealed_etc_landlock_grants(tier: EnforcementTier) -> Vec<LandlockGrant> {
1526 if tier != EnforcementTier::KernelSealed {
1527 return Vec::new();
1528 }
1529 crate::sealed::SEALED_ETC_PATHS
1530 .iter()
1531 .map(|entry| LandlockGrant {
1532 path: PathBuf::from(entry.path),
1533 list_dir: entry.directory,
1534 executable: false,
1535 })
1536 .collect()
1537}
1538
1539pub(crate) const SCOPED_NAME_RESOLUTION_PATHS: &[&str] = &["/etc/hosts", "/etc/nsswitch.conf"];
1543
1544pub(crate) fn resolve_scoped_name_resolution_landlock_grants(
1554 tier: EnforcementTier,
1555) -> Vec<LandlockGrant> {
1556 if tier != EnforcementTier::KernelFull {
1557 return Vec::new();
1558 }
1559 SCOPED_NAME_RESOLUTION_PATHS
1560 .iter()
1561 .map(|path| LandlockGrant {
1562 path: PathBuf::from(path),
1563 list_dir: false,
1564 executable: false,
1565 })
1566 .collect()
1567}
1568
1569#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1600#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1601pub(crate) struct CapsuleDeviceGrant {
1602 pub(crate) path: &'static str,
1603 pub(crate) writable: bool,
1604}
1605
1606#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1645pub(crate) const CAPSULE_DEVICE_GRANTS: &[CapsuleDeviceGrant] = &[
1646 CapsuleDeviceGrant {
1647 path: "/dev/null",
1648 writable: true,
1649 },
1650 CapsuleDeviceGrant {
1651 path: "/dev/zero",
1652 writable: false,
1653 },
1654 CapsuleDeviceGrant {
1655 path: "/dev/urandom",
1656 writable: false,
1657 },
1658];
1659
1660#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1664pub(crate) fn network_ip_allowed(ip: IpAddr, network_allow_ips: &[IpAddr]) -> bool {
1665 network_allow_ips.contains(&ip)
1666}
1667
1668const LINUX_AF_UNIX: i32 = 1;
1677const LINUX_AF_INET: i32 = 2;
1678const LINUX_AF_INET6: i32 = 10;
1679const LINUX_AF_NETLINK: i32 = 16;
1680const LINUX_AF_PACKET: i32 = 17;
1681
1682#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1714pub(crate) fn denied_socket_domains(unix_sockets_allowed: bool) -> Vec<i32> {
1715 let mut denied = vec![LINUX_AF_NETLINK, LINUX_AF_PACKET];
1716 if !unix_sockets_allowed {
1717 denied.push(LINUX_AF_UNIX);
1718 }
1719 denied
1720}
1721
1722#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1740pub(crate) fn allowed_socket_domains(unix_sockets_allowed: bool) -> Vec<i32> {
1741 let mut allowed = vec![LINUX_AF_INET_DOMAIN, LINUX_AF_INET6_DOMAIN];
1742 if unix_sockets_allowed {
1743 allowed.push(LINUX_AF_UNIX);
1744 }
1745 allowed
1746}
1747
1748#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1754const LINUX_AF_INET_DOMAIN: i32 = LINUX_AF_INET;
1755#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1756const LINUX_AF_INET6_DOMAIN: i32 = LINUX_AF_INET6;
1757
1758#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1789pub(crate) const SECCOMP_SYSCALL_ALLOWLIST: &[&str] = &[
1790 "execve",
1794 "execveat",
1795 "clone",
1802 "clone3",
1803 "fork",
1804 "vfork",
1805 "exit",
1806 "exit_group",
1807 "wait4",
1808 "waitid",
1809 "kill",
1810 "tkill",
1811 "tgkill",
1812 "rt_sigaction",
1813 "rt_sigprocmask",
1814 "rt_sigreturn",
1815 "rt_sigsuspend",
1816 "rt_sigpending",
1817 "rt_sigtimedwait",
1818 "rt_sigqueueinfo",
1819 "rt_tgsigqueueinfo",
1820 "sigaltstack",
1821 "restart_syscall",
1824 "pause",
1825 "set_tid_address",
1826 "set_robust_list",
1827 "get_robust_list",
1828 "rseq",
1830 "prctl",
1831 "arch_prctl",
1832 "getpid",
1833 "getppid",
1834 "gettid",
1835 "getpgrp",
1836 "getpgid",
1837 "setpgid",
1838 "getsid",
1839 "setsid",
1840 "getpriority",
1841 "setpriority",
1842 "ioprio_get",
1843 "ioprio_set",
1844 "sched_yield",
1845 "sched_getaffinity",
1846 "sched_setaffinity",
1847 "sched_getparam",
1848 "sched_setparam",
1849 "sched_getscheduler",
1850 "sched_setscheduler",
1851 "sched_getattr",
1852 "sched_setattr",
1853 "sched_get_priority_max",
1854 "sched_get_priority_min",
1855 "sched_rr_get_interval",
1856 "getcpu",
1857 "capget",
1858 "capset",
1859 "prlimit64",
1860 "getrlimit",
1861 "setrlimit",
1862 "getrusage",
1863 "getitimer",
1864 "setitimer",
1865 "times",
1866 "uname",
1867 "sysinfo",
1868 "pidfd_open",
1870 "pidfd_send_signal",
1871 "mmap",
1873 "munmap",
1874 "mprotect",
1875 "mremap",
1876 "madvise",
1877 "brk",
1878 "mlock",
1879 "mlock2",
1880 "munlock",
1881 "mlockall",
1882 "munlockall",
1883 "membarrier",
1884 "msync",
1885 "mincore",
1886 "memfd_create",
1887 "pkey_alloc",
1888 "pkey_free",
1889 "pkey_mprotect",
1890 "shmget",
1891 "shmat",
1892 "shmdt",
1893 "shmctl",
1894 "open",
1896 "openat",
1897 "openat2",
1898 "creat",
1899 "close",
1900 "close_range",
1901 "read",
1902 "write",
1903 "pread64",
1904 "pwrite64",
1905 "readv",
1906 "writev",
1907 "preadv",
1908 "pwritev",
1909 "preadv2",
1910 "pwritev2",
1911 "lseek",
1912 "dup",
1913 "dup2",
1914 "dup3",
1915 "fcntl",
1916 "flock",
1917 "fsync",
1918 "fdatasync",
1919 "sync",
1920 "syncfs",
1921 "sync_file_range",
1922 "fadvise64",
1923 "readahead",
1924 "truncate",
1925 "ftruncate",
1926 "fallocate",
1927 "stat",
1928 "fstat",
1929 "lstat",
1930 "newfstatat",
1931 "statx",
1932 "access",
1933 "faccessat",
1934 "faccessat2",
1935 "getdents",
1936 "getdents64",
1937 "getcwd",
1938 "chdir",
1939 "fchdir",
1940 "mkdir",
1941 "mkdirat",
1942 "rmdir",
1943 "unlink",
1944 "unlinkat",
1945 "rename",
1946 "renameat",
1947 "renameat2",
1948 "link",
1949 "linkat",
1950 "symlink",
1951 "symlinkat",
1952 "readlink",
1953 "readlinkat",
1954 "chmod",
1955 "fchmod",
1956 "fchmodat",
1957 "fchmodat2",
1960 "chown",
1961 "fchown",
1962 "fchownat",
1963 "lchown",
1964 "umask",
1965 "utime",
1966 "utimes",
1967 "utimensat",
1968 "futimesat",
1969 "mknod",
1973 "mknodat",
1974 "sendfile",
1975 "copy_file_range",
1976 "splice",
1977 "tee",
1978 "vmsplice",
1979 "ioctl",
1980 "poll",
1981 "ppoll",
1982 "select",
1983 "pselect6",
1984 "epoll_create",
1985 "epoll_create1",
1986 "epoll_ctl",
1987 "epoll_wait",
1988 "epoll_pwait",
1989 "epoll_pwait2",
1990 "eventfd",
1991 "eventfd2",
1992 "pipe",
1993 "pipe2",
1994 "inotify_init",
1995 "inotify_init1",
1996 "inotify_add_watch",
1997 "inotify_rm_watch",
1998 "signalfd",
1999 "signalfd4",
2000 "timerfd_create",
2001 "timerfd_settime",
2002 "timerfd_gettime",
2003 "getxattr",
2004 "lgetxattr",
2005 "fgetxattr",
2006 "setxattr",
2007 "lsetxattr",
2008 "fsetxattr",
2009 "listxattr",
2010 "llistxattr",
2011 "flistxattr",
2012 "removexattr",
2013 "lremovexattr",
2014 "fremovexattr",
2015 "statfs",
2016 "fstatfs",
2017 "name_to_handle_at",
2020 "io_setup",
2024 "io_destroy",
2025 "io_submit",
2026 "io_cancel",
2027 "io_getevents",
2028 "connect",
2035 "sendto",
2036 "socketpair",
2037 "bind",
2038 "listen",
2039 "accept",
2040 "accept4",
2041 "getsockname",
2042 "getpeername",
2043 "getsockopt",
2044 "setsockopt",
2045 "shutdown",
2046 "sendmsg",
2047 "sendmmsg",
2048 "recvfrom",
2049 "recvmsg",
2050 "recvmmsg",
2051 "clock_gettime",
2053 "clock_getres",
2054 "clock_nanosleep",
2055 "nanosleep",
2056 "gettimeofday",
2057 "time",
2058 "timer_create",
2059 "timer_settime",
2060 "timer_gettime",
2061 "timer_getoverrun",
2062 "timer_delete",
2063 "alarm",
2064 "msgget",
2066 "msgsnd",
2067 "msgrcv",
2068 "msgctl",
2069 "semget",
2070 "semop",
2071 "semctl",
2072 "semtimedop",
2073 "mq_open",
2074 "mq_unlink",
2075 "mq_timedsend",
2076 "mq_timedreceive",
2077 "mq_notify",
2078 "mq_getsetattr",
2079 "getrandom",
2081 "futex",
2082 "futex_waitv",
2083 "getuid",
2084 "geteuid",
2085 "getgid",
2086 "getegid",
2087 "setuid",
2088 "setgid",
2089 "setreuid",
2090 "setregid",
2091 "setresuid",
2092 "setresgid",
2093 "getresuid",
2094 "getresgid",
2095 "getgroups",
2096 "setgroups",
2097 "setfsuid",
2098 "setfsgid",
2099];
2100
2101#[allow(dead_code)]
2130pub(crate) const SECCOMP_MUST_STAY_DENIED: &[&str] = &[
2131 "io_uring_setup",
2133 "io_uring_enter",
2134 "io_uring_register",
2135 "bpf",
2136 "userfaultfd",
2137 "perf_event_open",
2138 "process_vm_readv",
2139 "process_vm_writev",
2140 "open_by_handle_at",
2141 "keyctl",
2142 "add_key",
2143 "request_key",
2144 "ptrace",
2145 "mount",
2147 "umount2",
2148 "pivot_root",
2149 "chroot",
2150 "unshare",
2151 "setns",
2152 "init_module",
2153 "finit_module",
2154 "delete_module",
2155 "kexec_load",
2156 "kexec_file_load",
2157 "reboot",
2158 "swapon",
2159 "swapoff",
2160 "acct",
2161 "quotactl",
2162 "nfsservctl",
2163 "iopl",
2164 "ioperm",
2165 "vm86",
2166 "vm86old",
2167 "_sysctl",
2168 "sysfs",
2169 "uselib",
2170 "create_module",
2171 "get_kernel_syms",
2172 "query_module",
2173 "lookup_dcookie",
2174 "clock_adjtime",
2175 "clock_settime",
2176 "settimeofday",
2177 "stime",
2178 "adjtimex",
2179 "set_mempolicy",
2180 "get_mempolicy",
2181 "mbind",
2182 "migrate_pages",
2183 "move_pages",
2184 "fanotify_init",
2185 "fanotify_mark",
2186 "seccomp",
2188 "personality",
2189 "kcmp",
2190];
2191
2192#[allow(dead_code)]
2203pub(crate) const SECCOMP_FILTER_INSTRUCTION_BUDGET: usize = 512;
2204
2205#[allow(dead_code)]
2210pub(crate) const BPF_MAXINSNS_CEILING: usize = 4096;
2211
2212#[allow(dead_code)]
2218pub(crate) const SECCOMP_MAX_INSNS_PER_PATH: usize = 32768;
2219
2220#[derive(Debug, Clone)]
2222pub(crate) struct ShellEnforcement {
2223 pub(crate) tier: EnforcementTier,
2224 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2232 pub(crate) network_allow_ips: Vec<IpAddr>,
2233 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2241 pub(crate) network_allow_rules: Vec<crate::network_policy::NetworkAllowRule>,
2242 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2247 pub(crate) egress_tcp_ports: Vec<u16>,
2248 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2255 pub(crate) unix_sockets_allowed: bool,
2256 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2266 pub(crate) workdir_exec: bool,
2267 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2275 pub(crate) landlock_abi: Option<u8>,
2276 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2312 pub(crate) landlock_grants: Vec<LandlockGrant>,
2313 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2321 pub(crate) sealed_bind_dirs: Vec<PathBuf>,
2322 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2333 pub(crate) staged_runtime_dirs: Vec<PathBuf>,
2334 pub(crate) resource_limits: crate::resources::HostResourceLimits,
2340 pub(crate) declared_resources: murmur_artifact::ResourceCapabilities,
2344 pub(crate) cgroup_scope: Option<Arc<crate::cgroup::CgroupScope>>,
2353 pub(crate) workdir_guard: Option<Arc<crate::resources::WorkdirGuard>>,
2357 pub(crate) session_netns: Arc<crate::network_namespace::SessionNetns>,
2364 pub(crate) runtime_dir: Option<crate::runtime_dir::RuntimeDirRoots>,
2370 pub(crate) runtime_dir_owner: Option<crate::runtime_dir::SharedRuntimeDir>,
2373 pub(crate) temp_mapping: crate::temp_dir::TempMapping,
2378 pub(crate) proc_view: crate::proc_view::ProcView,
2382 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2389 pub(crate) host_paths: Vec<crate::host_paths::HostPath>,
2390 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2395 pub(crate) host_path_read_only_binds: Vec<PathBuf>,
2396}
2397
2398impl ShellEnforcement {
2399 pub(crate) fn resolve(
2414 policy: &CapabilityPolicy,
2415 decision: &crate::containment::ContainmentDecision,
2416 probe: HostProbe,
2417 ) -> Result<Self, String> {
2418 let tier = decision.applied_tier();
2419 let network_allow_ips = resolve_network_allowlist_ips(&policy.network_allow)?;
2420 let network_allow_rules =
2421 crate::network_policy::parse_network_allow_rules(&policy.network_allow)
2422 .map_err(|error| error.to_string())?;
2423 let egress_tcp_ports = crate::egress_proxy::egress_listen_ports(&network_allow_rules);
2424 let exec_allow_paths = resolve_exec_allowlist(&policy.shell_allow);
2425 let closure = resolve_landlock_grants(&exec_allow_paths);
2431 let host_path_read_only_binds = crate::host_paths::check_execute_roots(
2436 &policy.host_paths,
2437 &execute_roots_from(&closure, policy),
2438 host_path_mechanism(tier),
2439 )
2440 .map_err(|refusal| refusal.to_string())?;
2441 let mut landlock_grants = LandlockGrant::non_listable_files(closure);
2442 landlock_grants.extend(resolve_interpreter_runtime_grants(
2443 &policy.shell_interpreter_runtime,
2444 ));
2445 landlock_grants.extend(resolve_derived_interpreter_grants(
2446 &policy.shell_derived_interpreter_runtime,
2447 ));
2448 landlock_grants.extend(resolve_trust_store_grants(&policy.shell_trust_store));
2452 landlock_grants.extend(resolve_staged_runtime_landlock_grants(policy));
2456 landlock_grants.extend(resolve_sealed_runtime_landlock_grants(tier));
2462 landlock_grants.extend(resolve_sealed_etc_landlock_grants(tier));
2467 landlock_grants.extend(resolve_scoped_name_resolution_landlock_grants(tier));
2470 let sealed_bind_dirs = resolve_sealed_bind_dirs(&exec_allow_paths, policy);
2471 let staged_runtime_dirs = resolve_staged_runtime_dirs(policy);
2472 Ok(Self {
2473 tier,
2474 network_allow_ips,
2475 network_allow_rules,
2476 egress_tcp_ports,
2477 unix_sockets_allowed: policy.unix_sockets_allowed,
2478 workdir_exec: policy.workdir_exec_allowed,
2479 landlock_abi: ruleset_landlock_abi(probe.landlock_kernel_abi(), tier),
2480 landlock_grants,
2481 sealed_bind_dirs,
2482 staged_runtime_dirs,
2483 resource_limits: policy.resources,
2484 declared_resources: policy.declared_resources,
2485 cgroup_scope: None,
2486 workdir_guard: None,
2487 session_netns: Arc::new(crate::network_namespace::SessionNetns::new()),
2488 runtime_dir: None,
2489 runtime_dir_owner: None,
2490 temp_mapping: crate::temp_dir::tier_temp_mapping(tier),
2491 proc_view: crate::proc_view::proc_view(tier, &probe.namespaces),
2492 host_paths: policy.host_paths.clone(),
2493 host_path_read_only_binds,
2494 })
2495 }
2496
2497 #[cfg(test)]
2500 pub(crate) fn resolve_declared(
2501 policy: &CapabilityPolicy,
2502 declared: murmur_artifact::ContainmentClass,
2503 probe: HostProbe,
2504 ) -> Result<Self, String> {
2505 let decision = crate::containment::ContainmentDecision::decide(
2506 probe.tier(),
2507 declared,
2508 policy.workdir_exec_allowed,
2509 false,
2510 );
2511 Self::resolve(policy, &decision, probe)
2512 }
2513
2514 pub(crate) fn with_runtime_dir(
2517 mut self,
2518 roots: crate::runtime_dir::RuntimeDirRoots,
2519 owner: Option<crate::runtime_dir::SharedRuntimeDir>,
2520 ) -> Self {
2521 self.runtime_dir = Some(roots);
2522 self.runtime_dir_owner = owner;
2523 self
2524 }
2525
2526 pub(crate) fn runtime_dir_path(&self) -> Option<&Path> {
2528 self.runtime_dir.as_ref().map(|roots| roots.runtime.path())
2529 }
2530
2531 pub(crate) fn records_root(&self) -> Option<&crate::workdir_fs::WorkdirRoot> {
2533 self.runtime_dir
2534 .as_ref()
2535 .and_then(|roots| roots.records.as_ref())
2536 }
2537
2538 pub(crate) fn records_dir_path(&self) -> Option<&Path> {
2541 self.records_root()
2542 .map(crate::workdir_fs::WorkdirRoot::path)
2543 }
2544
2545 pub(crate) fn temp_dir_path(&self) -> Option<&Path> {
2548 self.runtime_dir
2549 .as_ref()
2550 .and_then(|roots| roots.temp.as_ref())
2551 .map(crate::workdir_fs::WorkdirRoot::path)
2552 }
2553
2554 pub(crate) fn home_root(&self) -> Option<&crate::workdir_fs::WorkdirRoot> {
2557 self.runtime_dir
2558 .as_ref()
2559 .and_then(|roots| roots.home.as_ref())
2560 }
2561
2562 pub(crate) fn home_dir_path(&self) -> Option<&Path> {
2565 self.home_root().map(crate::workdir_fs::WorkdirRoot::path)
2566 }
2567
2568 pub(crate) fn with_temp_mapping(mut self, mapping: crate::temp_dir::TempMapping) -> Self {
2571 self.temp_mapping = mapping;
2572 self
2573 }
2574
2575 pub(crate) fn installed_temp_mapping(&self) -> crate::temp_dir::TempMapping {
2580 use crate::temp_dir::TempMapping;
2581 match (self.tier, self.temp_mapping) {
2582 (
2583 EnforcementTier::KernelFull | EnforcementTier::KernelSeccompOnly,
2584 TempMapping::MountNamespace,
2585 ) => TempMapping::MountNamespace,
2586 (
2587 EnforcementTier::KernelFull | EnforcementTier::KernelSeccompOnly,
2588 TempMapping::NotMapped(reason),
2589 ) => TempMapping::NotMapped(reason),
2590 (tier, _) => crate::temp_dir::tier_temp_mapping(tier),
2591 }
2592 }
2593
2594 #[cfg(all(test, target_os = "linux"))]
2597 pub(crate) fn with_proc_view(mut self, view: crate::proc_view::ProcView) -> Self {
2598 self.proc_view = view;
2599 self
2600 }
2601
2602 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2606 pub(crate) fn installed_proc_view(&self) -> crate::proc_view::ProcView {
2607 match (self.tier, &self.proc_view) {
2608 (
2609 EnforcementTier::KernelFull
2610 | EnforcementTier::KernelSealed
2611 | EnforcementTier::KernelSeccompOnly,
2612 view,
2613 ) => view.clone(),
2614 (tier, _) => crate::proc_view::tier_proc_view(tier),
2615 }
2616 }
2617
2618 pub(crate) fn tmpdir_value(&self) -> Option<String> {
2622 self.temp_dir_path()
2623 .map(|folder| self.installed_temp_mapping().tmpdir_value(folder))
2624 }
2625
2626 pub(crate) fn remove_runtime_dir(&self) {
2629 if let Some(owner) = &self.runtime_dir_owner {
2630 owner.remove();
2631 }
2632 }
2633
2634 pub(crate) fn with_host_bounding(
2643 mut self,
2644 cgroup_scope: Option<Arc<crate::cgroup::CgroupScope>>,
2645 workdir_guard: Option<Arc<crate::resources::WorkdirGuard>>,
2646 ) -> Self {
2647 self.cgroup_scope = cgroup_scope;
2648 self.workdir_guard = workdir_guard;
2649 self
2650 }
2651
2652 pub(crate) fn nproc_bound(&self) -> crate::resources::NprocBound {
2661 if self
2662 .cgroup_scope
2663 .as_ref()
2664 .is_some_and(|scope| scope.places_subprocesses())
2665 {
2666 crate::resources::NprocBound::CgroupPidsMax
2667 } else {
2668 crate::resources::NprocBound::Headroom {
2669 baseline: crate::resources::uid_task_count().unwrap_or(0),
2670 }
2671 }
2672 }
2673
2674 pub(crate) fn workdir_breach(&self) -> Option<crate::resources::WorkdirBreach> {
2676 self.workdir_guard.as_ref().and_then(|guard| guard.breach())
2677 }
2678
2679 pub(crate) fn check_workdir_budget(&self) -> Result<(), String> {
2685 match self.workdir_breach() {
2686 Some(breach) => Err(crate::resources::workdir_breach_message(breach)),
2687 None => Ok(()),
2688 }
2689 }
2690
2691 #[cfg_attr(not(test), allow(dead_code))]
2697 pub(crate) fn environment_only() -> Self {
2698 Self {
2699 tier: EnforcementTier::EnvironmentOnly,
2700 network_allow_ips: Vec::new(),
2701 network_allow_rules: Vec::new(),
2702 egress_tcp_ports: Vec::new(),
2703 unix_sockets_allowed: false,
2706 workdir_exec: false,
2709 landlock_abi: None,
2712 landlock_grants: Vec::new(),
2713 sealed_bind_dirs: Vec::new(),
2714 staged_runtime_dirs: Vec::new(),
2715 resource_limits: crate::resources::HostResourceLimits::default(),
2718 declared_resources: murmur_artifact::ResourceCapabilities::default(),
2719 cgroup_scope: None,
2720 workdir_guard: None,
2721 session_netns: Arc::new(crate::network_namespace::SessionNetns::new()),
2722 runtime_dir: None,
2723 runtime_dir_owner: None,
2724 temp_mapping: crate::temp_dir::tier_temp_mapping(EnforcementTier::EnvironmentOnly),
2725 proc_view: crate::proc_view::tier_proc_view(EnforcementTier::EnvironmentOnly),
2726 host_paths: Vec::new(),
2727 host_path_read_only_binds: Vec::new(),
2728 }
2729 }
2730
2731 #[cfg(test)]
2735 pub(crate) fn with_test_session_dirs(self) -> Self {
2736 let base = tempfile::tempdir().unwrap().keep();
2737 self.with_runtime_dir(test_runtime_dir(&base), None)
2738 }
2739
2740 pub(crate) fn with_session_netns(
2745 mut self,
2746 session_netns: Arc<crate::network_namespace::SessionNetns>,
2747 ) -> Self {
2748 self.session_netns = session_netns;
2749 self
2750 }
2751
2752 pub(crate) fn end_session(&self) {
2760 self.kill_session_processes();
2761 self.session_netns.end();
2762 }
2763
2764 pub(crate) fn kill_session_processes(&self) {
2770 let Some(scope) = self.cgroup_scope.as_ref() else {
2771 return;
2772 };
2773 if let Err(error) = scope.kill_all() {
2774 crate::runtime_err!(
2775 "[capsule-runtime] warning: could not kill the processes in cgroup scope {}: \
2776 {error}",
2777 scope.path().display()
2778 );
2779 }
2780 if !scope.wait_until_empty(SESSION_END_KILL_WAIT) {
2781 crate::runtime_err!(
2782 "[capsule-runtime] warning: cgroup scope {} still held processes {} ms after \
2783 they were killed",
2784 scope.path().display(),
2785 SESSION_END_KILL_WAIT.as_millis()
2786 );
2787 }
2788 }
2789}
2790
2791pub(crate) const SESSION_END_KILL_WAIT: std::time::Duration = std::time::Duration::from_secs(2);
2793
2794pub(crate) struct EndSessionOnDrop(ShellEnforcement);
2800
2801impl EndSessionOnDrop {
2802 pub(crate) fn new(enforcement: ShellEnforcement) -> Self {
2803 Self(enforcement)
2804 }
2805}
2806
2807impl Drop for EndSessionOnDrop {
2808 fn drop(&mut self) {
2809 self.0.end_session();
2810 }
2811}
2812
2813const KERNEL_ENFORCEMENT_NOTICE: &str = "capabilities.shell.allow is non-empty and this host \
2832resolved to a Linux kernel-enforcement tier (Landlock/seccomp). Landlock now grants a narrow, \
2833derived read+execute scope outside the workdir (the allowlisted binaries, their loader, and their \
2834shared libraries — nothing writable), read-only access to the directories an allowlisted \
2835CPython or Perl interpreter reports on its own import path, read-only access to the host's TLS \
2836trust store when capabilities.network.allow is also non-empty, each host_paths entry in \
2837~/.murmur/config.yaml at its own access, never executable, read-only access to /etc/hosts and \
2838/etc/nsswitch.conf so localhost resolves, read and list access to the session's private /proc \
2839(commands run in the session's own PID namespace and its procfs lists only the session's processes \
2840and the kernel's world-readable files — no host process; where this host cannot mount one, /proc \
2841is the host's and every open beneath it is denied), and no other directory granted \
2842wholesale, plus a fixed device set \
2843every capsule gets unconditionally: /dev/null read+write (with the session's temporary and home \
2844folders and any read-write host_paths entry, the only writable paths outside the workdir), \
2845/dev/zero and /dev/urandom read-only, and no other device — /dev/random, /dev/full, /dev/tty and \
2846raw block devices stay denied. The workdir's own grant also \
2847withholds device-node and unix-socket creation; seccomp refuses socket(AF_UNIX) outright unless \
2848capabilities.network.unix_sockets is declared, and always refuses AF_NETLINK/AF_PACKET, so a \
2849capsule cannot reach a host daemon socket such as /var/run/docker.sock; the seccomp filter now \
2850defaults to deny and permits only a fixed syscall allowlist modelled on the OCI/Docker default \
2851profile, so io_uring, bpf, userfaultfd, perf_event_open, ptrace and similar are refused outright; \
2852and the forked shell child \
2853drops every Linux capability and sets no_new_privs before execve. Paths outside the workdir are \
2854denied, not absent: stat, access and readlink on an ungranted path still succeed.";
2855
2856const KERNEL_SEALED_ENFORCEMENT_NOTICE: &str = "capabilities.shell.allow is non-empty and this \
2860host resolved to the sealed kernel-enforcement tier: the subprocess tree runs in a private mount \
2861namespace pivoted onto a composed root (host runtime bind-mounted read-only, the session workdir, \
2862its temporary folder at /tmp, its home folder at $HOME and any read-write host_paths entry the only \
2863writable paths, each host_paths entry in ~/.murmur/config.yaml bound at its own path, a private \
2864/dev tmpfs carrying the OCI default device set), with Landlock \
2865and seccomp still installed inside it as defence in depth. Paths outside that root are absent \
2866rather than denied. Commands run in the session's own PID namespace, and /proc is a bind of the \
2867session's private procfs: it lists only the session's own processes and the kernel's \
2868world-readable files, readable and listable, and no host process appears in it or can be \
2869signalled. Where this host cannot mount a private procfs (mur doctor's private proc line), /proc \
2870is a bind of the host's instead, Landlock refuses every open beneath it, and stat(2) on \
2871/proc/<pid> still shows that a host process exists, its owner and its start time.";
2872
2873const SECCOMP_ONLY_WARNING: &str = "capabilities.shell.allow is non-empty and this Linux kernel \
2874lacks Landlock (kernel <5.13) — filesystem access outside the capsule workdir is not \
2875kernel-enforced at all, and the seccomp exec/network enforcement that would apply has not been \
2876verified on real Linux hardware. The fixed capsule device set the Full tier applies (/dev/null \
2877read+write, /dev/zero and /dev/urandom read-only, every other device denied) is a Landlock \
2878mechanism, so it does not apply here either: on this tier every device under /dev is reachable \
2879exactly as it would be without any sandbox. Seccomp also refuses socket(AF_UNIX) unless \
2880capabilities.network.unix_sockets is declared, and always refuses AF_NETLINK/AF_PACKET — that \
2881rule needs no Landlock and so applies identically on this tier, but it has not been verified \
2882either. The same is true of the default-deny syscall allowlist (modelled on the OCI/Docker default \
2883profile), which refuses io_uring, bpf, userfaultfd, perf_event_open, ptrace and similar on this \
2884tier too. The forked shell child still drops every Linux capability and \
2885sets no_new_privs before execve on this tier, independently of Landlock, but that has not been \
2886verified either. Treat shell subprocess isolation as experimental on this host.";
2887
2888const ENVIRONMENT_ONLY_WARNING: &str = "capabilities.shell.allow is non-empty but this \
2889platform has no kernel-level subprocess sandbox (Landlock/seccomp are Linux-only) — \
2890enforcement is environment-only (synthetic HOME + credential env-stripping). This is \
2891permanent on this platform.";
2892
2893pub(crate) fn tier_warning(
2907 tier: EnforcementTier,
2908 shell_allow_is_empty: bool,
2909) -> Option<(&'static str, &'static str)> {
2910 if shell_allow_is_empty {
2911 return None;
2912 }
2913 match tier {
2914 EnforcementTier::KernelSealed => Some((W_SEC_005, KERNEL_SEALED_ENFORCEMENT_NOTICE)),
2915 EnforcementTier::KernelFull => Some((W_SEC_005, KERNEL_ENFORCEMENT_NOTICE)),
2916 EnforcementTier::KernelSeccompOnly => Some((W_SEC_002, SECCOMP_ONLY_WARNING)),
2917 EnforcementTier::EnvironmentOnly => Some((W_SEC_001, ENVIRONMENT_ONLY_WARNING)),
2918 EnforcementTier::Unconfined => None,
2920 }
2921}
2922
2923const NO_AGGREGATE_BOUNDING_WARNING: &str = "this capsule can spawn subprocesses, but \
2924this platform has no cgroup v2 (Linux-only), so no aggregate bound exists across the subprocess \
2925tree: nothing caps its total memory, task count or CPU. Per-process rlimits from \
2926capabilities.resources still apply (hard, not soft), but RLIMIT_NPROC is a per-uid ceiling and \
2927does NOT stop a fork bomb of distinct, short-lived processes — only a cgroup's pids.max does. \
2928This is permanent on this platform, not a placeholder for a future slice.";
2929
2930pub(crate) fn aggregate_bounding_warning(
2939 is_linux: bool,
2940 requires_bounding: bool,
2941 has_scope: bool,
2942) -> Option<(&'static str, &'static str)> {
2943 if is_linux || !requires_bounding || has_scope {
2944 return None;
2945 }
2946 Some((W_SEC_010, NO_AGGREGATE_BOUNDING_WARNING))
2947}
2948
2949pub(crate) fn warn_for_missing_aggregate_bounding(
2951 workdir: &crate::workdir_fs::WorkdirRoot,
2952 requires_bounding: bool,
2953 has_scope: bool,
2954) {
2955 let is_linux = cfg!(target_os = "linux");
2956 if let Some((code, message)) =
2957 aggregate_bounding_warning(is_linux, requires_bounding, has_scope)
2958 {
2959 let link = security_warning_link(code);
2960 crate::runtime_err!("[capsule-runtime] warning[{code}]: {message} ({link})");
2961 crate::agent::append_bootstrap_log(
2962 workdir,
2963 &format!("[capability-policy] warning[{code}]: {message} ({link})"),
2964 );
2965 }
2966}
2967
2968pub(crate) fn warn_for_enforcement_tier(
2970 tier: EnforcementTier,
2971 workdir: &crate::workdir_fs::WorkdirRoot,
2972 policy: &CapabilityPolicy,
2973) {
2974 if let Some((code, message)) = tier_warning(tier, policy.shell_allow.is_empty()) {
2975 let link = security_warning_link(code);
2976 crate::runtime_err!("[capsule-runtime] warning[{code}]: {message} ({link})");
2977 crate::agent::append_bootstrap_log(
2978 workdir,
2979 &format!("[capability-policy] warning[{code}]: {message} ({link})"),
2980 );
2981 }
2982 if tier == EnforcementTier::EnvironmentOnly {
2983 crate::runtime::warn_if_bash_network_bypass(workdir, policy);
2984 }
2985}
2986
2987#[derive(Debug)]
2994pub(crate) enum SupervisorHandle {
2995 Noop,
2996 #[cfg(target_os = "linux")]
2997 Linux {
2998 diag_read: std::os::fd::OwnedFd,
3005 },
3006}
3007
3008impl SupervisorHandle {
3009 pub(crate) fn read_diagnostic(&self) -> Option<String> {
3022 match self {
3023 SupervisorHandle::Noop => None,
3024 #[cfg(target_os = "linux")]
3025 SupervisorHandle::Linux { diag_read, .. } => {
3026 use std::os::fd::AsRawFd;
3027 linux_enforce::read_diagnostic_pipe(diag_read.as_raw_fd())
3028 }
3029 }
3030 }
3031}
3032
3033#[cfg(test)]
3037thread_local! {
3038 pub(crate) static FORCE_PREPARE_FAILURE: std::cell::Cell<bool> =
3039 const { std::cell::Cell::new(false) };
3040}
3041
3042#[cfg(test)]
3043fn forced_prepare_failure() -> Result<(), String> {
3044 if FORCE_PREPARE_FAILURE.with(|flag| flag.get()) {
3045 return Err("sandbox: kernel enforcement setup failed (forced by test seam)".to_string());
3046 }
3047 Ok(())
3048}
3049
3050#[cfg(all(test, target_os = "linux"))]
3059thread_local! {
3060 pub(crate) static FORCE_NO_NEW_PRIVS_FAILURE: std::cell::Cell<bool> =
3061 const { std::cell::Cell::new(false) };
3062 pub(crate) static FORCE_LANDLOCK_FAILURE: std::cell::Cell<bool> =
3063 const { std::cell::Cell::new(false) };
3064 pub(crate) static FORCE_SEALED_ROOT_FAILURE: std::cell::Cell<bool> =
3070 const { std::cell::Cell::new(false) };
3071 pub(crate) static FORCE_TMP_MAP_FAILURE: std::cell::Cell<bool> =
3074 const { std::cell::Cell::new(false) };
3075 pub(crate) static FORCE_PRIVATE_PROC_FAILURE: std::cell::Cell<bool> =
3078 const { std::cell::Cell::new(false) };
3079}
3080
3081#[cfg(all(test, target_os = "linux"))]
3083pub(crate) struct ForcePrivateProcFailureGuard;
3084
3085#[cfg(all(test, target_os = "linux"))]
3086impl ForcePrivateProcFailureGuard {
3087 pub(crate) fn new() -> Self {
3088 FORCE_PRIVATE_PROC_FAILURE.with(|flag| flag.set(true));
3089 Self
3090 }
3091}
3092
3093#[cfg(all(test, target_os = "linux"))]
3094impl Drop for ForcePrivateProcFailureGuard {
3095 fn drop(&mut self) {
3096 FORCE_PRIVATE_PROC_FAILURE.with(|flag| flag.set(false));
3097 }
3098}
3099
3100#[cfg(all(test, target_os = "linux"))]
3102pub(crate) struct ForceTmpMapFailureGuard;
3103
3104#[cfg(all(test, target_os = "linux"))]
3105impl ForceTmpMapFailureGuard {
3106 pub(crate) fn new() -> Self {
3107 FORCE_TMP_MAP_FAILURE.with(|flag| flag.set(true));
3108 Self
3109 }
3110}
3111
3112#[cfg(all(test, target_os = "linux"))]
3113impl Drop for ForceTmpMapFailureGuard {
3114 fn drop(&mut self) {
3115 FORCE_TMP_MAP_FAILURE.with(|flag| flag.set(false));
3116 }
3117}
3118
3119#[cfg(all(test, target_os = "linux"))]
3123pub(crate) struct ForceSealedRootFailureGuard;
3124
3125#[cfg(all(test, target_os = "linux"))]
3126impl ForceSealedRootFailureGuard {
3127 pub(crate) fn new() -> Self {
3128 FORCE_SEALED_ROOT_FAILURE.with(|flag| flag.set(true));
3129 Self
3130 }
3131}
3132
3133#[cfg(all(test, target_os = "linux"))]
3134impl Drop for ForceSealedRootFailureGuard {
3135 fn drop(&mut self) {
3136 FORCE_SEALED_ROOT_FAILURE.with(|flag| flag.set(false));
3137 }
3138}
3139
3140#[cfg(all(test, target_os = "linux"))]
3146pub(crate) fn sealed_test_enforcement() -> ShellEnforcement {
3147 let base = tempfile::tempdir().unwrap().keep();
3149 ShellEnforcement {
3150 tier: EnforcementTier::KernelSealed,
3151 ..ShellEnforcement::environment_only().with_test_session_dirs()
3152 }
3153 .with_runtime_dir(test_runtime_dir(&base), None)
3154}
3155
3156#[cfg(test)]
3159pub(crate) fn test_runtime_dir(base: &Path) -> crate::runtime_dir::RuntimeDirRoots {
3160 let open = |sub: &str| {
3161 let path = base.join(sub);
3162 std::fs::create_dir_all(&path).unwrap();
3163 crate::workdir_fs::WorkdirRoot::open(&path).unwrap()
3164 };
3165 crate::runtime_dir::RuntimeDirRoots::new(
3166 open(crate::runtime_dir::RUNTIME_SUBDIR),
3167 open(crate::runtime_dir::SEALED_ETC_SUBDIR),
3168 open(crate::runtime_dir::HARNESS_SUBDIR),
3169 )
3170 .with_records(open(crate::records_dir::RECORDS_DIR_NAME))
3171 .with_temp(open(&format!(
3172 "{}/{}",
3173 crate::records_dir::RECORDS_DIR_NAME,
3174 crate::temp_dir::SESSION_TEMP_DIR_NAME
3175 )))
3176 .with_home(open(&format!(
3177 "{}/{}",
3178 crate::records_dir::RECORDS_DIR_NAME,
3179 crate::records_dir::HOME_SUBDIR
3180 )))
3181}
3182
3183#[cfg(unix)]
3196pub(crate) fn attach_process_limits(
3197 command: &mut std::process::Command,
3198 enforcement: &ShellEnforcement,
3199) {
3200 use std::os::unix::process::CommandExt;
3201
3202 if enforcement.tier == EnforcementTier::Unconfined {
3203 attach_unconfined_limits(command, enforcement);
3204 return;
3205 }
3206
3207 let limits = enforcement.resource_limits;
3208 let nproc = enforcement.nproc_bound();
3209 let cgroup_scope = enforcement.cgroup_scope.clone();
3210
3211 #[allow(unsafe_code)]
3216 unsafe {
3217 command.pre_exec(move || {
3218 crate::resources::apply_hard_rlimits(&limits, nproc)?;
3219 if let Some(scope) = cgroup_scope.as_ref() {
3220 scope.join_current_process()?;
3221 }
3222 Ok(())
3223 });
3224 }
3225}
3226
3227#[cfg(unix)]
3234fn attach_unconfined_limits(command: &mut std::process::Command, enforcement: &ShellEnforcement) {
3235 use std::os::unix::process::CommandExt;
3236
3237 let declared = enforcement.declared_resources;
3238 let nproc = enforcement.nproc_bound();
3239 let cgroup_scope = enforcement.cgroup_scope.clone();
3240
3241 #[allow(unsafe_code)]
3246 unsafe {
3247 command.pre_exec(move || {
3248 crate::resources::apply_declared_rlimits(&declared, nproc)?;
3249 if let Some(scope) = cgroup_scope.as_ref() {
3250 scope.join_current_process()?;
3251 }
3252 #[cfg(target_os = "linux")]
3253 linux_enforce::mark_inherited_fds_cloexec()?;
3254 Ok(())
3255 });
3256 }
3257}
3258
3259#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
3276pub(crate) const FD_HYGIENE_FIRST_FD: u32 = 3;
3277
3278#[cfg(not(target_os = "linux"))]
3285pub(crate) fn prepare_enforcement(
3286 command: &mut std::process::Command,
3287 enforcement: &ShellEnforcement,
3288 _accessible_workdir: &Path,
3289) -> Result<SupervisorHandle, String> {
3290 #[cfg(test)]
3291 forced_prepare_failure()?;
3292
3293 debug_assert!(
3294 matches!(
3295 enforcement.tier,
3296 EnforcementTier::EnvironmentOnly | EnforcementTier::Unconfined
3297 ),
3298 "non-Linux targets must always resolve to EnforcementTier::EnvironmentOnly or Unconfined"
3299 );
3300 attach_process_limits(command, enforcement);
3301 Ok(SupervisorHandle::Noop)
3302}
3303
3304#[cfg(target_os = "linux")]
3310#[allow(unsafe_code)]
3311pub(crate) fn prepare_enforcement(
3312 command: &mut std::process::Command,
3313 enforcement: &ShellEnforcement,
3314 accessible_workdir: &Path,
3315) -> Result<SupervisorHandle, String> {
3316 use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
3317 use std::os::unix::process::CommandExt;
3318
3319 #[cfg(test)]
3320 forced_prepare_failure()?;
3321
3322 if matches!(
3323 enforcement.tier,
3324 EnforcementTier::EnvironmentOnly | EnforcementTier::Unconfined
3325 ) {
3326 attach_process_limits(command, enforcement);
3330 return Ok(SupervisorHandle::Noop);
3331 }
3332
3333 let temp_dir_fd = enforcement
3337 .runtime_dir
3338 .as_ref()
3339 .and_then(|roots| roots.temp.as_ref())
3340 .map(|temp| temp.path_fd().map(Arc::new))
3341 .transpose()
3342 .map_err(|error| {
3343 format!("sandbox: failed to open the session's temporary folder: {error}")
3344 })?;
3345 let home_dir_fd = enforcement
3348 .home_root()
3349 .map(|home| home.path_fd().map(Arc::new))
3350 .transpose()
3351 .map_err(|error| format!("sandbox: failed to open the session's home folder: {error}"))?;
3352 let host_path_fds: Vec<HostPathFd> = if matches!(
3355 enforcement.tier,
3356 EnforcementTier::KernelFull | EnforcementTier::KernelSealed
3357 ) {
3358 enforcement
3359 .host_paths
3360 .iter()
3361 .map(|host_path| {
3362 linux_enforce::open_host_path(&host_path.path).map(|fd| HostPathFd {
3363 fd: Arc::new(fd),
3364 path: host_path.path.clone(),
3365 read_write: host_path.access == crate::host_paths::HostPathAccess::ReadWrite,
3366 })
3367 })
3368 .collect::<Result<_, _>>()?
3369 } else {
3370 Vec::new()
3371 };
3372
3373 let sealed_sources = if enforcement.tier == EnforcementTier::KernelSealed {
3379 Some(SealedSourceFds::open(
3380 enforcement,
3381 temp_dir_fd.as_ref(),
3382 home_dir_fd.as_ref(),
3383 )?)
3384 } else {
3385 None
3386 };
3387 let runtime_dir_fd = match &sealed_sources {
3389 Some(sources) => Some(Arc::clone(&sources.runtime_dir)),
3390 None if enforcement.tier == EnforcementTier::KernelFull => enforcement
3391 .runtime_dir
3392 .as_ref()
3393 .map(|roots| roots.runtime.path_fd().map(Arc::new))
3394 .transpose()
3395 .map_err(|error| {
3396 format!(
3397 "sandbox: failed to open the runtime directory for Landlock scoping: {error}"
3398 )
3399 })?,
3400 None => None,
3401 };
3402 let records_dir_fd = match &sealed_sources {
3405 Some(sources) => sources.records_dir.as_ref().map(|(fd, _)| Arc::clone(fd)),
3406 None if enforcement.tier == EnforcementTier::KernelFull => enforcement
3407 .runtime_dir
3408 .as_ref()
3409 .and_then(|roots| roots.records.as_ref())
3410 .map(|records| records.path_fd().map(Arc::new))
3411 .transpose()
3412 .map_err(|error| {
3413 format!(
3414 "sandbox: failed to open the records directory for Landlock scoping: {error}"
3415 )
3416 })?,
3417 None => None,
3418 };
3419
3420 let tmp_map_spec = match (&temp_dir_fd, enforcement.installed_temp_mapping()) {
3422 (Some(folder_fd), crate::temp_dir::TempMapping::MountNamespace) => Some(
3423 build_tmp_map_spec(enforcement, folder_fd, accessible_workdir)?,
3424 ),
3425 _ => None,
3426 };
3427
3428 let landlock_fds = if matches!(
3435 enforcement.tier,
3436 EnforcementTier::KernelFull | EnforcementTier::KernelSealed
3437 ) {
3438 Some(linux_enforce::open_landlock_fds(
3439 linux_enforce::LandlockFdRequest {
3440 accessible_workdir,
3441 landlock_grants: &enforcement.landlock_grants,
3442 device_grants: &landlock_device_grants(enforcement.tier),
3443 workdir_exec: enforcement.workdir_exec,
3444 abi: enforcement.landlock_abi,
3445 sealed: sealed_sources.as_ref(),
3446 temp_dir_fd,
3447 home_dir_fd,
3448 runtime_dir_fd,
3449 records_dir_fd,
3450 host_path_fds: host_path_fds
3451 .iter()
3452 .map(|host_path| (Arc::clone(&host_path.fd), host_path.read_write))
3453 .collect(),
3454 },
3455 )?)
3456 } else {
3457 None
3458 };
3459
3460 let sealed_spec = match &sealed_sources {
3466 Some(sources) => Some(build_sealed_root(
3467 accessible_workdir,
3468 sources,
3469 &enforcement.sealed_bind_dirs,
3470 &enforcement.staged_runtime_dirs,
3471 &host_path_fds,
3472 &enforcement.host_path_read_only_binds,
3473 )?),
3474 None => None,
3475 };
3476
3477 let mut diag_fds = [0i32; 2];
3483 let rc = unsafe { libc::pipe2(diag_fds.as_mut_ptr(), libc::O_CLOEXEC) };
3486 if rc != 0 {
3487 return Err(format!(
3488 "sandbox: pipe2() for enforcement diagnostics failed: {}",
3489 std::io::Error::last_os_error()
3490 ));
3491 }
3492 let (diag_read, diag_write) = unsafe {
3495 (
3496 OwnedFd::from_raw_fd(diag_fds[0]),
3497 OwnedFd::from_raw_fd(diag_fds[1]),
3498 )
3499 };
3500
3501 let netns = enforcement.session_netns.establish(
3508 &enforcement.egress_tcp_ports,
3509 crate::egress_proxy::EgressPolicy::new(
3510 enforcement.network_allow_rules.clone(),
3511 enforcement.network_allow_ips.clone(),
3512 ),
3513 )?;
3514
3515 let tier = enforcement.tier;
3516 let private_proc =
3519 enforcement
3520 .installed_proc_view()
3521 .is_private()
3522 .then_some(linux_enforce::PrivateProc {
3523 mount_namespace_entered: tmp_map_spec.is_some(),
3524 });
3525 let unix_sockets_allowed = enforcement.unix_sockets_allowed;
3528 let resource_limits = enforcement.resource_limits;
3533 let nproc = enforcement.nproc_bound();
3534 let cgroup_scope = enforcement.cgroup_scope.clone();
3535
3536 unsafe {
3562 command.pre_exec(move || {
3563 if let Err(error) = crate::resources::apply_nproc_bound(&resource_limits, nproc) {
3568 linux_enforce::write_diagnostic(diag_write.as_raw_fd(), &error.to_string());
3569 return Err(error);
3570 }
3571 if let Some(scope) = cgroup_scope.as_ref() {
3572 if let Err(error) = scope.join_current_process() {
3573 linux_enforce::write_diagnostic(diag_write.as_raw_fd(), &error.to_string());
3574 return Err(error);
3575 }
3576 }
3577
3578 let (user_ns_fd, net_ns_fd) = netns.raw_fds();
3579 if let Err(error) = linux_enforce::child_install_enforcement(
3580 tier,
3581 linux_enforce::SessionNamespaces {
3582 user_ns_fd,
3583 net_ns_fd,
3584 pid_ns_fd: netns.pid_ns_fd(),
3585 },
3586 private_proc,
3587 tmp_map_spec.as_ref(),
3588 sealed_spec.as_ref(),
3589 landlock_fds.as_ref(),
3590 unix_sockets_allowed,
3591 ) {
3592 linux_enforce::write_diagnostic(diag_write.as_raw_fd(), &error.to_string());
3593 return Err(error);
3594 }
3595
3596 if let Err(error) = crate::resources::apply_per_process_rlimits(&resource_limits) {
3608 linux_enforce::write_diagnostic(diag_write.as_raw_fd(), &error.to_string());
3609 return Err(error);
3610 }
3611 Ok(())
3612 });
3613 }
3614
3615 Ok(SupervisorHandle::Linux { diag_read })
3616}
3617
3618#[cfg(target_os = "linux")]
3630fn landlock_device_grants(tier: EnforcementTier) -> Vec<CapsuleDeviceGrant> {
3631 match tier {
3632 EnforcementTier::KernelSealed => crate::sealed::SEALED_DEVICE_NODES
3633 .iter()
3634 .map(|device| CapsuleDeviceGrant {
3635 path: device.path,
3636 writable: device.writable,
3637 })
3638 .collect(),
3639 _ => CAPSULE_DEVICE_GRANTS.to_vec(),
3640 }
3641}
3642
3643#[cfg(target_os = "linux")]
3653fn build_tmp_map_spec(
3654 enforcement: &ShellEnforcement,
3655 folder_fd: &std::os::fd::OwnedFd,
3656 accessible_workdir: &Path,
3657) -> Result<crate::temp_dir::TmpMapSpec, String> {
3658 let folder = enforcement.temp_dir_path().ok_or_else(|| {
3659 format!(
3660 "{} this enforcement holds none",
3661 crate::temp_dir::TMP_MAP_FAILURE_PREFIX
3662 )
3663 })?;
3664 let identity = crate::workdir_fs::fd_identity(folder_fd).map_err(|errno| {
3665 format!(
3666 "{} fstat of the temporary folder failed: {errno}",
3667 crate::temp_dir::TMP_MAP_FAILURE_PREFIX
3668 )
3669 })?;
3670 let canonical_tmp = crate::temp_dir::canonical_tmp();
3671 let read_only = [
3672 enforcement.runtime_dir_path(),
3673 enforcement.records_dir_path(),
3674 ]
3675 .into_iter()
3676 .flatten()
3677 .chain(
3678 enforcement
3679 .landlock_grants
3680 .iter()
3681 .map(|grant| grant.path.as_path()),
3682 );
3683 let reexposed = crate::temp_dir::reexposed_paths(
3684 &canonical_tmp,
3685 std::iter::once(accessible_workdir).chain(enforcement.home_dir_path()),
3686 read_only,
3687 );
3688 crate::temp_dir::TmpMapSpec::new(
3689 folder,
3690 identity,
3691 &canonical_tmp,
3692 &reexposed,
3693 accessible_workdir,
3694 )
3695}
3696
3697#[cfg(target_os = "linux")]
3713fn ensure_sealed_identity_files(
3714 sealed_etc_root: &crate::workdir_fs::WorkdirRoot,
3715 home_root: &crate::workdir_fs::WorkdirRoot,
3716) -> Result<Vec<PathBuf>, String> {
3717 let (uid, gid) = home_root.owner().map_err(|error| {
3720 format!(
3721 "sealed: failed to stat the session's home folder for the capsule's uid/gid: {error}"
3722 )
3723 })?;
3724 let home = home_root.path().to_string_lossy().into_owned();
3727 let identity = crate::sealed::SealedAccountIdentity {
3728 uid,
3729 gid,
3730 home: &home,
3731 };
3732
3733 let mut written = Vec::new();
3734 for entry in crate::sealed::SEALED_ETC_PATHS {
3735 let Some(file) = entry.synthetic else {
3736 continue;
3737 };
3738 sealed_etc_root
3739 .overwrite_in_place(
3740 file.file_name(),
3741 file.render(&identity)?.as_bytes(),
3742 crate::workdir_fs::DEFAULT_FILE_MODE,
3743 )
3744 .map_err(|error| {
3745 format!(
3746 "sealed: failed to write the synthetic {}: {error}",
3747 entry.path
3748 )
3749 })?;
3750 written.push(crate::sealed::synthetic_etc_source(
3751 sealed_etc_root.path(),
3752 file,
3753 ));
3754 }
3755 Ok(written)
3756}
3757
3758#[cfg(target_os = "linux")]
3761pub(crate) struct HostPathFd {
3762 fd: Arc<std::os::fd::OwnedFd>,
3763 path: PathBuf,
3764 read_write: bool,
3765}
3766
3767#[cfg(target_os = "linux")]
3770pub(crate) struct SealedSourceFds {
3771 tmp_store: Arc<std::os::fd::OwnedFd>,
3774 tmp_store_path: PathBuf,
3776 home_dir: (Arc<std::os::fd::OwnedFd>, PathBuf),
3779 runtime_dir: Arc<std::os::fd::OwnedFd>,
3781 runtime_dir_path: PathBuf,
3784 records_dir: Option<(Arc<std::os::fd::OwnedFd>, PathBuf)>,
3788 etc_dir: PathBuf,
3790 identity_files: Vec<PathBuf>,
3792}
3793
3794#[cfg(target_os = "linux")]
3795impl SealedSourceFds {
3796 fn open(
3801 enforcement: &ShellEnforcement,
3802 temp_dir_fd: Option<&Arc<std::os::fd::OwnedFd>>,
3803 home_dir_fd: Option<&Arc<std::os::fd::OwnedFd>>,
3804 ) -> Result<Self, String> {
3805 let roots = enforcement.runtime_dir.as_ref().ok_or_else(|| {
3806 "sealed: a composed root needs the session's runtime directory, and this \
3807 enforcement holds none"
3808 .to_string()
3809 })?;
3810 let (Some(temp), Some(tmp_store)) = (roots.temp.as_ref(), temp_dir_fd) else {
3811 return Err(
3812 "sealed: a composed root binds the session's temporary folder at /tmp, \
3813 and this enforcement holds none"
3814 .to_string(),
3815 );
3816 };
3817 let tmp_store = Arc::clone(tmp_store);
3818 let (Some(home), Some(home_fd)) = (roots.home.as_ref(), home_dir_fd) else {
3819 return Err(crate::shell::NO_HOME_FOLDER_REFUSAL.to_string());
3820 };
3821 let home_dir = (Arc::clone(home_fd), home.path().to_path_buf());
3822 let identity_files = ensure_sealed_identity_files(&roots.sealed_etc, home)?;
3823 let runtime_dir = Arc::new(roots.runtime.path_fd().map_err(|error| {
3824 format!("sealed: failed to open the runtime directory for its bind: {error}")
3825 })?);
3826 let records_dir = roots
3827 .records
3828 .as_ref()
3829 .map(|records| {
3830 records
3831 .path_fd()
3832 .map(|fd| (Arc::new(fd), records.path().to_path_buf()))
3833 .map_err(|error| {
3834 format!(
3835 "sealed: failed to open the records directory for its bind: {error}"
3836 )
3837 })
3838 })
3839 .transpose()?;
3840 Ok(Self {
3841 tmp_store,
3842 tmp_store_path: temp.path().to_path_buf(),
3843 home_dir,
3844 runtime_dir,
3845 runtime_dir_path: roots.runtime.path().to_path_buf(),
3846 records_dir,
3847 etc_dir: roots.sealed_etc.path().to_path_buf(),
3848 identity_files,
3849 })
3850 }
3851
3852 fn identity_of(fd: &std::os::fd::OwnedFd) -> Result<(u64, u64), String> {
3854 crate::workdir_fs::fd_identity(fd)
3855 .map_err(|errno| format!("sealed: fstat of a bind source failed: {errno}"))
3856 }
3857}
3858
3859#[cfg(target_os = "linux")]
3875fn build_sealed_root(
3876 accessible_workdir: &Path,
3877 sources: &SealedSourceFds,
3878 extra_read_only: &[PathBuf],
3879 staged_runtime_read_only: &[PathBuf],
3880 host_paths: &[HostPathFd],
3881 host_path_read_only: &[PathBuf],
3882) -> Result<crate::sealed::SealedRootSpec, String> {
3883 use crate::sealed;
3884
3885 if !accessible_workdir.is_absolute() {
3889 return Err(format!(
3890 "sealed: capsule workdir {} is not absolute; a composed root reproduces the workdir at \
3891 its own absolute path",
3892 accessible_workdir.display()
3893 ));
3894 }
3895
3896 let host_path_binds = host_paths
3897 .iter()
3898 .map(|host_path| {
3899 SealedSourceFds::identity_of(&host_path.fd).map(|identity| sealed::HostPathBind {
3900 path: host_path.path.clone(),
3901 identity,
3902 read_only: !host_path.read_write,
3903 })
3904 })
3905 .collect::<Result<Vec<_>, _>>()?;
3906 let host_path_dirs: Vec<PathBuf> = host_paths
3907 .iter()
3908 .map(|host_path| host_path.path.clone())
3909 .collect();
3910 let base = sealed::choose_root_base(
3911 accessible_workdir,
3912 &host_path_dirs,
3913 sealed::SEALED_ROOT_BASE_CANDIDATES,
3914 |path| path.is_dir(),
3915 )
3916 .ok_or_else(|| {
3917 format!(
3918 "sealed: no usable base directory for the composed root; none of {:?} exists outside \
3919 the session workdir's own path and every host path's",
3920 sealed::SEALED_ROOT_BASE_CANDIDATES
3921 )
3922 })?;
3923
3924 let plan = sealed::plan_composed_root(
3925 accessible_workdir,
3926 &sealed::SealedBindSources {
3927 tmp_store: (
3928 &sources.tmp_store_path,
3929 SealedSourceFds::identity_of(&sources.tmp_store)?,
3930 ),
3931 etc_dir: &sources.etc_dir,
3932 runtime_dir: Some((
3933 &sources.runtime_dir_path,
3934 SealedSourceFds::identity_of(&sources.runtime_dir)?,
3935 )),
3936 records_dir: sources
3937 .records_dir
3938 .as_ref()
3939 .map(|(fd, path)| {
3940 SealedSourceFds::identity_of(fd).map(|identity| (path.as_path(), identity))
3941 })
3942 .transpose()?,
3943 home_dir: Some((
3944 &sources.home_dir.1,
3945 SealedSourceFds::identity_of(&sources.home_dir.0)?,
3946 )),
3947 host_paths: &host_path_binds,
3948 host_path_read_only,
3949 },
3950 &base,
3951 extra_read_only,
3952 staged_runtime_read_only,
3953 &sealed::RealHostLayout,
3954 );
3955 sealed::build_sealed_root_spec(&plan)
3956}
3957
3958#[cfg_attr(target_os = "linux", allow(unsafe_code))]
3966#[cfg(target_os = "linux")]
3967mod linux_enforce {
3968 use std::io;
3969 use std::os::fd::{AsFd, OwnedFd, RawFd};
3970 use std::path::Path;
3971
3972 use landlock::{make_bitflags, AccessFs, BitFlags};
3973
3974 use super::{EnforcementTier, LandlockGrant};
3975
3976 const MAX_DIAG_LEN: usize = 1024;
3980
3981 pub(super) struct OpenLandlockGrant {
3986 fd: OwnedFd,
3987 list_dir: bool,
3988 executable: bool,
3989 }
3990
3991 pub(super) struct OpenDeviceGrant {
3998 fd: OwnedFd,
3999 writable: bool,
4000 }
4001
4002 pub(super) struct LandlockChildFds {
4009 workdir_fd: OwnedFd,
4010 workdir_exec: bool,
4014 abi: Option<u8>,
4018 temp_dir_fd: Option<std::sync::Arc<OwnedFd>>,
4029 home_dir_fd: Option<std::sync::Arc<OwnedFd>>,
4034 sealed_identity_fds: Vec<OwnedFd>,
4047 runtime_dir_fd: Option<std::sync::Arc<OwnedFd>>,
4052 records_dir_fd: Option<std::sync::Arc<OwnedFd>>,
4057 host_path_fds: Vec<(std::sync::Arc<OwnedFd>, bool)>,
4060 grants: Vec<OpenLandlockGrant>,
4061 devices: Vec<OpenDeviceGrant>,
4062 }
4063
4064 impl LandlockChildFds {
4065 #[cfg(test)]
4068 pub(super) fn temp_dir_fd(&self) -> Option<&OwnedFd> {
4069 self.temp_dir_fd.as_deref()
4070 }
4071
4072 #[cfg(test)]
4074 pub(super) fn home_dir_fd(&self) -> Option<&OwnedFd> {
4075 self.home_dir_fd.as_deref()
4076 }
4077
4078 #[cfg(test)]
4080 pub(super) fn runtime_dir_fd(&self) -> Option<&OwnedFd> {
4081 self.runtime_dir_fd.as_deref()
4082 }
4083
4084 #[cfg(test)]
4086 pub(super) fn records_dir_fd(&self) -> Option<&OwnedFd> {
4087 self.records_dir_fd.as_deref()
4088 }
4089
4090 #[cfg(test)]
4093 pub(super) fn sealed_identity_fd_count(&self) -> usize {
4094 self.sealed_identity_fds.len()
4095 }
4096 }
4097
4098 pub(super) const WORKDIR_ACCESS_RIGHTS_NO_EXEC: BitFlags<AccessFs> = make_bitflags!(AccessFs::{
4157 WriteFile
4158 | ReadFile
4159 | ReadDir
4160 | RemoveDir
4161 | RemoveFile
4162 | MakeDir
4163 | MakeReg
4164 | MakeFifo
4165 | MakeSym
4166 | Refer
4167 | Truncate
4168 });
4169
4170 pub(super) fn workdir_access_rights(workdir_exec: bool) -> BitFlags<AccessFs> {
4178 if workdir_exec {
4179 WORKDIR_ACCESS_RIGHTS_NO_EXEC | AccessFs::Execute
4180 } else {
4181 WORKDIR_ACCESS_RIGHTS_NO_EXEC
4182 }
4183 }
4184
4185 pub(super) fn host_path_access_rights(read_write: bool) -> BitFlags<AccessFs> {
4190 if read_write {
4191 workdir_access_rights(false)
4192 } else {
4193 AccessFs::ReadFile | AccessFs::ReadDir
4194 }
4195 }
4196
4197 pub(super) fn kernel_landlock_abi() -> Option<u8> {
4204 const LANDLOCK_CREATE_RULESET_VERSION: u32 = 1;
4205 let version = unsafe {
4208 libc::syscall(
4209 libc::SYS_landlock_create_ruleset,
4210 std::ptr::null::<libc::c_void>(),
4211 0usize,
4212 LANDLOCK_CREATE_RULESET_VERSION,
4213 )
4214 };
4215 if version <= 0 {
4216 return None;
4217 }
4218 Some(u8::try_from(version).unwrap_or(u8::MAX))
4219 }
4220
4221 pub(super) fn probe_landlock_full_access() -> Option<bool> {
4238 use landlock::{
4239 Access, AccessFs, CompatLevel, Compatible, PathBeneath, PathFd, Ruleset, RulesetAttr,
4240 RulesetCreatedAttr, RulesetStatus, ABI,
4241 };
4242
4243 const PROBE_FULLY_ENFORCED: i32 = 0;
4246 const PROBE_PARTIAL: i32 = 1;
4247 const PROBE_FAILED: i32 = 2;
4248
4249 let abi = ABI::V1;
4253 let access_all = AccessFs::from_all(abi);
4254
4255 let root_fd = PathFd::new("/").ok()?;
4256
4257 let ruleset = Ruleset::default()
4258 .set_compatibility(CompatLevel::BestEffort)
4259 .handle_access(access_all)
4260 .ok()?
4261 .create()
4262 .ok()?
4263 .add_rule(PathBeneath::new(root_fd, access_all))
4264 .ok()?;
4265
4266 let pid = unsafe { libc::fork() };
4271 if pid < 0 {
4272 return None;
4273 }
4274 if pid == 0 {
4275 let code = match ruleset.restrict_self() {
4276 Ok(status) => {
4277 if matches!(status.ruleset, RulesetStatus::FullyEnforced) {
4278 PROBE_FULLY_ENFORCED
4279 } else {
4280 PROBE_PARTIAL
4281 }
4282 }
4283 Err(_) => PROBE_FAILED,
4284 };
4285 unsafe { libc::_exit(code) }
4288 }
4289
4290 let mut wait_status: libc::c_int = 0;
4291 let waited = unsafe { libc::waitpid(pid, &mut wait_status, 0) };
4293 if waited < 0 || !libc::WIFEXITED(wait_status) {
4294 return None;
4295 }
4296 match libc::WEXITSTATUS(wait_status) {
4297 PROBE_FULLY_ENFORCED => Some(true),
4298 PROBE_PARTIAL => Some(false),
4299 _ => None,
4302 }
4303 }
4304
4305 pub(super) fn child_install_enforcement(
4316 tier: EnforcementTier,
4317 namespaces: SessionNamespaces,
4318 private_proc: Option<PrivateProc>,
4319 tmp_map_spec: Option<&crate::temp_dir::TmpMapSpec>,
4320 sealed_spec: Option<&crate::sealed::SealedRootSpec>,
4321 landlock_fds: Option<&LandlockChildFds>,
4322 unix_sockets_allowed: bool,
4323 ) -> io::Result<()> {
4324 unsafe {
4346 crate::network_namespace::join_session_netns(
4347 namespaces.user_ns_fd,
4348 namespaces.net_ns_fd,
4349 )
4350 }
4351 .map_err(|error| {
4352 io::Error::other(format!(
4353 "sandbox: joining this session's network namespace failed: {error}"
4354 ))
4355 })?;
4356
4357 unsafe { enter_session_pid_namespace(namespaces.pid_ns_fd) }?;
4366
4367 if let Some(spec) = tmp_map_spec {
4373 #[cfg(test)]
4374 if super::FORCE_TMP_MAP_FAILURE.with(|flag| flag.get()) {
4375 return Err(io::Error::other(format!(
4376 "{} (forced by test seam)",
4377 spec.describe(crate::temp_dir::TmpMapFailure::forced(libc::EPERM))
4378 )));
4379 }
4380 unsafe { crate::temp_dir::map_private_tmp(spec) }
4385 .map_err(|failure| io::Error::other(spec.describe(failure)))?;
4386 }
4387
4388 let proc_fd = match private_proc {
4394 None => None,
4395 Some(private_proc) => {
4396 #[cfg(test)]
4397 if super::FORCE_PRIVATE_PROC_FAILURE.with(|flag| flag.get()) {
4398 return Err(io::Error::other(format!(
4399 "{} (forced by test seam)",
4400 crate::proc_view::describe_mount_failure(
4401 crate::proc_view::ProcMountStep::MountProc,
4402 libc::EPERM
4403 )
4404 )));
4405 }
4406 let fd = unsafe {
4409 crate::proc_view::mount_private_proc(!private_proc.mount_namespace_entered)
4410 }
4411 .map_err(|(step, errno)| {
4412 io::Error::other(crate::proc_view::describe_mount_failure(step, errno))
4413 })?;
4414 Some(unsafe { <OwnedFd as std::os::fd::FromRawFd>::from_raw_fd(fd) })
4416 }
4417 };
4418
4419 if tier == EnforcementTier::KernelSealed {
4430 if let Some(spec) = sealed_spec {
4431 #[cfg(test)]
4432 if super::FORCE_SEALED_ROOT_FAILURE.with(|flag| flag.get()) {
4433 return Err(io::Error::other(format!(
4434 "{} unshare(CLONE_NEWUSER|CLONE_NEWNS) failed (forced by test seam)",
4435 crate::sealed::SEALED_ROOT_FAILURE_PREFIX
4436 )));
4437 }
4438 crate::sealed::construct_composed_root(spec)
4439 .map_err(|failure| io::Error::other(spec.describe(failure)))?;
4440 }
4441 }
4442
4443 mark_inherited_fds_cloexec()?;
4458
4459 drop_all_capabilities()?;
4467
4468 install_seccomp_filter(unix_sockets_allowed)?;
4474
4475 if matches!(
4476 tier,
4477 EnforcementTier::KernelFull | EnforcementTier::KernelSealed
4478 ) {
4479 if let Some(fds) = landlock_fds {
4480 apply_landlock_scope(fds, proc_fd.as_ref()).map_err(io::Error::other)?;
4481 }
4482 }
4483
4484 Ok(())
4485 }
4486
4487 #[derive(Debug, Clone, Copy)]
4490 pub(super) struct SessionNamespaces {
4491 pub(super) user_ns_fd: RawFd,
4492 pub(super) net_ns_fd: RawFd,
4493 pub(super) pid_ns_fd: RawFd,
4494 }
4495
4496 #[derive(Debug, Clone, Copy)]
4498 pub(super) struct PrivateProc {
4499 pub(super) mount_namespace_entered: bool,
4501 }
4502
4503 const RELAY_GONE_EXIT: i32 = 125;
4506
4507 unsafe fn enter_session_pid_namespace(pid_ns_fd: RawFd) -> io::Result<()> {
4520 unsafe {
4523 if libc::setns(pid_ns_fd, libc::CLONE_NEWPID) != 0 {
4524 return Err(io::Error::other(format!(
4525 "sandbox: joining this session's PID namespace failed: {}",
4526 io::Error::last_os_error()
4527 )));
4528 }
4529 let mut default: libc::sigaction = std::mem::zeroed();
4532 default.sa_sigaction = libc::SIG_DFL;
4533 libc::sigaction(libc::SIGCHLD, &default, std::ptr::null_mut());
4534 let command = libc::fork();
4535 if command < 0 {
4536 return Err(io::Error::other(format!(
4537 "sandbox: forking the command into this session's PID namespace failed: {}",
4538 io::Error::last_os_error()
4539 )));
4540 }
4541 if command > 0 {
4542 run_relay(command);
4543 }
4544 if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL as libc::c_ulong) != 0 {
4545 return Err(io::Error::other(format!(
4546 "sandbox: prctl(PR_SET_PDEATHSIG) in the command failed: {}",
4547 io::Error::last_os_error()
4548 )));
4549 }
4550 if libc::getppid() != 0 {
4551 libc::_exit(RELAY_GONE_EXIT);
4552 }
4553 }
4554 Ok(())
4555 }
4556
4557 unsafe fn run_relay(command: libc::pid_t) -> ! {
4573 unsafe {
4575 let mut all: libc::sigset_t = std::mem::zeroed();
4576 libc::sigfillset(&mut all);
4577 libc::sigprocmask(libc::SIG_SETMASK, &all, std::ptr::null_mut());
4578 crate::cgroup::close_range(0, libc::c_uint::MAX);
4579 let mut status: libc::c_int = 0;
4580 loop {
4581 let waited = libc::waitpid(command, &mut status, 0);
4582 if waited == command {
4583 break;
4584 }
4585 if waited < 0 && *libc::__errno_location() == libc::EINTR {
4586 continue;
4587 }
4588 libc::_exit(1);
4589 }
4590 if libc::WIFEXITED(status) {
4591 libc::_exit(libc::WEXITSTATUS(status));
4592 }
4593 if libc::WIFSIGNALED(status) {
4594 let signal = libc::WTERMSIG(status);
4595 let mut default: libc::sigaction = std::mem::zeroed();
4596 default.sa_sigaction = libc::SIG_DFL;
4597 libc::sigaction(signal, &default, std::ptr::null_mut());
4598 let mut only: libc::sigset_t = std::mem::zeroed();
4599 libc::sigemptyset(&mut only);
4600 libc::sigaddset(&mut only, signal);
4601 libc::sigprocmask(libc::SIG_UNBLOCK, &only, std::ptr::null_mut());
4602 libc::kill(libc::getpid(), signal);
4603 libc::_exit(128 + signal);
4604 }
4605 libc::_exit(1)
4606 }
4607 }
4608
4609 pub(super) fn mark_inherited_fds_cloexec() -> io::Result<()> {
4633 let rc = unsafe {
4639 libc::syscall(
4640 libc::SYS_close_range,
4641 super::FD_HYGIENE_FIRST_FD,
4642 libc::c_uint::MAX,
4643 libc::CLOSE_RANGE_CLOEXEC,
4644 )
4645 };
4646 if rc != 0 {
4647 return Err(io::Error::last_os_error());
4648 }
4649 Ok(())
4650 }
4651
4652 const LINUX_CAPABILITY_VERSION_3: u32 = 0x2008_0522;
4657
4658 const CAP_LAST_CAP_FALLBACK: u32 = 63;
4663
4664 #[repr(C)]
4672 #[allow(dead_code)]
4673 struct CapUserHeader {
4674 version: u32,
4675 pid: libc::c_int,
4676 }
4677
4678 #[repr(C)]
4683 #[derive(Clone, Copy)]
4684 #[allow(dead_code)]
4685 struct CapUserData {
4686 effective: u32,
4687 permitted: u32,
4688 inheritable: u32,
4689 }
4690
4691 fn drop_all_capabilities() -> io::Result<()> {
4727 drop_capability_bounding_set()?;
4728 clear_capability_sets()?;
4729 set_no_new_privs()
4730 }
4731
4732 #[allow(unsafe_code)]
4742 fn drop_capability_bounding_set() -> io::Result<()> {
4743 let zero: libc::c_ulong = 0;
4744
4745 for cap in 0..=kernel_last_cap() {
4746 let cap_arg = libc::c_ulong::from(cap);
4747 let rc = unsafe { libc::prctl(libc::PR_CAPBSET_DROP, cap_arg, zero, zero, zero) };
4750 if rc == 0 {
4751 continue;
4752 }
4753
4754 let error = io::Error::last_os_error();
4755 match error.raw_os_error() {
4756 Some(libc::EINVAL) | Some(libc::EPERM) => continue,
4757 _ => return Err(error),
4758 }
4759 }
4760
4761 Ok(())
4762 }
4763
4764 #[allow(unsafe_code)]
4768 fn clear_capability_sets() -> io::Result<()> {
4769 let header = CapUserHeader {
4770 version: LINUX_CAPABILITY_VERSION_3,
4771 pid: 0,
4774 };
4775 let data = [CapUserData {
4776 effective: 0,
4777 permitted: 0,
4778 inheritable: 0,
4779 }; 2];
4780
4781 let rc = unsafe {
4785 libc::syscall(
4786 libc::SYS_capset,
4787 &header as *const CapUserHeader,
4788 data.as_ptr(),
4789 )
4790 };
4791 if rc != 0 {
4792 return Err(io::Error::last_os_error());
4793 }
4794
4795 Ok(())
4796 }
4797
4798 #[allow(unsafe_code)]
4805 fn set_no_new_privs() -> io::Result<()> {
4806 #[cfg(test)]
4807 if super::FORCE_NO_NEW_PRIVS_FAILURE.with(|flag| flag.get()) {
4808 return Err(io::Error::other(
4809 "sandbox: no_new_privs (prctl PR_SET_NO_NEW_PRIVS, 1) failed (forced by test seam)",
4810 ));
4811 }
4812 let enable: libc::c_ulong = 1;
4816 let zero: libc::c_ulong = 0;
4817
4818 let rc = unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, enable, zero, zero, zero) };
4821 if rc != 0 {
4822 return Err(io::Error::last_os_error());
4823 }
4824
4825 Ok(())
4826 }
4827
4828 #[allow(unsafe_code)]
4834 fn kernel_last_cap() -> u32 {
4835 const PATH: &[u8] = b"/proc/sys/kernel/cap_last_cap\0";
4836
4837 let fd = unsafe {
4840 libc::open(
4841 PATH.as_ptr() as *const libc::c_char,
4842 libc::O_RDONLY | libc::O_CLOEXEC,
4843 )
4844 };
4845 if fd < 0 {
4846 return CAP_LAST_CAP_FALLBACK;
4847 }
4848
4849 let mut buf = [0u8; 16];
4850 let read = unsafe { libc::read(fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) };
4853 unsafe { libc::close(fd) };
4855
4856 if read <= 0 {
4857 return CAP_LAST_CAP_FALLBACK;
4858 }
4859
4860 let parsed = std::str::from_utf8(&buf[..read as usize])
4861 .ok()
4862 .and_then(|text| text.trim().parse::<u32>().ok());
4863
4864 match parsed {
4865 Some(last) if last <= CAP_LAST_CAP_FALLBACK => last,
4866 _ => CAP_LAST_CAP_FALLBACK,
4867 }
4868 }
4869
4870 const LANDLOCK_SYSCALLS: [(&str, i32); 3] = [
4879 ("landlock_create_ruleset", 444),
4880 ("landlock_add_rule", 445),
4881 ("landlock_restrict_self", 446),
4882 ];
4883
4884 fn build_seccomp_filter(
4908 unix_sockets_allowed: bool,
4909 ) -> io::Result<libseccomp::ScmpFilterContext> {
4910 let mut filter =
4917 libseccomp::ScmpFilterContext::new(libseccomp::ScmpAction::Errno(libc::EPERM))
4918 .map_err(to_io_err)?;
4919
4920 let _ = filter.set_ctl_log(true);
4939
4940 let socket_syscall = libseccomp::ScmpSyscall::from_name("socket").map_err(to_io_err)?;
4960 for domain in super::denied_socket_domains(unix_sockets_allowed) {
4961 filter
4962 .add_rule_conditional(
4963 libseccomp::ScmpAction::Errno(libc::EACCES),
4964 socket_syscall,
4965 &[libseccomp::ScmpArgCompare::new(
4966 0,
4967 libseccomp::ScmpCompareOp::Equal,
4968 domain as u64,
4969 )],
4970 )
4971 .map_err(to_io_err)?;
4972 }
4973
4974 for domain in super::allowed_socket_domains(unix_sockets_allowed) {
4988 filter
4989 .add_rule_conditional(
4990 libseccomp::ScmpAction::Allow,
4991 socket_syscall,
4992 &[libseccomp::ScmpArgCompare::new(
4993 0,
4994 libseccomp::ScmpCompareOp::Equal,
4995 domain as u64,
4996 )],
4997 )
4998 .map_err(to_io_err)?;
4999 }
5000
5001 for name in super::SECCOMP_SYSCALL_ALLOWLIST {
5014 if let Ok(syscall) = libseccomp::ScmpSyscall::from_name(name) {
5015 let _ = filter.add_rule(libseccomp::ScmpAction::Allow, syscall);
5016 }
5017 }
5018
5019 for (name, fallback_nr) in LANDLOCK_SYSCALLS {
5032 let syscall = libseccomp::ScmpSyscall::from_name(name)
5033 .unwrap_or_else(|_| libseccomp::ScmpSyscall::from(fallback_nr));
5034 filter
5035 .add_rule(libseccomp::ScmpAction::Allow, syscall)
5036 .map_err(to_io_err)?;
5037 }
5038
5039 Ok(filter)
5040 }
5041
5042 fn install_seccomp_filter(unix_sockets_allowed: bool) -> io::Result<()> {
5046 build_seccomp_filter(unix_sockets_allowed)?
5050 .load()
5051 .map_err(to_io_err)
5052 }
5053
5054 fn to_io_err<E: std::fmt::Display>(error: E) -> io::Error {
5055 io::Error::other(error.to_string())
5056 }
5057
5058 #[cfg(test)]
5062 const SOCK_FILTER_BYTES: usize = 8;
5063
5064 #[cfg(test)]
5072 fn export_to_memfd<F>(export: F) -> io::Result<Vec<u8>>
5073 where
5074 F: FnOnce(&OwnedFd) -> Result<(), libseccomp::error::SeccompError>,
5075 {
5076 use std::io::{Read, Seek, SeekFrom};
5077 use std::os::fd::FromRawFd;
5078
5079 let raw = unsafe { libc::memfd_create(c"murmur-seccomp-export".as_ptr(), 0) };
5083 if raw < 0 {
5084 return Err(io::Error::last_os_error());
5085 }
5086 let memfd = unsafe { OwnedFd::from_raw_fd(raw) };
5089
5090 export(&memfd).map_err(to_io_err)?;
5091
5092 let mut file = std::fs::File::from(memfd);
5093 file.seek(SeekFrom::Start(0))?;
5094 let mut exported = Vec::new();
5095 file.read_to_end(&mut exported)?;
5096 Ok(exported)
5097 }
5098
5099 #[cfg(test)]
5111 fn seccomp_filter_instruction_count(unix_sockets_allowed: bool) -> io::Result<usize> {
5112 let filter = build_seccomp_filter(unix_sockets_allowed)?;
5113 let exported = export_to_memfd(|fd| filter.export_bpf(fd))?;
5114 Ok(exported.len() / SOCK_FILTER_BYTES)
5115 }
5116
5117 #[cfg(test)]
5122 fn seccomp_filter_pfc(unix_sockets_allowed: bool) -> io::Result<String> {
5123 let filter = build_seccomp_filter(unix_sockets_allowed)?;
5124 let exported = export_to_memfd(|fd| filter.export_pfc(fd))?;
5125 String::from_utf8(exported).map_err(to_io_err)
5126 }
5127
5128 fn apply_landlock_scope(
5196 fds: &LandlockChildFds,
5197 proc_fd: Option<&OwnedFd>,
5198 ) -> Result<(), String> {
5199 #[cfg(test)]
5200 if super::FORCE_LANDLOCK_FAILURE.with(|flag| flag.get()) {
5201 return Err("landlock: ruleset construction failed (forced by test seam)".to_string());
5202 }
5203
5204 use landlock::{
5205 Access, AccessFs, CompatLevel, Compatible, PathBeneath, Ruleset, RulesetAttr,
5206 RulesetCreatedAttr, ABI,
5207 };
5208
5209 let abi = fds.abi.map_or(ABI::V1, |abi| ABI::from(i32::from(abi)));
5210 let handled = AccessFs::from_all(abi);
5211 let read_execute = AccessFs::Execute | AccessFs::ReadFile;
5215 let read_execute_list = read_execute | AccessFs::ReadDir;
5217 let read_list = AccessFs::ReadFile | AccessFs::ReadDir;
5221 let read_only: BitFlags<AccessFs> = AccessFs::ReadFile.into();
5225 let device_read: BitFlags<AccessFs> = AccessFs::ReadFile.into();
5230 let device_read_write = device_read | AccessFs::WriteFile | AccessFs::Truncate;
5231
5232 let writable_rights = workdir_access_rights(fds.workdir_exec) & handled;
5239
5240 let mut ruleset = Ruleset::default()
5244 .set_compatibility(CompatLevel::HardRequirement)
5245 .handle_access(handled)
5246 .map_err(|error| format!("landlock: handle_access failed: {error}"))?
5247 .create()
5248 .map_err(|error| format!("landlock: ruleset create failed: {error}"))?
5249 .add_rule(PathBeneath::new(fds.workdir_fd.as_fd(), writable_rights))
5250 .map_err(|error| format!("landlock: add_rule failed: {error}"))?;
5251
5252 if let Some(proc_fd) = proc_fd {
5255 ruleset = ruleset
5256 .add_rule(PathBeneath::new(proc_fd.as_fd(), read_list & handled))
5257 .map_err(|error| {
5258 format!("landlock: add_rule for the session's private /proc failed: {error}")
5259 })?;
5260 }
5261
5262 if let Some(temp_dir_fd) = &fds.temp_dir_fd {
5265 ruleset = ruleset
5266 .add_rule(PathBeneath::new(temp_dir_fd.as_fd(), writable_rights))
5267 .map_err(|error| {
5268 format!("landlock: add_rule for the session's temporary folder failed: {error}")
5269 })?;
5270 }
5271
5272 if let Some(home_dir_fd) = &fds.home_dir_fd {
5275 ruleset = ruleset
5276 .add_rule(PathBeneath::new(home_dir_fd.as_fd(), writable_rights))
5277 .map_err(|error| {
5278 format!("landlock: add_rule for the session's home folder failed: {error}")
5279 })?;
5280 }
5281
5282 for (host_path_fd, read_write) in &fds.host_path_fds {
5286 ruleset = ruleset
5287 .add_rule(PathBeneath::new(
5288 host_path_fd.as_fd(),
5289 host_path_access_rights(*read_write) & handled,
5290 ))
5291 .map_err(|error| format!("landlock: add_rule for a host path failed: {error}"))?;
5292 }
5293
5294 if let Some(runtime_dir_fd) = &fds.runtime_dir_fd {
5296 ruleset = ruleset
5297 .add_rule(PathBeneath::new(
5298 runtime_dir_fd.as_fd(),
5299 read_list & handled,
5300 ))
5301 .map_err(|error| {
5302 format!("landlock: add_rule for the runtime directory failed: {error}")
5303 })?;
5304 }
5305
5306 if let Some(records_dir_fd) = &fds.records_dir_fd {
5309 ruleset = ruleset
5310 .add_rule(PathBeneath::new(
5311 records_dir_fd.as_fd(),
5312 read_list & handled,
5313 ))
5314 .map_err(|error| {
5315 format!("landlock: add_rule for the records directory failed: {error}")
5316 })?;
5317 }
5318
5319 for identity_fd in &fds.sealed_identity_fds {
5323 ruleset = ruleset
5324 .add_rule(PathBeneath::new(identity_fd.as_fd(), read_only & handled))
5325 .map_err(|error| {
5326 format!("landlock: add_rule for a synthetic /etc file failed: {error}")
5327 })?;
5328 }
5329
5330 for grant in &fds.grants {
5331 let access = match (grant.executable, grant.list_dir) {
5335 (true, true) => read_execute_list,
5336 (true, false) => read_execute,
5337 (false, true) => read_list,
5338 (false, false) => read_only,
5339 };
5340 ruleset = ruleset
5341 .add_rule(PathBeneath::new(grant.fd.as_fd(), access & handled))
5342 .map_err(|error| format!("landlock: add_rule for grant failed: {error}"))?;
5343 }
5344
5345 for device in &fds.devices {
5346 let access = if device.writable {
5350 device_read_write
5351 } else {
5352 device_read
5353 };
5354 ruleset = ruleset
5355 .add_rule(PathBeneath::new(device.fd.as_fd(), access & handled))
5356 .map_err(|error| format!("landlock: add_rule for device failed: {error}"))?;
5357 }
5358
5359 ruleset
5360 .restrict_self()
5361 .map_err(|error| format!("landlock: restrict_self failed: {error}"))?;
5362
5363 Ok(())
5364 }
5365
5366 pub(super) struct LandlockFdRequest<'a> {
5368 pub(super) accessible_workdir: &'a Path,
5371 pub(super) landlock_grants: &'a [LandlockGrant],
5374 pub(super) device_grants: &'a [super::CapsuleDeviceGrant],
5382 pub(super) workdir_exec: bool,
5384 pub(super) abi: Option<u8>,
5386 pub(super) sealed: Option<&'a super::SealedSourceFds>,
5388 pub(super) temp_dir_fd: Option<std::sync::Arc<OwnedFd>>,
5393 pub(super) home_dir_fd: Option<std::sync::Arc<OwnedFd>>,
5395 pub(super) runtime_dir_fd: Option<std::sync::Arc<OwnedFd>>,
5397 pub(super) records_dir_fd: Option<std::sync::Arc<OwnedFd>>,
5399 pub(super) host_path_fds: Vec<(std::sync::Arc<OwnedFd>, bool)>,
5402 }
5403
5404 impl<'a> LandlockFdRequest<'a> {
5405 #[cfg(test)]
5408 pub(super) fn workdir_only(accessible_workdir: &'a Path) -> Self {
5409 Self {
5410 accessible_workdir,
5411 landlock_grants: &[],
5412 device_grants: &[],
5413 workdir_exec: false,
5414 abi: None,
5415 sealed: None,
5416 temp_dir_fd: None,
5417 home_dir_fd: None,
5418 runtime_dir_fd: None,
5419 records_dir_fd: None,
5420 host_path_fds: Vec::new(),
5421 }
5422 }
5423 }
5424
5425 pub(super) fn open_landlock_fds(
5439 request: LandlockFdRequest<'_>,
5440 ) -> Result<LandlockChildFds, String> {
5441 let LandlockFdRequest {
5442 accessible_workdir,
5443 landlock_grants,
5444 device_grants,
5445 workdir_exec,
5446 abi,
5447 sealed,
5448 temp_dir_fd,
5449 home_dir_fd,
5450 runtime_dir_fd,
5451 records_dir_fd,
5452 host_path_fds,
5453 } = request;
5454 let workdir_fd = open_o_path(accessible_workdir)
5455 .map(OwnedFd::from)
5456 .map_err(|error| {
5457 format!(
5458 "sandbox: failed to open workdir {} for Landlock scoping: {error}",
5459 accessible_workdir.display()
5460 )
5461 })?;
5462
5463 let mut sealed_identity_fds = Vec::new();
5468 for path in sealed
5469 .map(|sources| sources.identity_files.as_slice())
5470 .unwrap_or_default()
5471 {
5472 let file = open_o_path(path).map_err(|error| {
5473 format!(
5474 "sandbox: failed to open the synthetic /etc file {} for Landlock scoping: \
5475 {error}",
5476 path.display()
5477 )
5478 })?;
5479 sealed_identity_fds.push(OwnedFd::from(file));
5480 }
5481
5482 let mut grants = Vec::with_capacity(landlock_grants.len());
5483 for grant in landlock_grants {
5484 match open_o_path(&grant.path) {
5485 Ok(file) => grants.push(OpenLandlockGrant {
5486 list_dir: grant.list_dir
5496 && file.metadata().map(|meta| meta.is_dir()).unwrap_or(false),
5497 fd: OwnedFd::from(file),
5498 executable: grant.executable,
5499 }),
5500 Err(_) => continue,
5501 }
5502 }
5503
5504 let mut devices = Vec::with_capacity(device_grants.len());
5505 for device in device_grants {
5506 match open_o_path(Path::new(device.path)) {
5507 Ok(file) => devices.push(OpenDeviceGrant {
5508 fd: OwnedFd::from(file),
5509 writable: device.writable,
5510 }),
5511 Err(_) => continue,
5512 }
5513 }
5514
5515 Ok(LandlockChildFds {
5516 workdir_fd,
5517 workdir_exec,
5518 abi,
5519 temp_dir_fd,
5520 home_dir_fd,
5521 sealed_identity_fds,
5522 runtime_dir_fd,
5523 records_dir_fd,
5524 host_path_fds,
5525 grants,
5526 devices,
5527 })
5528 }
5529
5530 fn open_o_path(path: &Path) -> io::Result<std::fs::File> {
5539 use std::os::unix::fs::OpenOptionsExt;
5540 std::fs::OpenOptions::new()
5541 .read(true)
5542 .custom_flags(libc::O_PATH | libc::O_CLOEXEC)
5543 .open(path)
5544 }
5545
5546 pub(super) fn open_host_path(path: &Path) -> Result<OwnedFd, String> {
5550 use std::os::unix::fs::OpenOptionsExt;
5551 std::fs::OpenOptions::new()
5552 .read(true)
5553 .custom_flags(libc::O_PATH | libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_DIRECTORY)
5554 .open(path)
5555 .map(OwnedFd::from)
5556 .map_err(|error| {
5557 format!(
5558 "sandbox: failed to open host path {} (host_paths in ~/.murmur/config.yaml): \
5559 {error}",
5560 path.display()
5561 )
5562 })
5563 }
5564
5565 #[allow(unsafe_code)]
5571 pub(super) fn write_diagnostic(fd: RawFd, message: &str) {
5572 let bytes = message.as_bytes();
5573 let len = bytes.len().min(MAX_DIAG_LEN);
5574 let mut written = 0;
5575 while written < len {
5576 let n = unsafe {
5579 libc::write(
5580 fd,
5581 bytes[written..len].as_ptr() as *const libc::c_void,
5582 len - written,
5583 )
5584 };
5585 if n < 0 {
5586 if io::Error::last_os_error().raw_os_error() == Some(libc::EINTR) {
5587 continue;
5588 }
5589 break;
5590 }
5591 if n == 0 {
5592 break;
5593 }
5594 written += n as usize;
5595 }
5596 }
5597
5598 #[allow(unsafe_code)]
5604 pub(super) fn read_diagnostic_pipe(fd: RawFd) -> Option<String> {
5605 let mut buf = Vec::new();
5606 let mut chunk = [0u8; 256];
5607 loop {
5608 let n = unsafe { libc::read(fd, chunk.as_mut_ptr() as *mut libc::c_void, chunk.len()) };
5611 if n < 0 {
5612 if io::Error::last_os_error().raw_os_error() == Some(libc::EINTR) {
5613 continue;
5614 }
5615 break;
5616 }
5617 if n == 0 {
5618 break;
5619 }
5620 buf.extend_from_slice(&chunk[..n as usize]);
5621 if buf.len() >= MAX_DIAG_LEN {
5622 break;
5623 }
5624 }
5625 if buf.is_empty() {
5626 None
5627 } else {
5628 Some(String::from_utf8_lossy(&buf).into_owned())
5629 }
5630 }
5631
5632 #[cfg(test)]
5643 #[allow(clippy::print_stdout, clippy::print_stderr)]
5644 mod seccomp_budget {
5645 use std::io;
5646 use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
5647
5648 use super::{
5651 build_seccomp_filter, export_to_memfd, seccomp_filter_pfc, to_io_err,
5652 LANDLOCK_SYSCALLS, SOCK_FILTER_BYTES,
5653 };
5654 use crate::sandbox::{
5655 allowed_socket_domains, denied_socket_domains, detect_enforcement_tier,
5656 EnforcementTier, BPF_MAXINSNS_CEILING, SECCOMP_FILTER_INSTRUCTION_BUDGET,
5657 SECCOMP_MAX_INSNS_PER_PATH, SECCOMP_SYSCALL_ALLOWLIST,
5658 };
5659
5660 const KERNEL_PER_FILTER_OVERHEAD_INSNS: usize = 4;
5671
5672 const PROBE_ERRNO_UNKNOWN: i32 = 254;
5676
5677 fn probe_filter(rules: usize) -> io::Result<(libseccomp::ScmpFilterContext, usize)> {
5692 let mut filter = libseccomp::ScmpFilterContext::new(libseccomp::ScmpAction::Allow)
5693 .map_err(to_io_err)?;
5694 filter.set_api_sysrawrc(true).map_err(to_io_err)?;
5695
5696 let syscall = libseccomp::ScmpSyscall::from_name("ptrace").map_err(to_io_err)?;
5697 for value in 0..rules {
5698 filter
5699 .add_rule_conditional(
5700 libseccomp::ScmpAction::Errno(libc::EPERM),
5701 syscall,
5702 &[libseccomp::ScmpArgCompare::new(
5703 0,
5704 libseccomp::ScmpCompareOp::Equal,
5705 value as u64,
5706 )],
5707 )
5708 .map_err(to_io_err)?;
5709 }
5710
5711 let exported = export_to_memfd(|fd| filter.export_bpf(fd))?;
5712 Ok((filter, exported.len() / SOCK_FILTER_BYTES))
5713 }
5714
5715 fn first_rule_count_above(instructions: usize) -> io::Result<usize> {
5720 let mut under = 1;
5721 let mut over = 8;
5722 while probe_filter(over)?.1 <= instructions {
5723 under = over;
5724 over *= 2;
5725 assert!(
5726 over <= 1 << 20,
5727 "no probe filter of up to {over} rules exceeded {instructions} instructions — \
5728 the probe is not growing the program the way this search assumes"
5729 );
5730 }
5731 while over - under > 1 {
5732 let mid = under + (over - under) / 2;
5733 if probe_filter(mid)?.1 <= instructions {
5734 under = mid;
5735 } else {
5736 over = mid;
5737 }
5738 }
5739 Ok(over)
5740 }
5741
5742 fn refusal_errno(error: &libseccomp::error::SeccompError) -> i32 {
5747 use libseccomp::error::SeccompErrno;
5748
5749 if let Some(raw) = error.sysrawrc() {
5750 return -raw;
5751 }
5752 match error.errno() {
5753 Some(SeccompErrno::EACCES) => libc::EACCES,
5754 Some(SeccompErrno::ECANCELED) => libc::ECANCELED,
5755 Some(SeccompErrno::EINVAL) => libc::EINVAL,
5756 Some(SeccompErrno::ENOMEM) => libc::ENOMEM,
5757 _ => PROBE_ERRNO_UNKNOWN,
5758 }
5759 }
5760
5761 fn load_in_child(filter: &libseccomp::ScmpFilterContext) -> Result<(), i32> {
5767 let pid = unsafe { libc::fork() };
5774 assert!(pid >= 0, "fork failed: {}", io::Error::last_os_error());
5775
5776 if pid == 0 {
5777 let code = match filter.load() {
5778 Ok(()) => 0,
5779 Err(error) => refusal_errno(&error),
5780 };
5781 unsafe { libc::_exit(code) }
5784 }
5785
5786 let mut status: libc::c_int = 0;
5787 let waited = unsafe { libc::waitpid(pid, &mut status, 0) };
5789 assert!(waited == pid, "waitpid: {}", io::Error::last_os_error());
5790 assert!(
5791 libc::WIFEXITED(status),
5792 "the load probe child did not exit normally (status {status})"
5793 );
5794 match libc::WEXITSTATUS(status) {
5795 0 => Ok(()),
5796 errno => Err(errno),
5797 }
5798 }
5799
5800 fn resolvable_allowlist_syscalls() -> io::Result<usize> {
5805 use std::collections::HashSet;
5806
5807 let mut scratch =
5808 libseccomp::ScmpFilterContext::new(libseccomp::ScmpAction::Errno(libc::EPERM))
5809 .map_err(to_io_err)?;
5810 let mut accepted = HashSet::new();
5811 for name in SECCOMP_SYSCALL_ALLOWLIST {
5812 if let Ok(syscall) = libseccomp::ScmpSyscall::from_name(name) {
5813 if scratch
5814 .add_rule(libseccomp::ScmpAction::Allow, syscall)
5815 .is_ok()
5816 {
5817 accepted.insert(i32::from(syscall));
5818 }
5819 }
5820 }
5821 Ok(accepted.len())
5822 }
5823
5824 fn skip_without_seccomp(what: &str) -> bool {
5827 let tier = detect_enforcement_tier();
5828 if tier == EnforcementTier::EnvironmentOnly {
5829 eprintln!(
5830 "SKIP — PROVES NOTHING ON THIS HOST: measuring {what} requires a host that \
5831 can load a seccomp filter at all, which is every tier above \
5832 EnforcementTier::EnvironmentOnly; detected {tier:?}"
5833 );
5834 return true;
5835 }
5836 false
5837 }
5838
5839 #[test]
5846 fn seccomp_filter_instruction_count() {
5847 for unix_sockets_allowed in [false, true] {
5848 let count = super::seccomp_filter_instruction_count(unix_sockets_allowed)
5849 .expect("export the built seccomp filter");
5850
5851 eprintln!(
5852 "seccomp filter instruction count: unix_sockets_allowed={unix_sockets_allowed} \
5853 -> {count} instructions; budget {SECCOMP_FILTER_INSTRUCTION_BUDGET}; kernel \
5854 per-filter ceiling {BPF_MAXINSNS_CEILING} ({:.1}x headroom); kernel \
5855 cumulative ceiling {SECCOMP_MAX_INSNS_PER_PATH} ({:.1}x headroom)",
5856 BPF_MAXINSNS_CEILING as f64 / count as f64,
5857 SECCOMP_MAX_INSNS_PER_PATH as f64 / count as f64,
5858 );
5859
5860 assert!(
5861 count > 0,
5862 "the exported filter is empty, so nothing was measured — \
5863 `export_bpf` wrote no `struct sock_filter` records at all"
5864 );
5865 assert!(
5866 count <= SECCOMP_FILTER_INSTRUCTION_BUDGET,
5867 "the child's seccomp filter compiles to {count} instructions with \
5868 unix_sockets_allowed={unix_sockets_allowed}, over this project's budget of \
5869 {SECCOMP_FILTER_INSTRUCTION_BUDGET} (the kernel refuses a single filter over \
5870 {BPF_MAXINSNS_CEILING} with EINVAL). Re-measure, decide whether the rule set \
5871 still earns its size, and raise SECCOMP_FILTER_INSTRUCTION_BUDGET \
5872 deliberately — updating \
5873 docs/content/reference/seccomp-filter-instruction-budget.md with the new \
5874 numbers — rather than letting the filter grow into a spawn failure"
5875 );
5876 }
5877 }
5878
5879 #[test]
5888 fn seccomp_filter_rule_shape() {
5889 let allowlist_syscalls =
5890 resolvable_allowlist_syscalls().expect("count the resolvable allowlist syscalls");
5891 assert!(
5892 allowlist_syscalls > 0,
5893 "no SECCOMP_SYSCALL_ALLOWLIST name resolved on this host, so this test would pass \
5894 for the wrong reason"
5895 );
5896
5897 for unix_sockets_allowed in [false, true] {
5898 let filter =
5899 build_seccomp_filter(unix_sockets_allowed).expect("build the seccomp filter");
5900 assert_eq!(
5901 filter.get_act_default().expect("read the default action"),
5902 libseccomp::ScmpAction::Errno(libc::EPERM),
5903 "the filter's default action is no longer a deny with EPERM, which is what \
5904 makes every syscall outside the allowlist unreachable"
5905 );
5906
5907 let pfc =
5908 seccomp_filter_pfc(unix_sockets_allowed).expect("export the filter's pfc");
5909 let denied = denied_socket_domains(unix_sockets_allowed);
5910 let allowed = allowed_socket_domains(unix_sockets_allowed);
5911
5912 let syscall_blocks = pfc.matches("# filter for syscall ").count();
5915 assert_eq!(
5916 syscall_blocks,
5917 1 + allowlist_syscalls + LANDLOCK_SYSCALLS.len(),
5918 "the built filter carries rules for {syscall_blocks} syscalls, not the \
5919 socket rule plus {allowlist_syscalls} allowlist syscalls plus {} Landlock \
5920 syscalls this code adds",
5921 LANDLOCK_SYSCALLS.len()
5922 );
5923
5924 let deny_actions = pfc
5925 .matches(&format!("action ERRNO({});", libc::EACCES))
5926 .count();
5927 assert_eq!(
5928 deny_actions,
5929 denied.len(),
5930 "the built filter carries {deny_actions} EACCES rules, not one per denied \
5931 socket domain ({denied:?}) — an unconditional Allow on `socket` would look \
5932 exactly like this, and would have discarded every domain denial with it"
5933 );
5934
5935 let allow_actions = pfc.matches("action ALLOW;").count();
5938 assert_eq!(
5939 allow_actions,
5940 allowed.len() + allowlist_syscalls + LANDLOCK_SYSCALLS.len(),
5941 "the built filter carries {allow_actions} Allow rules, not one per allowed \
5942 socket domain ({allowed:?}) plus one per allowlist syscall plus one per \
5943 Landlock syscall"
5944 );
5945
5946 for (name, _) in LANDLOCK_SYSCALLS {
5947 assert!(
5948 pfc.contains(&format!("\"{name}\"")),
5949 "{name} has no rule in the built filter, so `apply_landlock_scope` would \
5950 be refused by the filter installed just before it"
5951 );
5952 }
5953
5954 eprintln!(
5955 "seccomp filter rule shape: unix_sockets_allowed={unix_sockets_allowed} -> \
5956 default ERRNO(EPERM); {} socket EACCES rules; {} socket ALLOW rules; \
5957 {allowlist_syscalls} allowlist syscalls; {} Landlock syscalls",
5958 denied.len(),
5959 allowed.len(),
5960 LANDLOCK_SYSCALLS.len(),
5961 );
5962 }
5963 }
5964
5965 #[test]
5972 fn seccomp_per_filter_ceiling() {
5973 if skip_without_seccomp("the kernel's per-filter instruction ceiling") {
5974 return;
5975 }
5976
5977 let over_rules = first_rule_count_above(BPF_MAXINSNS_CEILING)
5978 .expect("bracket the per-filter ceiling");
5979 let (under_filter, under_count) =
5980 probe_filter(over_rules - 1).expect("build the under-ceiling probe filter");
5981 let (over_filter, over_count) =
5982 probe_filter(over_rules).expect("build the over-ceiling probe filter");
5983
5984 assert!(
5985 under_count <= BPF_MAXINSNS_CEILING && over_count > BPF_MAXINSNS_CEILING,
5986 "the two probe filters ({under_count} and {over_count} instructions) do not \
5987 straddle BPF_MAXINSNS_CEILING ({BPF_MAXINSNS_CEILING}), so this test would prove \
5988 nothing about where the ceiling is"
5989 );
5990
5991 let under_outcome = load_in_child(&under_filter);
5992 let over_outcome = load_in_child(&over_filter);
5993
5994 eprintln!(
5995 "seccomp per-filter ceiling: largest filter that loaded = {under_count} \
5996 instructions; smallest that did not = {over_count} instructions, refused with \
5997 errno {} (EINVAL is {}); recorded BPF_MAXINSNS_CEILING = {BPF_MAXINSNS_CEILING}",
5998 over_outcome.err().unwrap_or(0),
5999 libc::EINVAL,
6000 );
6001
6002 assert_eq!(
6003 under_outcome,
6004 Ok(()),
6005 "a {under_count}-instruction filter was refused, below the \
6006 {BPF_MAXINSNS_CEILING}-instruction ceiling this test expects the kernel to hold"
6007 );
6008 assert_eq!(
6009 over_outcome,
6010 Err(libc::EINVAL),
6011 "a {over_count}-instruction filter was not refused with EINVAL, so \
6012 BPF_MAXINSNS_CEILING ({BPF_MAXINSNS_CEILING}) is not where this kernel puts its \
6013 per-filter limit"
6014 );
6015 }
6016
6017 #[test]
6025 fn seccomp_cumulative_ceiling() {
6026 if skip_without_seccomp("the kernel's cumulative filter-chain ceiling") {
6027 return;
6028 }
6029
6030 let (filter, count) = probe_filter(120).expect("build the cumulative probe filter");
6033 let per_filter_cost = count + KERNEL_PER_FILTER_OVERHEAD_INSNS;
6034 let max_loads = SECCOMP_MAX_INSNS_PER_PATH / per_filter_cost + 16;
6037
6038 let mut fds = [0 as libc::c_int; 2];
6039 let rc = unsafe { libc::pipe2(fds.as_mut_ptr(), libc::O_CLOEXEC) };
6041 assert!(rc == 0, "pipe2: {}", io::Error::last_os_error());
6042 let (read_fd, write_fd) =
6044 unsafe { (OwnedFd::from_raw_fd(fds[0]), OwnedFd::from_raw_fd(fds[1])) };
6045
6046 let pid = unsafe { libc::fork() };
6050 assert!(pid >= 0, "fork failed: {}", io::Error::last_os_error());
6051
6052 if pid == 0 {
6053 let mut loads: u32 = 0;
6054 let mut refusal: i32 = 0;
6055 while loads < max_loads as u32 {
6056 match filter.load() {
6057 Ok(()) => loads += 1,
6058 Err(error) => {
6059 refusal = refusal_errno(&error);
6060 break;
6061 }
6062 }
6063 }
6064
6065 let mut report = [0u8; 8];
6066 report[..4].copy_from_slice(&loads.to_ne_bytes());
6067 report[4..].copy_from_slice(&refusal.to_ne_bytes());
6068 unsafe { libc::write(write_fd.as_raw_fd(), report.as_ptr().cast(), report.len()) };
6071 unsafe { libc::_exit(0) }
6073 }
6074
6075 drop(write_fd);
6076 let mut report = [0u8; 8];
6077 std::io::Read::read_exact(&mut std::fs::File::from(read_fd), &mut report)
6078 .expect("read the cumulative probe child's report");
6079 let loads = u32::from_ne_bytes(report[..4].try_into().unwrap()) as usize;
6080 let refusal = i32::from_ne_bytes(report[4..].try_into().unwrap());
6081
6082 let mut status: libc::c_int = 0;
6083 let waited = unsafe { libc::waitpid(pid, &mut status, 0) };
6085 assert!(waited == pid, "waitpid: {}", io::Error::last_os_error());
6086
6087 let stacked = loads * count;
6088 let cumulative_at_refusal = count + loads * per_filter_cost;
6089 let loads_if_counted_classic = SECCOMP_MAX_INSNS_PER_PATH / per_filter_cost;
6090 let shipped = super::seccomp_filter_instruction_count(false)
6091 .expect("measure the shipped filter for comparison");
6092
6093 eprintln!(
6094 "seccomp cumulative ceiling: {loads} loads of a {count}-instruction filter \
6095 succeeded ({stacked} instructions stacked, {cumulative_at_refusal} counted the \
6096 way the kernel counts once the {KERNEL_PER_FILTER_OVERHEAD_INSNS}-instruction \
6097 per-filter overhead is added); load {} was refused with errno {refusal} (ENOMEM \
6098 is {}), against the recorded SECCOMP_MAX_INSNS_PER_PATH of \
6099 {SECCOMP_MAX_INSNS_PER_PATH} — which the exported classic length puts at at most \
6100 {loads_if_counted_classic} loads. The runtime installs ONE filter of {shipped} \
6101 instructions, so reaching this ceiling would take {} of them in a single task.",
6102 loads + 1,
6103 libc::ENOMEM,
6104 SECCOMP_MAX_INSNS_PER_PATH / shipped,
6105 );
6106
6107 assert!(
6108 loads >= 2,
6109 "the kernel refused the filter chain after only {loads} load(s), so this is not \
6110 a cumulative limit and the ENOMEM it returns is not the one being ruled out"
6111 );
6112 assert_eq!(
6113 refusal,
6114 libc::ENOMEM,
6115 "stacking filters past SECCOMP_MAX_INSNS_PER_PATH was refused with errno \
6116 {refusal}, not ENOMEM ({}) — the rule-out in \
6117 docs/content/reference/seccomp-filter-instruction-budget.md rests on ENOMEM \
6118 being what this ceiling returns",
6119 libc::ENOMEM
6120 );
6121 assert!(
6122 loads <= loads_if_counted_classic,
6123 "the kernel took {loads} loads of a {count}-instruction filter — more than the \
6124 {loads_if_counted_classic} that fit under SECCOMP_MAX_INSNS_PER_PATH \
6125 ({SECCOMP_MAX_INSNS_PER_PATH}) even before the conversion to eBPF lengthens \
6126 each one, so this kernel's ceiling is higher than the recorded value"
6127 );
6128 assert!(
6133 loads * 2 > loads_if_counted_classic,
6134 "the kernel refused after {loads} loads of a {count}-instruction filter, far \
6135 short of the {loads_if_counted_classic} that SECCOMP_MAX_INSNS_PER_PATH \
6136 ({SECCOMP_MAX_INSNS_PER_PATH}) implies — the ceiling being measured is not the \
6137 one recorded"
6138 );
6139 assert!(
6140 shipped * 2 < SECCOMP_MAX_INSNS_PER_PATH,
6141 "the shipped filter ({shipped} instructions) is within a factor of two of the \
6142 cumulative ceiling, which would make the rule-out this test supports untrue"
6143 );
6144 }
6145 }
6146}
6147
6148#[cfg(test)]
6153pub(crate) fn build_elf64(interp: Option<&str>, needed: &[&str], runpath: Option<&str>) -> Vec<u8> {
6154 let has_dynamic = !needed.is_empty() || runpath.is_some();
6155
6156 let mut phnum: u16 = 1; if interp.is_some() {
6158 phnum += 1;
6159 }
6160 if has_dynamic {
6161 phnum += 1;
6162 }
6163
6164 let ehsize = 64usize;
6165 let phentsize = 56usize;
6166 let phoff = ehsize;
6167 let body_start = phoff + phentsize * phnum as usize;
6168
6169 let mut strtab: Vec<u8> = vec![0];
6171 let mut needed_offsets = Vec::new();
6172 for name in needed {
6173 needed_offsets.push(strtab.len() as u64);
6174 strtab.extend_from_slice(name.as_bytes());
6175 strtab.push(0);
6176 }
6177 let runpath_offset = runpath.map(|r| {
6178 let off = strtab.len() as u64;
6179 strtab.extend_from_slice(r.as_bytes());
6180 strtab.push(0);
6181 off
6182 });
6183
6184 let mut body: Vec<u8> = Vec::new();
6185
6186 let interp_offset = body_start + body.len();
6187 if let Some(i) = interp {
6188 body.extend_from_slice(i.as_bytes());
6189 body.push(0);
6190 }
6191
6192 while !(body_start + body.len()).is_multiple_of(8) {
6194 body.push(0);
6195 }
6196 let dyn_offset = body_start + body.len();
6197 let num_dyn = needed.len() + runpath.map_or(0, |_| 1) + 3; let dyn_size = num_dyn * 16;
6199 let strtab_offset = dyn_offset + dyn_size;
6200
6201 if has_dynamic {
6202 let mut push_dyn = |tag: u64, val: u64| {
6203 body.extend_from_slice(&tag.to_le_bytes());
6204 body.extend_from_slice(&val.to_le_bytes());
6205 };
6206 for &o in &needed_offsets {
6207 push_dyn(1, o); }
6209 if let Some(ro) = runpath_offset {
6210 push_dyn(29, ro); }
6212 push_dyn(5, strtab_offset as u64); push_dyn(10, strtab.len() as u64); push_dyn(0, 0); assert_eq!(body_start + body.len(), strtab_offset);
6216 body.extend_from_slice(&strtab);
6217 }
6218
6219 let total_len = body_start + body.len();
6220 let mut out = vec![0u8; total_len];
6221 out[0..4].copy_from_slice(b"\x7fELF");
6222 out[4] = 2; out[5] = 1; out[6] = 1; out[16..18].copy_from_slice(&3u16.to_le_bytes()); out[18..20].copy_from_slice(&0x3eu16.to_le_bytes()); out[20..24].copy_from_slice(&1u32.to_le_bytes()); out[32..40].copy_from_slice(&(phoff as u64).to_le_bytes()); out[54..56].copy_from_slice(&(phentsize as u16).to_le_bytes()); out[56..58].copy_from_slice(&phnum.to_le_bytes()); let mut phdrs: Vec<(u32, u64, u64, u64)> = Vec::new(); phdrs.push((1, 0, 0, total_len as u64)); if let Some(i) = interp {
6235 phdrs.push((
6236 3,
6237 interp_offset as u64,
6238 interp_offset as u64,
6239 i.len() as u64 + 1,
6240 ));
6241 }
6242 if has_dynamic {
6243 phdrs.push((2, dyn_offset as u64, dyn_offset as u64, dyn_size as u64));
6244 }
6245 for (idx, (p_type, p_offset, p_vaddr, p_filesz)) in phdrs.iter().enumerate() {
6246 let base = phoff + idx * phentsize;
6247 out[base..base + 4].copy_from_slice(&p_type.to_le_bytes());
6248 out[base + 8..base + 16].copy_from_slice(&p_offset.to_le_bytes());
6249 out[base + 16..base + 24].copy_from_slice(&p_vaddr.to_le_bytes());
6250 out[base + 32..base + 40].copy_from_slice(&p_filesz.to_le_bytes());
6251 }
6252
6253 out[body_start..total_len].copy_from_slice(&body);
6254 out
6255}
6256
6257#[cfg(test)]
6258mod tests {
6259 use super::*;
6260 use murmur_artifact::security_warnings::W_SEC_003;
6261
6262 #[test]
6269 fn fd_hygiene_range_starts_above_stdio() {
6270 assert_eq!(
6271 FD_HYGIENE_FIRST_FD, 3,
6272 "fd hygiene must start at 3 — 0/1/2 are the stdio pipes every spawn needs to \
6273 survive execve"
6274 );
6275 }
6276
6277 #[cfg(target_os = "linux")]
6288 #[allow(unsafe_code)]
6289 #[test]
6290 fn close_range_syscall_reports_failure_as_minus_one_and_errno() {
6291 let rc = unsafe {
6295 libc::syscall(
6296 libc::SYS_close_range,
6297 FD_HYGIENE_FIRST_FD,
6298 libc::c_uint::MAX,
6299 libc::c_uint::MAX,
6300 )
6301 };
6302
6303 assert_eq!(rc, -1, "an invalid flag word must not be accepted");
6304 assert_eq!(
6305 std::io::Error::last_os_error().raw_os_error(),
6306 Some(libc::EINVAL),
6307 "the two statements the production path runs on failure must carry the kernel's errno"
6308 );
6309 }
6310
6311 fn sealed_capable() -> crate::namespace_probe::NamespaceReading {
6314 crate::namespace_probe::NamespaceReading {
6315 network: Ok(()),
6316 mount: crate::namespace_probe::MountAttempt::Created,
6317 sealed_root: crate::namespace_probe::MountAttempt::Created,
6318 private_proc: crate::namespace_probe::MountAttempt::NotAttempted,
6319 userns_grant: crate::sealed::UsernsGrant::Withheld,
6320 attachment: crate::sealed::ProfileAttachment::Unconfined,
6321 }
6322 }
6323
6324 fn sealed_incapable() -> crate::namespace_probe::NamespaceReading {
6326 crate::namespace_probe::NamespaceReading {
6327 network: Err(crate::network_namespace::NamespaceAttemptError::Creator(
6328 "the namespace creator did not run".to_string(),
6329 )),
6330 mount: crate::namespace_probe::MountAttempt::NotAttempted,
6331 sealed_root: crate::namespace_probe::MountAttempt::NotAttempted,
6332 private_proc: crate::namespace_probe::MountAttempt::NotAttempted,
6333 userns_grant: crate::sealed::UsernsGrant::Withheld,
6334 attachment: crate::sealed::ProfileAttachment::NotReported,
6335 }
6336 }
6337
6338 #[test]
6342 fn doctor_reads_the_decision_from_the_probe_it_is_handed() {
6343 if !cfg!(target_os = "linux") {
6344 return;
6345 }
6346 let sealed_host = HostProbe::from_parts(Some(true), sealed_capable());
6347 assert_eq!(sealed_host.tier(), EnforcementTier::KernelSealed);
6348 assert_eq!(
6349 crate::containment::applied_mechanism(
6350 &sealed_host,
6351 murmur_artifact::ContainmentClass::Scoped
6352 ),
6353 "landlock+seccomp"
6354 );
6355 assert_eq!(
6356 crate::containment::applied_mechanism(
6357 &sealed_host,
6358 murmur_artifact::ContainmentClass::Sealed
6359 ),
6360 "mountns+pivot_root+landlock+seccomp"
6361 );
6362 }
6363
6364 fn unprobed() -> HostProbe {
6367 HostProbe::from_parts(None, sealed_incapable())
6368 }
6369
6370 #[test]
6374 fn an_unprobed_host_resolves_to_seccomp_only() {
6375 let tier = unprobed().tier();
6376 if cfg!(target_os = "linux") {
6377 assert_eq!(tier, EnforcementTier::KernelSeccompOnly);
6378 } else {
6379 assert_eq!(tier, EnforcementTier::EnvironmentOnly);
6380 }
6381 assert_ne!(tier, EnforcementTier::KernelSealed);
6382 assert_ne!(tier, EnforcementTier::KernelFull);
6383 }
6384
6385 #[test]
6388 fn an_unprobed_host_names_a_blocker() {
6389 assert!(unprobed().sealed_shortfall().is_some());
6390 }
6391
6392 #[test]
6395 fn an_unprobed_host_achieves_less_than_sealed() {
6396 let achieved = crate::containment::achieved_class_for_tier(unprobed().tier());
6397 assert!(achieved < murmur_artifact::ContainmentClass::Sealed);
6398 }
6399
6400 #[test]
6403 fn an_unprobed_host_refuses_a_declared_sealed_floor() {
6404 let probe = unprobed();
6405 let achieved = crate::containment::achieved_class_for_tier(probe.tier());
6406 let error = crate::containment::check_containment_floor(
6407 murmur_artifact::ContainmentClass::Sealed,
6408 achieved,
6409 probe.sealed_shortfall().as_ref(),
6410 false,
6411 None,
6412 )
6413 .expect_err("an unprobed host cannot back a declared sealed floor");
6414 assert!(matches!(
6415 error,
6416 crate::errors::RuntimeError::ContainmentFloorUnmet { .. }
6417 ));
6418 }
6419
6420 #[test]
6424 fn a_probe_that_created_every_namespace_backs_sealed_while_the_setting_reads_one() {
6425 for attachment in [
6426 crate::sealed::ProfileAttachment::Unconfined,
6427 crate::sealed::ProfileAttachment::Other {
6428 profile: "docker-default".to_string(),
6429 },
6430 ] {
6431 let probe = HostProbe::from_parts(
6432 Some(true),
6433 crate::namespace_probe::NamespaceReading {
6434 attachment,
6435 ..sealed_capable()
6436 },
6437 );
6438 if !cfg!(target_os = "linux") {
6439 assert_eq!(probe.tier(), EnforcementTier::EnvironmentOnly);
6440 continue;
6441 }
6442 assert_eq!(probe.tier(), EnforcementTier::KernelSealed);
6443 assert_eq!(probe.sealed_shortfall(), None);
6444 assert!(probe.mount_namespace_usable());
6445 assert_eq!(probe.egress_namespace_failure(), None);
6446 assert_eq!(
6447 crate::temp_dir::temp_mapping(
6448 EnforcementTier::KernelFull,
6449 probe.mount_namespace_usable(),
6450 Path::new("/home/u/w"),
6451 ),
6452 crate::temp_dir::TempMapping::MountNamespace
6453 );
6454 }
6455 }
6456
6457 #[test]
6461 fn every_decision_is_identical_across_every_userns_grant() {
6462 use crate::namespace_probe::{
6463 MountAttempt, MountAttemptError, MountStep, NamespaceReading,
6464 };
6465 let root_refused = NamespaceReading {
6466 sealed_root: MountAttempt::Failed(MountAttemptError::Step {
6467 step: MountStep::PivotRoot,
6468 errno: libc::EPERM,
6469 }),
6470 ..sealed_capable()
6471 };
6472 for base in [sealed_capable(), root_refused, sealed_incapable()] {
6473 let decide = |grant: crate::sealed::UsernsGrant| {
6474 let probe = HostProbe::from_parts(
6475 Some(true),
6476 NamespaceReading {
6477 userns_grant: grant,
6478 ..base.clone()
6479 },
6480 );
6481 (
6482 probe.tier(),
6483 probe.sealed_shortfall().map(|shortfall| shortfall.blocker),
6484 probe.mount_namespace_usable(),
6485 probe
6486 .egress_namespace_failure()
6487 .map(|failure| failure.blocker),
6488 )
6489 };
6490 let expected = decide(crate::sealed::UsernsGrant::Withheld);
6491 for grant in crate::sealed::UsernsGrant::ALL {
6492 assert_eq!(decide(*grant), expected, "{grant:?} over {base:?}");
6493 }
6494 }
6495 }
6496
6497 #[test]
6501 fn a_live_reading_answers_every_question_from_its_own_attempts() {
6502 let probe = HostProbe::probe();
6503 let namespaces = probe.namespaces().clone();
6504 let linux = cfg!(target_os = "linux");
6505 assert_eq!(
6506 probe.tier() == EnforcementTier::KernelSealed,
6507 probe.sealed_shortfall().is_none(),
6508 "{namespaces:?}"
6509 );
6510 assert_eq!(
6511 probe.mount_namespace_usable(),
6512 linux
6513 && namespaces.network.is_ok()
6514 && namespaces.mount == crate::namespace_probe::MountAttempt::Created
6515 );
6516 assert_eq!(
6517 probe.egress_namespace_failure().is_some(),
6518 linux && namespaces.network.is_err()
6519 );
6520 }
6521
6522 #[test]
6523 fn tier_from_probe_non_linux_is_always_environment_only() {
6524 for landlock in [None, Some(true), Some(false)] {
6525 for namespaces in [sealed_capable(), sealed_incapable()] {
6526 assert_eq!(
6527 tier_from_probe(false, landlock, &namespaces),
6528 EnforcementTier::EnvironmentOnly
6529 );
6530 }
6531 }
6532 }
6533
6534 #[test]
6535 fn tier_from_probe_linux_fully_enforced_is_kernel_full() {
6536 assert_eq!(
6537 tier_from_probe(true, Some(true), &sealed_incapable()),
6538 EnforcementTier::KernelFull
6539 );
6540 }
6541
6542 #[test]
6543 fn tier_from_probe_linux_partially_enforced_is_seccomp_only() {
6544 assert_eq!(
6545 tier_from_probe(true, Some(false), &sealed_incapable()),
6546 EnforcementTier::KernelSeccompOnly
6547 );
6548 }
6549
6550 #[test]
6551 fn tier_from_probe_linux_no_probe_result_is_seccomp_only() {
6552 assert_eq!(
6553 tier_from_probe(true, None, &sealed_incapable()),
6554 EnforcementTier::KernelSeccompOnly
6555 );
6556 }
6557
6558 #[test]
6562 fn tier_from_probe_reaches_sealed_only_when_every_precondition_holds() {
6563 use crate::namespace_probe::{
6564 MountAttempt, MountAttemptError, MountStep, NamespaceReading,
6565 };
6566
6567 assert_eq!(
6568 tier_from_probe(true, Some(true), &sealed_capable()),
6569 EnforcementTier::KernelSealed
6570 );
6571
6572 let network_refused = NamespaceReading {
6574 network: Err(crate::network_namespace::NamespaceAttemptError::Step {
6575 step: crate::network_namespace::CreationStep::IdentityMaps,
6576 errno: libc::EPERM,
6577 }),
6578 mount: MountAttempt::NotAttempted,
6579 sealed_root: MountAttempt::NotAttempted,
6580 ..sealed_capable()
6581 };
6582 assert_eq!(
6583 tier_from_probe(true, Some(true), &network_refused),
6584 EnforcementTier::KernelFull
6585 );
6586
6587 for step in [
6589 MountStep::JoinSession,
6590 MountStep::UnshareUserMount,
6591 MountStep::IdentityMaps,
6592 MountStep::MakePrivateNested,
6593 MountStep::Tmpfs,
6594 MountStep::PivotPrepare,
6595 MountStep::PivotRoot,
6596 MountStep::DetachOldRoot,
6597 ] {
6598 let reading = NamespaceReading {
6599 sealed_root: MountAttempt::Failed(MountAttemptError::Step {
6600 step,
6601 errno: libc::EPERM,
6602 }),
6603 ..sealed_capable()
6604 };
6605 assert_eq!(
6606 tier_from_probe(true, Some(true), &reading),
6607 EnforcementTier::KernelFull,
6608 "a sealed root refused at {step:?} must fall back to KernelFull"
6609 );
6610 }
6611
6612 let tmp_refused = NamespaceReading {
6614 mount: MountAttempt::Failed(MountAttemptError::Step {
6615 step: MountStep::MakePrivate,
6616 errno: libc::EACCES,
6617 }),
6618 ..sealed_capable()
6619 };
6620 assert_eq!(
6621 tier_from_probe(true, Some(true), &tmp_refused),
6622 EnforcementTier::KernelSealed
6623 );
6624
6625 assert_eq!(
6628 tier_from_probe(true, Some(false), &sealed_capable()),
6629 EnforcementTier::KernelSeccompOnly
6630 );
6631 assert_eq!(
6632 tier_from_probe(true, None, &sealed_capable()),
6633 EnforcementTier::KernelSeccompOnly
6634 );
6635
6636 assert_eq!(
6638 tier_from_probe(false, Some(true), &sealed_capable()),
6639 EnforcementTier::EnvironmentOnly
6640 );
6641 }
6642
6643 #[test]
6647 fn the_tmp_mapping_follows_the_mount_attempt() {
6648 use crate::namespace_probe::{
6649 MountAttempt, MountAttemptError, MountStep, NamespaceReading,
6650 };
6651 use crate::temp_dir::{temp_mapping, NotMappedReason, TempMapping};
6652 if !cfg!(target_os = "linux") {
6653 return;
6654 }
6655 let workdir = Path::new("/home/u/w");
6656 let refused = |step, errno| MountAttempt::Failed(MountAttemptError::Step { step, errno });
6657
6658 let mount_refused = HostProbe::from_parts(
6659 Some(true),
6660 NamespaceReading {
6661 mount: refused(MountStep::MakePrivate, libc::EACCES),
6662 sealed_root: refused(MountStep::MakePrivateNested, libc::EACCES),
6663 ..sealed_capable()
6664 },
6665 );
6666 assert_eq!(mount_refused.tier(), EnforcementTier::KernelFull);
6667 assert_eq!(
6668 temp_mapping(
6669 mount_refused.tier(),
6670 mount_refused.mount_namespace_usable(),
6671 workdir
6672 ),
6673 TempMapping::NotMapped(NotMappedReason::NoMountNamespace)
6674 );
6675
6676 let root_refused = HostProbe::from_parts(
6677 Some(true),
6678 NamespaceReading {
6679 sealed_root: refused(MountStep::PivotRoot, libc::EPERM),
6680 ..sealed_capable()
6681 },
6682 );
6683 assert_eq!(root_refused.tier(), EnforcementTier::KernelFull);
6684 assert_eq!(
6685 temp_mapping(
6686 root_refused.tier(),
6687 root_refused.mount_namespace_usable(),
6688 workdir
6689 ),
6690 TempMapping::MountNamespace
6691 );
6692
6693 let network_refused = HostProbe::from_parts(
6694 Some(true),
6695 NamespaceReading {
6696 network: Err(crate::network_namespace::NamespaceAttemptError::Step {
6697 step: crate::network_namespace::CreationStep::Unshare,
6698 errno: libc::EPERM,
6699 }),
6700 mount: MountAttempt::NotAttempted,
6701 sealed_root: MountAttempt::NotAttempted,
6702 ..sealed_capable()
6703 },
6704 );
6705 assert!(!network_refused.mount_namespace_usable());
6706 assert!(matches!(
6707 temp_mapping(
6708 network_refused.tier(),
6709 network_refused.mount_namespace_usable(),
6710 workdir
6711 ),
6712 TempMapping::NotMapped(_)
6713 ));
6714 assert!(network_refused.egress_namespace_failure().is_some());
6715 }
6716
6717 #[test]
6719 fn ruleset_landlock_abi_is_the_kernel_abi_capped_at_the_target_on_landlock_tiers() {
6720 for tier in [EnforcementTier::KernelFull, EnforcementTier::KernelSealed] {
6721 for (kernel, built) in [
6722 (Some(1), Some(1)),
6723 (Some(2), Some(2)),
6724 (Some(3), Some(3)),
6725 (Some(8), Some(3)),
6726 (Some(u8::MAX), Some(3)),
6727 ] {
6728 assert_eq!(
6729 ruleset_landlock_abi(kernel, tier),
6730 built,
6731 "kernel ABI {kernel:?} on {tier:?}"
6732 );
6733 }
6734 }
6735 }
6736
6737 #[test]
6739 fn ruleset_landlock_abi_is_none_without_a_kernel_abi_or_a_landlock_tier() {
6740 for tier in [EnforcementTier::KernelFull, EnforcementTier::KernelSealed] {
6741 assert_eq!(ruleset_landlock_abi(None, tier), None);
6742 assert_eq!(ruleset_landlock_abi(Some(0), tier), None);
6743 }
6744 for tier in [
6745 EnforcementTier::KernelSeccompOnly,
6746 EnforcementTier::EnvironmentOnly,
6747 ] {
6748 for kernel in [None, Some(0), Some(1), Some(3), Some(8)] {
6749 assert_eq!(
6750 ruleset_landlock_abi(kernel, tier),
6751 None,
6752 "kernel ABI {kernel:?} on {tier:?}"
6753 );
6754 }
6755 }
6756 }
6757
6758 #[test]
6761 fn ruleset_landlock_abi_reaches_shell_enforcement_from_the_probe() {
6762 let negative = unprobed();
6763 for abi in [None, Some(1), Some(8)] {
6764 let enforcement = ShellEnforcement::resolve_declared(
6765 &CapabilityPolicy::default(),
6766 murmur_artifact::ContainmentClass::Advisory,
6767 negative.clone().with_landlock_kernel_abi(abi),
6768 )
6769 .unwrap()
6770 .with_test_session_dirs();
6771 assert_eq!(
6772 enforcement.landlock_abi,
6773 ruleset_landlock_abi(abi, enforcement.tier),
6774 "kernel ABI {abi:?}"
6775 );
6776 assert_eq!(enforcement.landlock_abi, None);
6778 }
6779 if cfg!(target_os = "linux") {
6780 let full = HostProbe::from_parts(Some(true), sealed_incapable());
6781 let enforcement = ShellEnforcement::resolve_declared(
6782 &CapabilityPolicy::default(),
6783 murmur_artifact::ContainmentClass::Scoped,
6784 full.with_landlock_kernel_abi(Some(8)),
6785 )
6786 .unwrap()
6787 .with_test_session_dirs();
6788 assert_eq!(enforcement.tier, EnforcementTier::KernelFull);
6789 assert_eq!(enforcement.landlock_abi, Some(LANDLOCK_TARGET_ABI));
6790 }
6791 }
6792
6793 #[test]
6795 fn landlock_doctor_lines_name_the_kernel_and_ruleset_abi() {
6796 if !cfg!(target_os = "linux") {
6797 for abi in [None, Some(1), Some(8)] {
6798 assert_eq!(
6799 landlock_doctor_lines(abi),
6800 vec!["kernel ABI: n/a (Landlock is a Linux mechanism)".to_string()]
6801 );
6802 }
6803 return;
6804 }
6805
6806 let none = landlock_doctor_lines(None).join("\n");
6807 assert!(none.contains("no Landlock on this host"), "{none}");
6808 assert_eq!(landlock_doctor_lines(Some(0)), landlock_doctor_lines(None));
6809
6810 let v1 = landlock_doctor_lines(Some(1)).join("\n");
6811 assert!(v1.contains("kernel ABI: 1"), "{v1}");
6812 assert!(v1.contains("ruleset ABI: 1"), "{v1}");
6813 assert!(v1.contains("EXDEV"), "{v1}");
6814 assert!(v1.contains("Invalid cross-device link"), "{v1}");
6815 assert!(v1.contains("Linux 5.19 (Landlock ABI v2)"), "{v1}");
6816 assert!(v1.contains("uv"), "{v1}");
6817
6818 let v2 = landlock_doctor_lines(Some(2)).join("\n");
6819 assert!(v2.contains("kernel ABI: 2"), "{v2}");
6820 assert!(v2.contains("ruleset ABI: 2"), "{v2}");
6821 assert!(!v2.contains("EXDEV"), "{v2}");
6822 assert!(
6823 v2.contains("not mediated below Landlock ABI v3 (Linux 6.2)"),
6824 "{v2}"
6825 );
6826
6827 for kernel in [3, 8] {
6828 let lines = landlock_doctor_lines(Some(kernel));
6829 assert_eq!(
6830 lines,
6831 vec![
6832 format!("kernel ABI: {kernel}"),
6833 "ruleset ABI: 3 (the kernel's, capped at 3)".to_string(),
6834 ]
6835 );
6836 }
6837 }
6838
6839 #[test]
6842 fn applied_tier_is_unconfined_on_every_host_for_the_unconfined_stance() {
6843 use murmur_artifact::ContainmentClass;
6844 for host_tier in [
6845 EnforcementTier::KernelSealed,
6846 EnforcementTier::KernelFull,
6847 EnforcementTier::KernelSeccompOnly,
6848 EnforcementTier::EnvironmentOnly,
6849 ] {
6850 assert_eq!(
6851 applied_tier(host_tier, ContainmentClass::Unconfined),
6852 EnforcementTier::Unconfined,
6853 "{host_tier:?}"
6854 );
6855 }
6856 assert_eq!(tier_warning(EnforcementTier::Unconfined, false), None);
6857 assert_eq!(
6858 ruleset_landlock_abi(Some(4), EnforcementTier::Unconfined),
6859 None
6860 );
6861 }
6862
6863 #[test]
6867 fn applied_tier_never_installs_a_composed_root_for_a_weaker_declaration() {
6868 use murmur_artifact::ContainmentClass;
6869
6870 assert_eq!(
6871 applied_tier(EnforcementTier::KernelSealed, ContainmentClass::Sealed),
6872 EnforcementTier::KernelSealed
6873 );
6874 for declared in [ContainmentClass::Advisory, ContainmentClass::Scoped] {
6875 assert_eq!(
6876 applied_tier(EnforcementTier::KernelSealed, declared),
6877 EnforcementTier::KernelFull,
6878 "declaring {declared} must not opt a capsule into the composed root"
6879 );
6880 }
6881
6882 for host_tier in [
6885 EnforcementTier::KernelFull,
6886 EnforcementTier::KernelSeccompOnly,
6887 EnforcementTier::EnvironmentOnly,
6888 ] {
6889 for declared in [
6890 ContainmentClass::Advisory,
6891 ContainmentClass::Scoped,
6892 ContainmentClass::Sealed,
6893 ] {
6894 assert_eq!(applied_tier(host_tier, declared), host_tier);
6895 }
6896 }
6897 }
6898
6899 #[test]
6900 fn sealed_bind_dirs_are_whole_directories_outside_the_fixed_runtime_paths() {
6901 use murmur_artifact::{InterpreterRuntimeDir, InterpreterRuntimeGrant};
6902
6903 let policy = CapabilityPolicy {
6904 shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
6905 binary: "python3".to_string(),
6906 dirs: vec![
6907 InterpreterRuntimeDir {
6909 path: "/usr/lib/python3.11".to_string(),
6910 list_dir: true,
6911 },
6912 InterpreterRuntimeDir {
6913 path: "/opt/py/lib".to_string(),
6914 list_dir: true,
6915 },
6916 InterpreterRuntimeDir {
6918 path: "/opt/py/lib".to_string(),
6919 list_dir: false,
6920 },
6921 InterpreterRuntimeDir {
6922 path: "relative/lib".to_string(),
6923 list_dir: false,
6924 },
6925 ],
6926 }],
6927 ..CapabilityPolicy::default()
6928 };
6929 let exec_allow = vec![
6930 PathBuf::from("/usr/bin/python3"),
6931 PathBuf::from("/opt/toolchain/bin/cc"),
6932 ];
6933
6934 assert_eq!(
6935 resolve_sealed_bind_dirs(&exec_allow, &policy),
6936 vec![
6937 PathBuf::from("/opt/toolchain/bin"),
6938 PathBuf::from("/opt/py/lib")
6939 ]
6940 );
6941 }
6942
6943 #[test]
6948 fn host_path_execute_roots_include_interpreter_runtime_dirs_and_staged_trees() {
6949 use crate::host_paths::{check_execute_roots, HostPath, HostPathAccess, HostPathMechanism};
6950 let policy = CapabilityPolicy {
6951 shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
6952 binary: "python3".to_string(),
6953 dirs: vec![murmur_artifact::InterpreterRuntimeDir {
6954 path: "/nonexistent-host-path-test/interp".to_string(),
6955 list_dir: true,
6956 }],
6957 }],
6958 shell_staged_runtime: vec![murmur_artifact::StagedRuntimeGrant {
6959 binary: "python3".to_string(),
6960 source_path: "/nonexistent-host-path-test/opt/py".to_string(),
6961 pin: "pin-1".to_string(),
6962 }],
6963 ..CapabilityPolicy::default()
6964 };
6965 let roots = execute_roots_from(&[], &policy);
6966 let dirs: Vec<&Path> = roots.iter().map(|root| root.dir.as_path()).collect();
6967 assert_eq!(
6968 dirs,
6969 [
6970 Path::new("/nonexistent-host-path-test/interp"),
6971 Path::new("/nonexistent-host-path-test/opt/py"),
6972 ]
6973 );
6974 let read_write = |path: &str| HostPath {
6975 declared: path.to_string(),
6976 path: PathBuf::from(path),
6977 access: HostPathAccess::ReadWrite,
6978 credential_locations: Vec::new(),
6979 };
6980
6981 let inside_interpreter = [read_write("/nonexistent-host-path-test/interp/cache")];
6982 for mechanism in [HostPathMechanism::Landlock, HostPathMechanism::Bind] {
6983 assert!(check_execute_roots(&inside_interpreter, &roots, mechanism).is_err());
6984 }
6985
6986 let holding_staged = [read_write("/nonexistent-host-path-test/opt")];
6987 assert!(check_execute_roots(&holding_staged, &roots, HostPathMechanism::Landlock).is_err());
6988 assert_eq!(
6989 check_execute_roots(&holding_staged, &roots, HostPathMechanism::Bind).unwrap(),
6990 [PathBuf::from("/nonexistent-host-path-test/opt/py")]
6991 );
6992 }
6993
6994 #[test]
6999 fn staged_runtime_dirs_keep_fixed_path_overlaps_and_drop_only_duplicates_and_relatives() {
7000 use murmur_artifact::StagedRuntimeGrant;
7001
7002 let grant = |binary: &str, source: &str| StagedRuntimeGrant {
7003 binary: binary.to_string(),
7004 source_path: source.to_string(),
7005 pin: "pin-1".to_string(),
7006 };
7007 let policy = CapabilityPolicy {
7008 shell_staged_runtime: vec![
7009 grant("python3", "/opt/py"),
7010 grant("perl", "/usr/lib/perl5"),
7013 grant("python3.12", "/opt/py"),
7015 grant("ruby", "relative/ruby"),
7016 ],
7017 ..CapabilityPolicy::default()
7018 };
7019
7020 assert_eq!(
7021 resolve_staged_runtime_dirs(&policy),
7022 vec![PathBuf::from("/opt/py"), PathBuf::from("/usr/lib/perl5")]
7023 );
7024 }
7025
7026 #[test]
7030 fn every_staged_runtime_dir_also_gets_a_listable_landlock_grant() {
7031 use murmur_artifact::StagedRuntimeGrant;
7032
7033 let policy = CapabilityPolicy {
7034 shell_staged_runtime: vec![StagedRuntimeGrant {
7035 binary: "python3".to_string(),
7036 source_path: "/opt/py".to_string(),
7037 pin: "pin-1".to_string(),
7038 }],
7039 ..CapabilityPolicy::default()
7040 };
7041
7042 let dirs = resolve_staged_runtime_dirs(&policy);
7043 let grants = resolve_staged_runtime_landlock_grants(&policy);
7044
7045 assert_eq!(
7046 grants,
7047 vec![LandlockGrant {
7048 path: PathBuf::from("/opt/py"),
7049 list_dir: true,
7050 executable: true,
7051 }],
7052 "a staged tree is walked by the runtime that uses it, so it must be listable",
7053 );
7054 assert_eq!(
7055 grants
7056 .iter()
7057 .map(|grant| grant.path.clone())
7058 .collect::<Vec<_>>(),
7059 dirs,
7060 "the bound set and the granted set must not drift apart",
7061 );
7062 }
7063
7064 #[test]
7065 fn every_sealed_runtime_path_gets_a_listable_non_executable_landlock_grant() {
7066 let grants = resolve_sealed_runtime_landlock_grants(EnforcementTier::KernelSealed);
7067
7068 assert_eq!(
7069 grants,
7070 crate::sealed::SEALED_RUNTIME_PATHS
7071 .iter()
7072 .map(|path| LandlockGrant {
7073 path: PathBuf::from(path),
7074 list_dir: true,
7075 executable: false,
7076 })
7077 .collect::<Vec<_>>(),
7078 "the composed root binds exactly these, so exactly these must be enumerable — \
7079 the bound set and the granted set must not drift apart",
7080 );
7081 assert!(
7082 grants.iter().all(|grant| grant.list_dir),
7083 "a runtime tree an interpreter walks is useless without getdents64",
7084 );
7085 assert!(
7086 grants.iter().all(|grant| !grant.executable),
7087 "Execute is the exec allowlist: granting it over /usr, /bin and /sbin would let a \
7088 sealed capsule run every binary the host ships, whatever shell.allow says",
7089 );
7090 }
7091
7092 #[test]
7093 fn sealed_runtime_landlock_grants_are_sealed_tier_only() {
7094 for tier in [
7095 EnforcementTier::KernelFull,
7096 EnforcementTier::KernelSeccompOnly,
7097 EnforcementTier::EnvironmentOnly,
7098 ] {
7099 assert!(
7100 resolve_sealed_runtime_landlock_grants(tier).is_empty(),
7101 "{tier:?} has no composed root, so granting ReadDir on SEALED_RUNTIME_PATHS \
7102 would enumerate the real host filesystem",
7103 );
7104 }
7105 }
7106
7107 #[test]
7112 fn shell_enforcement_grants_sealed_runtime_paths_only_when_sealed_applies() {
7113 let policy = CapabilityPolicy::default();
7114 let usr = PathBuf::from("/usr");
7115
7116 let scoped = ShellEnforcement::resolve_declared(
7117 &policy,
7118 murmur_artifact::ContainmentClass::Scoped,
7119 HostProbe::probe(),
7120 )
7121 .expect("an empty policy resolves")
7122 .with_test_session_dirs();
7123 assert!(
7124 !scoped.landlock_grants.iter().any(|grant| grant.path == usr),
7125 "a scoped capsule runs Landlock over the real host filesystem — /usr must stay \
7126 unenumerable there",
7127 );
7128
7129 let sealed = ShellEnforcement::resolve_declared(
7130 &policy,
7131 murmur_artifact::ContainmentClass::Sealed,
7132 HostProbe::probe(),
7133 )
7134 .expect("an empty policy resolves")
7135 .with_test_session_dirs();
7136 let granted: Vec<&LandlockGrant> = sealed
7137 .landlock_grants
7138 .iter()
7139 .filter(|grant| {
7140 crate::sealed::SEALED_RUNTIME_PATHS.contains(&grant.path.to_str().unwrap_or(""))
7141 })
7142 .collect();
7143 if sealed.tier == EnforcementTier::KernelSealed {
7144 assert_eq!(
7145 granted.len(),
7146 crate::sealed::SEALED_RUNTIME_PATHS.len(),
7147 "a sealed session must carry one listable grant per bound runtime path",
7148 );
7149 assert!(granted
7150 .iter()
7151 .all(|grant| grant.list_dir && !grant.executable));
7152 } else {
7153 assert!(
7154 granted.is_empty(),
7155 "this host fell back to {:?}, where there is no composed root to enumerate",
7156 sealed.tier,
7157 );
7158 }
7159 }
7160
7161 #[test]
7162 fn every_sealed_etc_path_gets_a_non_executable_landlock_grant() {
7163 let grants = resolve_sealed_etc_landlock_grants(EnforcementTier::KernelSealed);
7164
7165 assert_eq!(
7166 grants,
7167 crate::sealed::SEALED_ETC_PATHS
7168 .iter()
7169 .map(|entry| LandlockGrant {
7170 path: PathBuf::from(entry.path),
7171 list_dir: entry.directory,
7172 executable: false,
7173 })
7174 .collect::<Vec<_>>(),
7175 "the composed root binds exactly these /etc entries, so exactly these must be \
7176 readable — the bound set and the granted set must not drift apart",
7177 );
7178 assert!(
7179 grants.iter().all(|grant| !grant.executable),
7180 "Execute is the exec allowlist, and nothing in /etc is a program a manifest asked to \
7181 run — /etc/alternatives is a directory of symlinks into /usr/bin",
7182 );
7183
7184 let listable: Vec<&str> = grants
7188 .iter()
7189 .filter(|grant| grant.list_dir)
7190 .map(|grant| grant.path.to_str().unwrap())
7191 .collect();
7192 assert_eq!(
7193 listable,
7194 vec![
7195 "/etc/ld.so.conf.d",
7196 "/etc/alternatives",
7197 "/etc/ssl",
7198 "/etc/pki",
7199 "/etc/ca-certificates",
7200 "/etc/terminfo",
7201 ],
7202 "ReadDir belongs to exactly the directory entries: os.listdir('/etc/ssl/certs') and \
7203 the c_rehash symlink lookup need it, and a regular file has no meaningful ReadDir",
7204 );
7205 }
7206
7207 #[test]
7208 fn sealed_etc_landlock_grants_are_sealed_tier_only() {
7209 for tier in [
7210 EnforcementTier::KernelFull,
7211 EnforcementTier::KernelSeccompOnly,
7212 EnforcementTier::EnvironmentOnly,
7213 ] {
7214 assert!(
7215 resolve_sealed_etc_landlock_grants(tier).is_empty(),
7216 "{tier:?} has no composed root, so these rules would hand out the real host's \
7217 /etc — its trust store, resolv.conf and account databases",
7218 );
7219 }
7220 }
7221
7222 #[test]
7227 fn shell_enforcement_grants_sealed_etc_paths_only_when_sealed_applies() {
7228 let policy = CapabilityPolicy::default();
7229 let is_etc_grant = |grant: &LandlockGrant| {
7230 crate::sealed::SEALED_ETC_PATHS
7231 .iter()
7232 .any(|entry| Path::new(entry.path) == grant.path)
7233 };
7234
7235 let scoped = ShellEnforcement::resolve_declared(
7236 &policy,
7237 murmur_artifact::ContainmentClass::Scoped,
7238 HostProbe::probe(),
7239 )
7240 .expect("an empty policy resolves")
7241 .with_test_session_dirs();
7242 let name_resolution = |grant: &LandlockGrant| {
7245 SCOPED_NAME_RESOLUTION_PATHS
7246 .iter()
7247 .any(|path| Path::new(path) == grant.path)
7248 };
7249 assert!(
7250 !scoped
7251 .landlock_grants
7252 .iter()
7253 .any(|grant| is_etc_grant(grant) && !name_resolution(grant)),
7254 "a scoped capsule runs Landlock over the real host filesystem — /etc/shadow's \
7255 neighbours must not become readable there",
7256 );
7257 assert_eq!(
7258 scoped
7259 .landlock_grants
7260 .iter()
7261 .filter(|grant| name_resolution(grant))
7262 .count(),
7263 SCOPED_NAME_RESOLUTION_PATHS.len()
7264 );
7265
7266 let sealed = ShellEnforcement::resolve_declared(
7267 &policy,
7268 murmur_artifact::ContainmentClass::Sealed,
7269 HostProbe::probe(),
7270 )
7271 .expect("an empty policy resolves")
7272 .with_test_session_dirs();
7273 let granted: Vec<&LandlockGrant> = sealed
7274 .landlock_grants
7275 .iter()
7276 .filter(|grant| is_etc_grant(grant))
7277 .collect();
7278 if sealed.tier == EnforcementTier::KernelSealed {
7279 assert_eq!(
7280 granted.len(),
7281 crate::sealed::SEALED_ETC_PATHS.len(),
7282 "a sealed session must carry one read grant per bound /etc entry",
7283 );
7284 assert!(granted.iter().all(|grant| !grant.executable));
7285 } else {
7286 assert!(
7289 granted
7290 .iter()
7291 .all(|grant| name_resolution(grant) && !grant.executable && !grant.list_dir),
7292 "this host fell back to {:?}, where there is no composed root and /etc is the \
7293 host's own: {granted:?}",
7294 sealed.tier,
7295 );
7296 assert_eq!(
7297 granted.len(),
7298 resolve_scoped_name_resolution_landlock_grants(sealed.tier).len(),
7299 "{:?}: {granted:?}",
7300 sealed.tier,
7301 );
7302 }
7303 }
7304
7305 #[test]
7306 fn resolve_network_allowlist_ips_empty_list_is_empty() {
7307 let ips = resolve_network_allowlist_ips(&[]).unwrap();
7308 assert!(ips.is_empty());
7309 }
7310
7311 #[test]
7312 fn resolve_network_allowlist_ips_resolves_localhost_to_loopback() {
7313 let ips = resolve_network_allowlist_ips(&["localhost".to_string()]).unwrap();
7314 assert!(
7315 !ips.is_empty(),
7316 "localhost should resolve to at least one address"
7317 );
7318 assert!(
7319 ips.iter().all(|ip| ip.is_loopback()),
7320 "every resolved localhost address should be loopback, got {ips:?}"
7321 );
7322 }
7323
7324 #[test]
7325 fn resolve_network_allowlist_ips_rejects_invalid_host_syntax_cleanly() {
7326 let error = resolve_network_allowlist_ips(&["".to_string()]).unwrap_err();
7327 assert!(!error.is_empty());
7328 }
7329
7330 #[test]
7335 fn resolve_network_allowlist_ips_skips_unresolvable_host_without_failing() {
7336 let ips =
7337 resolve_network_allowlist_ips(&["definitely-not-a-real-host.invalid".to_string()])
7338 .expect("an unresolvable host must not be a hard error");
7339 assert!(
7340 ips.is_empty(),
7341 "an unresolvable host should contribute no IPs, got {ips:?}"
7342 );
7343 }
7344
7345 #[test]
7348 fn resolve_network_allowlist_ips_keeps_resolvable_hosts_when_another_is_unresolvable() {
7349 let ips = resolve_network_allowlist_ips(&[
7350 "definitely-not-a-real-host.invalid".to_string(),
7351 "localhost".to_string(),
7352 ])
7353 .expect("a mix of resolvable and unresolvable hosts must not be a hard error");
7354
7355 assert!(
7356 !ips.is_empty(),
7357 "localhost's addresses should survive alongside an unresolvable host"
7358 );
7359 assert!(
7360 ips.iter().all(|ip| ip.is_loopback()),
7361 "only localhost should have contributed addresses, got {ips:?}"
7362 );
7363 }
7364
7365 #[test]
7366 fn resolve_network_allowlist_ips_dedupes_across_hosts() {
7367 let ips =
7370 resolve_network_allowlist_ips(&["localhost".to_string(), "localhost:8080".to_string()])
7371 .unwrap();
7372 let mut sorted = ips.clone();
7373 sorted.sort();
7374 sorted.dedup();
7375 assert_eq!(
7376 ips.len(),
7377 sorted.len(),
7378 "resolved IPs should already be deduplicated"
7379 );
7380 }
7381
7382 #[test]
7383 fn environment_only_has_no_resolved_ips_and_environment_only_tier() {
7384 let enforcement = ShellEnforcement::environment_only();
7385 assert_eq!(enforcement.tier, EnforcementTier::EnvironmentOnly);
7386 assert!(enforcement.network_allow_ips.is_empty());
7387 }
7388
7389 #[cfg(any(target_os = "linux", target_os = "macos"))]
7393 #[test]
7394 fn nproc_bound_without_a_scope_is_headroom_over_a_live_count() {
7395 match ShellEnforcement::environment_only()
7396 .with_test_session_dirs()
7397 .nproc_bound()
7398 {
7399 crate::resources::NprocBound::Headroom { baseline } => assert!(
7400 baseline > 0,
7401 "the test process is owned by this uid, so the count cannot be zero"
7402 ),
7403 other => panic!("no scope must give headroom, got {other:?}"),
7404 }
7405 }
7406
7407 #[cfg(target_os = "linux")]
7410 #[test]
7411 fn nproc_bound_without_a_scope_is_measured_on_each_call() {
7412 let _lock = crate::resources::test_support::host_threads_lock();
7413 let enforcement = ShellEnforcement::environment_only().with_test_session_dirs();
7414 let baseline = |bound| match bound {
7415 crate::resources::NprocBound::Headroom { baseline } => baseline,
7416 other => panic!("no scope must give headroom, got {other:?}"),
7417 };
7418
7419 let first = baseline(enforcement.nproc_bound());
7420 let holders = crate::resources::test_support::HeldThreads::start(200);
7421 let second = baseline(enforcement.nproc_bound());
7422 drop(holders);
7423
7424 assert!(
7425 second > first,
7426 "200 threads started between the calls must raise the baseline: {first} then {second}"
7427 );
7428 }
7429
7430 #[cfg(target_os = "linux")]
7432 #[test]
7433 fn nproc_bound_with_a_scope_leaves_rlimit_nproc_to_pids_max() {
7434 if !crate::cgroup::cgroup_delegation_available() {
7435 crate::runtime_err!(
7436 "[SKIP-HOST] nproc_bound_with_a_scope_leaves_rlimit_nproc_to_pids_max: this host \
7437 cannot delegate a cgroup v2 scope"
7438 );
7439 return;
7440 }
7441 let temp = tempfile::tempdir().unwrap();
7442 let limits = crate::resources::HostResourceLimits::default();
7443 let scope = crate::cgroup::prepare_scope(true, &limits, "ses_nproc_bound", temp.path())
7444 .expect("this host delegates a cgroup scope")
7445 .scope;
7446 let enforcement = ShellEnforcement::environment_only()
7447 .with_test_session_dirs()
7448 .with_host_bounding(scope, None);
7449
7450 assert_eq!(
7451 enforcement.nproc_bound(),
7452 crate::resources::NprocBound::CgroupPidsMax
7453 );
7454 }
7455
7456 #[cfg(target_os = "linux")]
7463 #[test]
7464 fn a_scoped_capsule_keeps_spawning_after_the_host_adds_threads() {
7465 const TEST: &str = "a_scoped_capsule_keeps_spawning_after_the_host_adds_threads";
7466 if !crate::cgroup::cgroup_delegation_available() {
7467 crate::runtime_err!("[SKIP-HOST] {TEST}: this host cannot delegate a cgroup v2 scope");
7468 return;
7469 }
7470 let _lock = crate::resources::test_support::host_threads_lock();
7471
7472 let temp = tempfile::tempdir().unwrap();
7473 let limits = crate::resources::HostResourceLimits {
7474 max_processes: 32,
7475 max_open_files: 256,
7476 ..crate::resources::HostResourceLimits::default()
7477 };
7478 let scope = crate::cgroup::prepare_scope(true, &limits, "ses_nproc_race", temp.path())
7479 .expect("this host delegates a cgroup scope")
7480 .scope;
7481 assert!(
7482 scope.is_some(),
7483 "a required scope on Linux is always created"
7484 );
7485 let node_available = crate::sandbox::resolve_invoked_binary_path("node") != "node";
7486 let mut shell_allow = vec!["bash".to_string()];
7487 if node_available {
7488 shell_allow.push("node".to_string());
7489 }
7490 let policy = CapabilityPolicy {
7491 shell_allow,
7492 resources: limits,
7493 ..CapabilityPolicy::default()
7494 };
7495 let mut enforcement = ShellEnforcement::environment_only().with_test_session_dirs();
7496 enforcement.resource_limits = limits;
7497 let enforcement = enforcement.with_host_bounding(scope, None);
7498
7499 let holders = crate::resources::test_support::HeldThreads::start(400);
7500
7501 let result = crate::shell::execute_shell(
7502 "bash",
7503 &[
7504 "-c",
7505 "for i in $(seq 1 40); do sleep 0.2 & done; wait; ulimit -Hu; ulimit -Hn",
7506 ],
7507 &[],
7508 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
7509 &policy,
7510 &enforcement,
7511 )
7512 .expect("bash must run");
7513 assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
7514 assert_eq!(result.resource_limit_hit, None);
7515 let (_, own_hard) = crate::resources::test_support::own_rlimit_nproc();
7516 let lines: Vec<&str> = result.stdout.lines().collect();
7517 assert_eq!(
7518 lines,
7519 [
7520 crate::resources::test_support::rendered_rlimit(own_hard).as_str(),
7521 "256"
7522 ],
7523 "RLIMIT_NPROC is inherited unchanged and RLIMIT_NOFILE is the declared ceiling",
7524 );
7525
7526 if node_available {
7527 let script = "const { spawn } = require('child_process');\n\
7528 const runs = Array.from({ length: 8 }, () => new Promise((resolve) => {\n\
7529 const child = spawn(process.execPath, ['-e', 'process.exit(0)']);\n\
7530 child.once('error', (error) => resolve(error.message));\n\
7531 child.once('exit', (code) => resolve(code === 0 ? null : `exit ${code}`));\n\
7532 }));\n\
7533 Promise.all(runs).then((outcomes) => {\n\
7534 const failures = outcomes.filter((outcome) => outcome !== null);\n\
7535 if (failures.length > 0) { console.error(failures.join('\\n')); process.exit(1); }\n\
7536 });\n";
7537 let result = crate::shell::execute_shell(
7538 "node",
7539 &["-e", script],
7540 &[],
7541 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
7542 &policy,
7543 &enforcement,
7544 )
7545 .expect("node must run");
7546 assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
7547 } else {
7548 crate::runtime_err!("[SKIP-HOST] {TEST}: no node on PATH, skipping the node half");
7549 }
7550
7551 drop(holders);
7552 }
7553
7554 #[cfg(target_os = "linux")]
7558 #[test]
7559 fn without_a_scope_the_nproc_baseline_is_measured_per_spawn() {
7560 const TEST: &str = "without_a_scope_the_nproc_baseline_is_measured_per_spawn";
7561 let _lock = crate::resources::test_support::host_threads_lock();
7562
7563 let temp = tempfile::tempdir().unwrap();
7564 let policy = CapabilityPolicy {
7565 shell_allow: vec!["bash".to_string()],
7566 resources: crate::resources::HostResourceLimits {
7567 max_processes: 64,
7568 ..crate::resources::HostResourceLimits::default()
7569 },
7570 ..CapabilityPolicy::default()
7571 };
7572 let mut enforcement = ShellEnforcement::environment_only().with_test_session_dirs();
7573 enforcement.resource_limits = policy.resources;
7574
7575 let before = crate::resources::uid_task_count().expect("Linux can count the uid's tasks");
7576 let (_, own_hard) = crate::resources::test_support::own_rlimit_nproc();
7577 if own_hard != libc::RLIM_INFINITY && own_hard < before + 664 {
7578 crate::runtime_err!(
7579 "[SKIP-HOST] {TEST}: the inherited hard RLIMIT_NPROC is {own_hard}, too close to \
7580 the uid's {before} tasks for 300 more threads to show in a child's ceiling"
7581 );
7582 return;
7583 }
7584
7585 let hard_nproc = || -> u64 {
7586 let result = crate::shell::execute_shell(
7587 "bash",
7588 &["-c", "ulimit -Hu"],
7589 &[],
7590 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
7591 &policy,
7592 &enforcement,
7593 )
7594 .expect("bash must run");
7595 assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
7596 result.stdout.trim().parse().expect("a finite ceiling")
7597 };
7598
7599 let holders = crate::resources::test_support::HeldThreads::start(300);
7600 let held = hard_nproc();
7601 assert!(
7602 held >= before + 64 + 150,
7603 "the ceiling must count the holder threads started after the enforcement was \
7604 resolved: {held} with {before} tasks before them"
7605 );
7606
7607 drop(holders);
7608 let released = hard_nproc();
7609 assert!(
7610 released < held,
7611 "the ceiling must drop once the holders exit: {released} after, {held} while held"
7612 );
7613 }
7614
7615 fn make_executable(path: &Path, contents: &str) {
7616 use std::os::unix::fs::PermissionsExt;
7617 std::fs::write(path, contents).unwrap();
7618 let mut perms = std::fs::metadata(path).unwrap().permissions();
7619 perms.set_mode(0o755);
7620 std::fs::set_permissions(path, perms).unwrap();
7621 }
7622
7623 fn bootstrap_log_contents(workdir: &Path) -> String {
7624 std::fs::read_to_string(workdir.join("logs").join("bootstrap.log")).unwrap_or_default()
7625 }
7626
7627 #[test]
7630 fn resolve_exec_allowlist_resolves_bare_name_via_path_dirs() {
7631 let temp = tempfile::tempdir().unwrap();
7632 let bin_dir = temp.path().join("bin");
7633 std::fs::create_dir(&bin_dir).unwrap();
7634 let tool = bin_dir.join("mytool");
7635 make_executable(&tool, "#!/bin/sh\nexit 0\n");
7636
7637 let resolved = resolve_exec_allowlist_in(&["mytool".to_string()], &[bin_dir]);
7638 assert_eq!(resolved, vec![std::fs::canonicalize(&tool).unwrap()]);
7639 }
7640
7641 #[test]
7642 fn resolve_exec_allowlist_skips_entries_that_do_not_resolve() {
7643 let temp = tempfile::tempdir().unwrap();
7644 let resolved = resolve_exec_allowlist_in(
7645 &["definitely-not-a-real-binary".to_string(), String::new()],
7646 &[temp.path().to_path_buf()],
7647 );
7648 assert!(
7649 resolved.is_empty(),
7650 "unresolvable entries must shrink (not fail) the allow set: {resolved:?}"
7651 );
7652 }
7653
7654 #[test]
7655 fn resolve_exec_allowlist_ignores_non_executable_path_matches() {
7656 let temp = tempfile::tempdir().unwrap();
7657 std::fs::write(temp.path().join("mytool"), "not executable").unwrap();
7658 let resolved =
7659 resolve_exec_allowlist_in(&["mytool".to_string()], &[temp.path().to_path_buf()]);
7660 assert!(resolved.is_empty());
7661 }
7662
7663 #[test]
7664 fn resolve_exec_allowlist_canonicalizes_path_matches_through_symlinks() {
7665 let temp = tempfile::tempdir().unwrap();
7666 let real = temp.path().join("real-tool");
7667 make_executable(&real, "#!/bin/sh\nexit 0\n");
7668 let bin_dir = temp.path().join("bin");
7669 std::fs::create_dir(&bin_dir).unwrap();
7670 std::os::unix::fs::symlink(&real, bin_dir.join("link-tool")).unwrap();
7671
7672 let resolved = resolve_exec_allowlist_in(&["link-tool".to_string()], &[bin_dir]);
7673 assert_eq!(resolved, vec![std::fs::canonicalize(&real).unwrap()]);
7674 }
7675
7676 #[test]
7679 fn resolve_invoked_binary_path_resolves_bare_name_via_path_dirs() {
7680 let temp = tempfile::tempdir().unwrap();
7681 let bin_dir = temp.path().join("bin");
7682 std::fs::create_dir(&bin_dir).unwrap();
7683 let tool = bin_dir.join("mytool");
7684 make_executable(&tool, "#!/bin/sh\nexit 0\n");
7685
7686 let resolved = resolve_invoked_binary_path_in("mytool", &[bin_dir]);
7687 assert_eq!(
7688 resolved,
7689 std::fs::canonicalize(&tool)
7690 .unwrap()
7691 .to_string_lossy()
7692 .into_owned(),
7693 "a resolvable name must report the canonical absolute path, not the bare name"
7694 );
7695 }
7696
7697 #[test]
7698 fn resolve_invoked_binary_path_falls_back_to_bare_name_when_unresolvable() {
7699 let temp = tempfile::tempdir().unwrap();
7700 let path_dirs = [temp.path().to_path_buf()];
7701 let resolved = resolve_invoked_binary_path_in("definitely-not-a-real-binary", &path_dirs);
7702 assert_eq!(
7703 resolved, "definitely-not-a-real-binary",
7704 "an unresolvable name must fall back to the bare name, never error"
7705 );
7706 assert_eq!(resolve_invoked_binary_path_in("", &[]), "");
7707 }
7708
7709 #[test]
7710 fn resolve_invoked_binary_path_takes_the_first_path_match() {
7711 let temp = tempfile::tempdir().unwrap();
7712 let first = temp.path().join("first");
7713 let second = temp.path().join("second");
7714 std::fs::create_dir(&first).unwrap();
7715 std::fs::create_dir(&second).unwrap();
7716 make_executable(&first.join("mytool"), "#!/bin/sh\nexit 0\n");
7717 make_executable(&second.join("mytool"), "#!/bin/sh\nexit 1\n");
7718
7719 let resolved = resolve_invoked_binary_path_in("mytool", &[first.clone(), second]);
7720 assert_eq!(
7721 resolved,
7722 std::fs::canonicalize(first.join("mytool"))
7723 .unwrap()
7724 .to_string_lossy()
7725 .into_owned(),
7726 "reporting must match execvp: the first PATH hit is the one that ran"
7727 );
7728 }
7729
7730 #[test]
7731 fn resolve_invoked_binary_path_ignores_non_executable_path_matches() {
7732 let temp = tempfile::tempdir().unwrap();
7733 std::fs::write(temp.path().join("mytool"), "not executable").unwrap();
7734 assert_eq!(
7735 resolve_invoked_binary_path_in("mytool", &[temp.path().to_path_buf()]),
7736 "mytool"
7737 );
7738 }
7739
7740 #[test]
7741 fn resolve_invoked_binary_path_canonicalizes_a_path_entry_through_symlinks() {
7742 let temp = tempfile::tempdir().unwrap();
7743 let real = temp.path().join("real-tool");
7744 make_executable(&real, "#!/bin/sh\nexit 0\n");
7745 let link = temp.path().join("link-tool");
7746 std::os::unix::fs::symlink(&real, &link).unwrap();
7747
7748 assert_eq!(
7749 resolve_invoked_binary_path_in(&link.to_string_lossy(), &[]),
7750 std::fs::canonicalize(&real)
7751 .unwrap()
7752 .to_string_lossy()
7753 .into_owned()
7754 );
7755 }
7756
7757 #[test]
7760 fn parse_elf_extracts_interp_needed_and_runpath() {
7761 let bytes = build_elf64(
7762 Some("/lib64/ld-linux-x86-64.so.2"),
7763 &["libc.so.6", "libm.so.6"],
7764 Some("$ORIGIN/../lib:/opt/custom/lib"),
7765 );
7766 let deps = parse_elf_dependencies(bytes.as_slice()).expect("well-formed ELF64 must parse");
7767 assert_eq!(deps.interp.as_deref(), Some("/lib64/ld-linux-x86-64.so.2"));
7768 assert_eq!(
7769 deps.needed,
7770 vec!["libc.so.6".to_string(), "libm.so.6".to_string()]
7771 );
7772 assert_eq!(
7773 deps.runpaths,
7774 vec!["$ORIGIN/../lib".to_string(), "/opt/custom/lib".to_string()],
7775 "DT_RUNPATH must be split on ':' with empty parts dropped"
7776 );
7777 }
7778
7779 #[test]
7780 fn parse_elf_static_binary_contributes_only_itself() {
7781 let bytes = build_elf64(None, &[], None);
7783 let deps =
7784 parse_elf_dependencies(bytes.as_slice()).expect("a static ELF64 is still valid ELF");
7785 assert_eq!(deps, ElfDependencies::default());
7786 }
7787
7788 #[test]
7789 fn parse_elf_rejects_non_elf_and_non_elf64() {
7790 assert!(parse_elf_dependencies(&b"not an elf at all, way too short"[..]).is_none());
7791 let mut elf32 = build_elf64(Some("/lib/ld.so"), &["libc.so.6"], None);
7793 elf32[4] = 1;
7794 assert!(
7795 parse_elf_dependencies(elf32.as_slice()).is_none(),
7796 "32-bit ELF is not a binary this runtime execs and must not parse"
7797 );
7798 }
7799
7800 #[cfg(target_os = "linux")]
7804 #[test]
7805 #[allow(clippy::print_stdout)]
7806 fn a_file_read_by_position_parses_exactly_as_its_bytes_in_memory() {
7807 let mut binaries = Vec::new();
7808 for name in ["python3", "perl", "bash", "git"] {
7809 match find_on_path(name) {
7810 Some(path) => binaries.push(std::fs::canonicalize(path).unwrap()),
7811 None => println!("[SKIP] no {name} on PATH"),
7812 }
7813 }
7814 let mut images: std::collections::BTreeSet<PathBuf> =
7815 resolve_landlock_grants(&binaries).into_iter().collect();
7816 let dynload = std::process::Command::new("python3")
7817 .args(["-I", "-S", "-c", "import sys; print('\\n'.join(sys.path))"])
7818 .output()
7819 .ok()
7820 .map(|output| String::from_utf8_lossy(&output.stdout).into_owned())
7821 .unwrap_or_default();
7822 for dir in dynload.lines().filter(|line| line.ends_with("lib-dynload")) {
7823 for entry in std::fs::read_dir(dir).into_iter().flatten().flatten() {
7824 images.insert(entry.path());
7825 }
7826 }
7827 let compare = |path: &Path| {
7828 let Ok(bytes) = std::fs::read(path) else {
7829 return;
7830 };
7831 let file = ElfFile::open_host(path).unwrap();
7832 assert_eq!(
7833 parse_elf_dependencies(bytes.as_slice()),
7834 parse_elf_dependencies(&file),
7835 "{}",
7836 path.display()
7837 );
7838 assert_eq!(
7839 elf_dynamic_symbol_names(bytes.as_slice()),
7840 elf_dynamic_symbol_names(&file),
7841 "{}",
7842 path.display()
7843 );
7844 assert_eq!(
7845 elf_is_shared_object(bytes.as_slice()),
7846 elf_is_shared_object(&file),
7847 "{}",
7848 path.display()
7849 );
7850 };
7851 for path in &images {
7852 compare(path);
7853 }
7854 println!("compared {} host images", images.len());
7855
7856 let temp = tempfile::tempdir().unwrap();
7859 let mut variants = 0;
7860 for binary in &binaries {
7861 let bytes = std::fs::read(binary).unwrap();
7862 let mut cuts = vec![
7863 0,
7864 1,
7865 17,
7866 18,
7867 63,
7868 64,
7869 100,
7870 4096,
7871 bytes.len() / 2,
7872 bytes.len() - 1,
7873 ];
7874 for field in [32usize, 40] {
7875 if let Some(offset) = read_u64(&bytes, field, true) {
7876 cuts.push(offset as usize + 30);
7877 }
7878 }
7879 let mut copies: Vec<Vec<u8>> = cuts
7880 .into_iter()
7881 .filter(|cut| *cut <= bytes.len())
7882 .map(|cut| bytes[..cut].to_vec())
7883 .collect();
7884 for field in [32usize, 40] {
7885 let mut moved = bytes.clone();
7886 moved[field..field + 8].copy_from_slice(&(bytes.len() as u64 - 8).to_le_bytes());
7887 copies.push(moved);
7888 }
7889 let mut big_endian = bytes.clone();
7890 big_endian[5] = 2;
7891 copies.push(big_endian);
7892 for copy in copies {
7893 let path = temp.path().join(format!("variant-{variants}"));
7894 std::fs::write(&path, ©).unwrap();
7895 compare(&path);
7896 variants += 1;
7897 }
7898 }
7899 println!("compared {variants} malformed images");
7900 }
7901
7902 #[cfg(target_os = "linux")]
7905 #[test]
7906 #[allow(clippy::print_stdout)]
7907 fn resolving_a_closure_reads_no_binary_in_full() {
7908 let (_temp, root) = crate::interpreter_runtime::tests::canonical_tempdir();
7909 let Some(stand_in) = crate::interpreter_runtime::tests::dash_copy(&root, "padded") else {
7910 return;
7911 };
7912 crate::interpreter_runtime::tests::pad_with_zeros(&stand_in, 64 << 20);
7913 let unpadded = resolve_landlock_grants(&[std::fs::canonicalize("/bin/dash").unwrap()]);
7914 let before = crate::interpreter_runtime::tests::thread_rchar();
7915 let grants = resolve_landlock_grants(std::slice::from_ref(&stand_in));
7916 let read = crate::interpreter_runtime::tests::thread_rchar() - before;
7917 assert_eq!(grants.len(), unpadded.len(), "{grants:?}");
7918 assert!(grants.contains(&stand_in));
7919 assert!(
7920 read < 8 * 1024 * 1024,
7921 "resolving the closure read {read} bytes"
7922 );
7923 println!("resolving the padded stand-in's closure read {read} bytes");
7924 }
7925
7926 #[test]
7929 fn resolve_soname_finds_via_search_dirs_and_runpath_origin() {
7930 let temp = tempfile::tempdir().unwrap();
7931 let origin = temp.path().join("app");
7932 std::fs::create_dir(&origin).unwrap();
7933 let libs = temp.path().join("libs");
7934 std::fs::create_dir(&libs).unwrap();
7935 std::fs::write(libs.join("libc.so.6"), "x").unwrap();
7936
7937 assert_eq!(
7939 resolve_soname("libc.so.6", &origin, &[], std::slice::from_ref(&libs)),
7940 Some(std::fs::canonicalize(libs.join("libc.so.6")).unwrap())
7941 );
7942 assert_eq!(
7944 resolve_soname("libmissing.so", &origin, &[], std::slice::from_ref(&libs)),
7945 None
7946 );
7947
7948 let origin_lib = origin.join("lib");
7950 std::fs::create_dir(&origin_lib).unwrap();
7951 std::fs::write(origin_lib.join("libfoo.so"), "x").unwrap();
7952 assert_eq!(
7953 resolve_soname("libfoo.so", &origin, &["$ORIGIN/lib".to_string()], &[]),
7954 Some(std::fs::canonicalize(origin_lib.join("libfoo.so")).unwrap())
7955 );
7956 }
7957
7958 #[test]
7961 fn resolve_landlock_grants_derives_interp_and_needed_closure() {
7962 let temp = tempfile::tempdir().unwrap();
7963 let bin_dir = temp.path().join("bin");
7964 let lib_dir = temp.path().join("lib");
7965 std::fs::create_dir(&bin_dir).unwrap();
7966 std::fs::create_dir(&lib_dir).unwrap();
7967
7968 let interp = bin_dir.join("ld-fake.so");
7970 std::fs::write(&interp, "not really elf").unwrap();
7971 std::fs::write(lib_dir.join("libfake.so.1"), "not really elf").unwrap();
7972
7973 let prog = bin_dir.join("prog");
7975 let elf = build_elf64(Some(interp.to_str().unwrap()), &["libfake.so.1"], None);
7976 std::fs::write(&prog, &elf).unwrap();
7977
7978 let grants =
7979 resolve_landlock_grants_in(std::slice::from_ref(&prog), std::slice::from_ref(&lib_dir));
7980
7981 let expect = |p: PathBuf| std::fs::canonicalize(p).unwrap();
7982 assert!(
7983 grants.contains(&expect(prog)),
7984 "the binary itself must be granted: {grants:?}"
7985 );
7986 assert!(
7987 grants.contains(&expect(interp)),
7988 "the ELF interpreter must be granted: {grants:?}"
7989 );
7990 assert!(
7991 grants.contains(&expect(lib_dir.join("libfake.so.1"))),
7992 "each resolved DT_NEEDED library must be granted: {grants:?}"
7993 );
7994 }
7995
7996 #[test]
7997 fn resolve_landlock_grants_skips_unresolvable_deps_and_missing_binaries() {
7998 let temp = tempfile::tempdir().unwrap();
7999 let bin_dir = temp.path().join("bin");
8000 std::fs::create_dir(&bin_dir).unwrap();
8001
8002 let prog = bin_dir.join("prog");
8004 std::fs::write(
8005 &prog,
8006 build_elf64(Some("/definitely/not/here.so"), &["libnope.so"], None),
8007 )
8008 .unwrap();
8009 let missing = bin_dir.join("does-not-exist");
8010
8011 let grants =
8012 resolve_landlock_grants_in(&[prog.clone(), missing], std::slice::from_ref(&bin_dir));
8013
8014 assert_eq!(grants, vec![std::fs::canonicalize(&prog).unwrap()]);
8017 }
8018
8019 fn interp_grant(binary: &str, dirs: &[(&str, bool)]) -> InterpreterRuntimeGrant {
8022 InterpreterRuntimeGrant {
8023 binary: binary.to_string(),
8024 dirs: dirs
8025 .iter()
8026 .map(|(path, list_dir)| murmur_artifact::InterpreterRuntimeDir {
8027 path: path.to_string(),
8028 list_dir: *list_dir,
8029 })
8030 .collect(),
8031 }
8032 }
8033
8034 #[test]
8035 fn resolve_interpreter_runtime_grants_copies_list_dir_verbatim() {
8036 let grants = resolve_interpreter_runtime_grants(&[interp_grant(
8039 "python3",
8040 &[
8041 ("/usr/lib/python3.11", true),
8042 ("/usr/lib/python3.11/lib-dynload", false),
8043 ],
8044 )]);
8045
8046 assert_eq!(
8047 grants,
8048 vec![
8049 LandlockGrant {
8050 path: PathBuf::from("/usr/lib/python3.11"),
8051 list_dir: true,
8052 executable: true,
8053 },
8054 LandlockGrant {
8055 path: PathBuf::from("/usr/lib/python3.11/lib-dynload"),
8056 list_dir: false,
8057 executable: true,
8058 },
8059 ]
8060 );
8061 }
8062
8063 #[test]
8064 fn resolve_interpreter_runtime_grants_flattens_multiple_grants_and_needs_no_existing_path() {
8065 let grants = resolve_interpreter_runtime_grants(&[
8068 interp_grant("python3", &[("/opt/py/nonexistent", false)]),
8069 interp_grant("ruby", &[("/opt/rb/also-nonexistent", true)]),
8070 ]);
8071
8072 assert_eq!(
8073 grants,
8074 vec![
8075 LandlockGrant {
8076 path: PathBuf::from("/opt/py/nonexistent"),
8077 list_dir: false,
8078 executable: true,
8079 },
8080 LandlockGrant {
8081 path: PathBuf::from("/opt/rb/also-nonexistent"),
8082 list_dir: true,
8083 executable: true,
8084 },
8085 ]
8086 );
8087 assert!(resolve_interpreter_runtime_grants(&[]).is_empty());
8088 }
8089
8090 #[test]
8091 fn non_listable_files_marks_every_closure_file_non_enumerable() {
8092 let wrapped = LandlockGrant::non_listable_files(vec![
8095 PathBuf::from("/lib/x86_64-linux-gnu/libc.so.6"),
8096 PathBuf::from("/usr/bin/bash"),
8097 ]);
8098 assert!(wrapped.iter().all(|grant| !grant.list_dir), "{wrapped:?}");
8099 assert_eq!(
8100 wrapped.iter().map(|g| g.path.clone()).collect::<Vec<_>>(),
8101 vec![
8102 PathBuf::from("/lib/x86_64-linux-gnu/libc.so.6"),
8103 PathBuf::from("/usr/bin/bash"),
8104 ]
8105 );
8106 }
8107
8108 #[test]
8122 fn capsule_device_grants_are_exactly_three_devices_with_only_dev_null_writable() {
8123 let listed: Vec<(&str, bool)> = CAPSULE_DEVICE_GRANTS
8124 .iter()
8125 .map(|device| (device.path, device.writable))
8126 .collect();
8127 assert_eq!(
8128 listed,
8129 vec![
8130 ("/dev/null", true),
8131 ("/dev/zero", false),
8132 ("/dev/urandom", false),
8133 ],
8134 "the capsule device set is fixed at exactly these three paths and these access \
8135 levels. /dev/null must stay writable — Python's subprocess.DEVNULL opens it O_RDWR \
8136 and a shell `2>/dev/null` redirect opens it O_WRONLY, so a read-only grant breaks \
8137 both. Widening this list is allowed only on demonstrated workload failure, and the \
8138 failure gets recorded alongside the widening (see the manual acceptance procedure on \
8139 the diagnostics reference page)."
8140 );
8141 }
8142
8143 #[test]
8144 fn capsule_device_grants_exclude_the_devices_deliberately_left_out() {
8145 for excluded in [
8150 "/dev/random",
8151 "/dev/full",
8152 "/dev/tty",
8153 "/dev/console",
8154 "/dev/mem",
8155 "/dev/sda",
8156 "/dev",
8157 ] {
8158 assert!(
8159 !CAPSULE_DEVICE_GRANTS
8160 .iter()
8161 .any(|device| device.path == excluded),
8162 "{excluded} must not be in the capsule device set — it is denied by the ordinary \
8163 no-matching-rule path, and adding it here would be the only way to change that"
8164 );
8165 }
8166 }
8167
8168 #[test]
8169 fn capsule_device_grants_name_absolute_paths_under_dev() {
8170 for device in CAPSULE_DEVICE_GRANTS {
8173 assert!(
8174 device.path.starts_with("/dev/"),
8175 "{} must be an absolute path under /dev",
8176 device.path
8177 );
8178 }
8179 }
8180
8181 #[test]
8189 fn denied_socket_domains_denies_unix_by_default() {
8190 let denied = denied_socket_domains(false);
8191 assert!(
8192 denied.contains(&LINUX_AF_UNIX),
8193 "AF_UNIX must be denied when the manifest does not declare \
8194 capabilities.network.unix_sockets — this is the /var/run/docker.sock escape"
8195 );
8196 }
8197
8198 #[test]
8199 fn denied_socket_domains_omits_unix_only_when_explicitly_allowed() {
8200 let denied = denied_socket_domains(true);
8201 assert!(
8202 !denied.contains(&LINUX_AF_UNIX),
8203 "a capsule that declared capabilities.network.unix_sockets: true must get no \
8204 AF_UNIX deny rule at all"
8205 );
8206 }
8207
8208 #[test]
8211 fn denied_socket_domains_always_denies_netlink_and_packet() {
8212 for unix_sockets_allowed in [false, true] {
8213 let denied = denied_socket_domains(unix_sockets_allowed);
8214 assert!(
8215 denied.contains(&LINUX_AF_NETLINK),
8216 "AF_NETLINK must be denied unconditionally (unix_sockets_allowed = \
8217 {unix_sockets_allowed})"
8218 );
8219 assert!(
8220 denied.contains(&LINUX_AF_PACKET),
8221 "AF_PACKET must be denied unconditionally (unix_sockets_allowed = \
8222 {unix_sockets_allowed})"
8223 );
8224 }
8225 }
8226
8227 #[test]
8231 fn denied_socket_domains_never_denies_ip_families() {
8232 for unix_sockets_allowed in [false, true] {
8233 let denied = denied_socket_domains(unix_sockets_allowed);
8234 assert!(!denied.contains(&LINUX_AF_INET_DOMAIN));
8235 assert!(!denied.contains(&LINUX_AF_INET6_DOMAIN));
8236 }
8237 }
8238
8239 #[test]
8242 fn denied_socket_domains_returns_exactly_the_documented_sets() {
8243 assert_eq!(
8244 denied_socket_domains(false),
8245 vec![LINUX_AF_NETLINK, LINUX_AF_PACKET, LINUX_AF_UNIX]
8246 );
8247 assert_eq!(
8248 denied_socket_domains(true),
8249 vec![LINUX_AF_NETLINK, LINUX_AF_PACKET]
8250 );
8251 }
8252
8253 #[cfg(target_os = "linux")]
8259 #[test]
8260 fn linux_socket_domain_constants_match_libc() {
8261 assert_eq!(LINUX_AF_UNIX, libc::AF_UNIX);
8262 assert_eq!(LINUX_AF_NETLINK, libc::AF_NETLINK);
8263 assert_eq!(LINUX_AF_PACKET, libc::AF_PACKET);
8264 assert_eq!(LINUX_AF_INET_DOMAIN, libc::AF_INET);
8265 assert_eq!(LINUX_AF_INET6_DOMAIN, libc::AF_INET6);
8266 }
8267
8268 #[test]
8273 fn socket_domain_allow_and_deny_sets_never_overlap() {
8274 for unix_sockets_allowed in [false, true] {
8275 let denied = denied_socket_domains(unix_sockets_allowed);
8276 for domain in allowed_socket_domains(unix_sockets_allowed) {
8277 assert!(
8278 !denied.contains(&domain),
8279 "socket domain {domain} is both allowed and denied \
8280 (unix_sockets_allowed = {unix_sockets_allowed})"
8281 );
8282 }
8283 }
8284 }
8285
8286 #[test]
8291 fn allowed_socket_domains_takes_unix_back_exactly_when_declared() {
8292 assert_eq!(
8293 allowed_socket_domains(false),
8294 vec![LINUX_AF_INET_DOMAIN, LINUX_AF_INET6_DOMAIN]
8295 );
8296 assert_eq!(
8297 allowed_socket_domains(true),
8298 vec![LINUX_AF_INET_DOMAIN, LINUX_AF_INET6_DOMAIN, LINUX_AF_UNIX]
8299 );
8300 }
8301
8302 #[test]
8310 fn allowlist_contains_no_syscall_that_must_stay_denied() {
8311 for name in SECCOMP_MUST_STAY_DENIED {
8312 assert!(
8313 !SECCOMP_SYSCALL_ALLOWLIST.contains(name),
8314 "{name} is in SECCOMP_MUST_STAY_DENIED but was added to \
8315 SECCOMP_SYSCALL_ALLOWLIST — if this is deliberate, remove it from the deny list \
8316 in the same edit and say why in the commit"
8317 );
8318 }
8319 }
8320
8321 #[test]
8326 fn allowlist_permits_exec_because_landlock_now_decides_it() {
8327 for name in ["execve", "execveat"] {
8328 assert!(
8329 SECCOMP_SYSCALL_ALLOWLIST.contains(&name),
8330 "{name} must reach the kernel — the default action is a deny, so omitting it \
8331 refuses the child's own exec and nothing runs"
8332 );
8333 }
8334 }
8335
8336 #[test]
8341 fn allowlist_excludes_socket_which_is_ruled_on_by_domain() {
8342 assert!(
8343 !SECCOMP_SYSCALL_ALLOWLIST.contains(&"socket"),
8344 "socket must be permitted per-domain by allowed_socket_domains, never unconditionally"
8345 );
8346 }
8347
8348 #[test]
8351 fn allowlist_has_no_duplicates() {
8352 let mut seen = std::collections::HashSet::new();
8353 for name in SECCOMP_SYSCALL_ALLOWLIST {
8354 assert!(seen.insert(*name), "{name} appears twice in the allowlist");
8355 }
8356 }
8357
8358 #[test]
8361 fn allowlist_covers_the_syscalls_no_process_can_run_without() {
8362 for name in [
8363 "read",
8364 "write",
8365 "openat",
8366 "close",
8367 "mmap",
8368 "munmap",
8369 "brk",
8370 "exit_group",
8371 "futex",
8372 "rt_sigreturn",
8373 "clone",
8374 "wait4",
8375 "getpid",
8376 "getdents64",
8377 ] {
8378 assert!(
8379 SECCOMP_SYSCALL_ALLOWLIST.contains(&name),
8380 "{name} is missing from the allowlist — no process could run under this filter"
8381 );
8382 }
8383 }
8384
8385 #[test]
8386 fn network_ip_allowed_matches_only_the_resolved_allowlist() {
8387 let allow = vec![IpAddr::from([127, 0, 0, 1])];
8388 assert!(network_ip_allowed(IpAddr::from([127, 0, 0, 1]), &allow));
8389 assert!(!network_ip_allowed(IpAddr::from([8, 8, 8, 8]), &allow));
8390 assert!(
8391 !network_ip_allowed(IpAddr::from([127, 0, 0, 1]), &[]),
8392 "an empty resolved allowlist must deny every destination"
8393 );
8394 }
8395
8396 #[test]
8399 fn tier_warning_kernel_full_warns_when_a_shell_is_declared() {
8400 assert_eq!(
8404 tier_warning(EnforcementTier::KernelFull, false),
8405 Some((W_SEC_005, KERNEL_ENFORCEMENT_NOTICE))
8406 );
8407 assert_eq!(tier_warning(EnforcementTier::KernelFull, true), None);
8408 }
8409
8410 #[test]
8414 fn tier_warning_kernel_sealed_states_what_proc_is() {
8415 let (code, notice) = tier_warning(EnforcementTier::KernelSealed, false).unwrap();
8416 assert_eq!(code, W_SEC_005);
8417 assert!(!notice.contains("no PID namespace"), "{notice}");
8418 assert!(notice.contains("own PID namespace"), "{notice}");
8419 assert!(notice.contains("private procfs"), "{notice}");
8420 assert!(notice.contains("no host process appears"), "{notice}");
8421 assert!(
8422 notice.contains("Landlock refuses every open beneath it"),
8423 "{notice}"
8424 );
8425 assert!(notice.contains("stat(2) on /proc/<pid>"), "{notice}");
8426 assert!(!notice.contains("any process of the same user"), "{notice}");
8427 assert_eq!(tier_warning(EnforcementTier::KernelSealed, true), None);
8428 }
8429
8430 #[test]
8431 fn tier_warning_all_tiers_warn_only_when_shell_allow_is_nonempty() {
8432 assert_eq!(
8433 tier_warning(EnforcementTier::KernelSeccompOnly, false),
8434 Some((W_SEC_002, SECCOMP_ONLY_WARNING))
8435 );
8436 assert_eq!(
8437 tier_warning(EnforcementTier::EnvironmentOnly, false),
8438 Some((W_SEC_001, ENVIRONMENT_ONLY_WARNING))
8439 );
8440 assert_eq!(tier_warning(EnforcementTier::KernelFull, true), None);
8441 assert_eq!(tier_warning(EnforcementTier::KernelSeccompOnly, true), None);
8442 assert_eq!(tier_warning(EnforcementTier::EnvironmentOnly, true), None);
8443 }
8444
8445 #[test]
8450 fn aggregate_bounding_warns_only_off_linux_and_only_with_a_subprocess_route() {
8451 assert_eq!(
8452 aggregate_bounding_warning(false, true, false),
8453 Some((W_SEC_010, NO_AGGREGATE_BOUNDING_WARNING))
8454 );
8455 assert_eq!(
8456 aggregate_bounding_warning(false, false, false),
8457 None,
8458 "a capsule that cannot spawn a subprocess has nothing to bound"
8459 );
8460 assert_eq!(
8461 aggregate_bounding_warning(true, true, false),
8462 None,
8463 "on Linux this case is a refused launch, not a warning"
8464 );
8465 assert_eq!(
8466 aggregate_bounding_warning(true, true, true),
8467 None,
8468 "a scope exists, so there is no gap"
8469 );
8470 }
8471
8472 #[test]
8476 fn aggregate_bounding_warning_states_why_rlimit_nproc_is_insufficient() {
8477 let (_, message) = aggregate_bounding_warning(false, true, false).unwrap();
8478 assert!(message.contains("per-uid"), "message was: {message}");
8479 assert!(message.contains("fork bomb"), "message was: {message}");
8480 assert!(message.contains("pids.max"), "message was: {message}");
8481 }
8482
8483 #[test]
8484 fn warn_for_missing_aggregate_bounding_writes_the_code_and_link_to_bootstrap_log() {
8485 let temp = tempfile::tempdir().unwrap();
8486 warn_for_missing_aggregate_bounding(
8487 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
8488 true,
8489 false,
8490 );
8491 let log = bootstrap_log_contents(temp.path());
8492
8493 if cfg!(target_os = "linux") {
8494 assert!(
8495 log.is_empty(),
8496 "Linux refuses the launch instead of warning: {log}"
8497 );
8498 } else {
8499 assert!(log.contains(W_SEC_010), "log was: {log}");
8500 assert!(
8501 log.contains(&security_warning_link(W_SEC_010)),
8502 "log was: {log}"
8503 );
8504 }
8505 }
8506
8507 fn warning_test_policy() -> CapabilityPolicy {
8508 CapabilityPolicy {
8509 shell_allow: vec!["bash".to_string()],
8510 network_allow: vec!["https://api.example.com".to_string()],
8511 ..CapabilityPolicy::default()
8512 }
8513 }
8514
8515 #[test]
8516 fn warn_for_enforcement_tier_kernel_full_logs_scope_when_shell_declared() {
8517 let temp = tempfile::tempdir().unwrap();
8518 warn_for_enforcement_tier(
8519 EnforcementTier::KernelFull,
8520 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
8521 &warning_test_policy(),
8522 );
8523 let log = bootstrap_log_contents(temp.path());
8524 assert!(
8525 log.contains(W_SEC_005),
8526 "KernelFull must not be silent — it must state what the tier does and does not cover: {log}"
8527 );
8528 assert!(
8529 log.contains("denied, not absent"),
8530 "must state the scope's documented limit: paths outside the workdir are denied, not \
8531 absent: {log}"
8532 );
8533 assert!(
8534 log.contains("read+execute") && log.contains("outside the workdir"),
8535 "must describe what is now actually granted (narrow, derived read+execute outside \
8536 the workdir), not an unfixed grant bug: {log}"
8537 );
8538 assert!(
8539 log.contains(&security_warning_link(W_SEC_005)),
8540 "must link to the diagnostics doc page: {log}"
8541 );
8542 }
8543
8544 #[test]
8545 fn warn_for_enforcement_tier_kernel_full_silent_without_shell_allow() {
8546 let temp = tempfile::tempdir().unwrap();
8547 warn_for_enforcement_tier(
8548 EnforcementTier::KernelFull,
8549 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
8550 &CapabilityPolicy::default(),
8551 );
8552 assert_eq!(
8553 bootstrap_log_contents(temp.path()),
8554 "",
8555 "with no shell declared there is no subprocess to enforce on — stay silent"
8556 );
8557 }
8558
8559 #[test]
8560 fn warn_for_enforcement_tier_seccomp_only_logs_the_landlock_gap() {
8561 let temp = tempfile::tempdir().unwrap();
8562 warn_for_enforcement_tier(
8563 EnforcementTier::KernelSeccompOnly,
8564 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
8565 &warning_test_policy(),
8566 );
8567 let log = bootstrap_log_contents(temp.path());
8568 assert!(
8569 log.contains("Landlock"),
8570 "must name the missing primitive: {log}"
8571 );
8572 assert!(
8573 log.contains("filesystem"),
8574 "must name what is not enforced: {log}"
8575 );
8576 assert!(
8577 log.contains(W_SEC_002),
8578 "must carry its warning code: {log}"
8579 );
8580 assert!(
8581 log.contains(&security_warning_link(W_SEC_002)),
8582 "must link to the diagnostics doc page: {log}"
8583 );
8584 }
8585
8586 #[test]
8587 fn warn_for_enforcement_tier_environment_only_logs_permanent_and_bypass_warnings() {
8588 let temp = tempfile::tempdir().unwrap();
8589 warn_for_enforcement_tier(
8590 EnforcementTier::EnvironmentOnly,
8591 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
8592 &warning_test_policy(),
8593 );
8594 let log = bootstrap_log_contents(temp.path());
8595 assert!(
8596 log.contains("permanent"),
8597 "must state the tier is permanent, not pending: {log}"
8598 );
8599 assert!(
8600 log.contains("bash"),
8601 "the still-accurate bash/network bypass warning must also fire: {log}"
8602 );
8603 assert!(
8604 log.contains(W_SEC_001),
8605 "must carry the tier warning code: {log}"
8606 );
8607 assert!(
8608 log.contains(W_SEC_003),
8609 "must carry the bash/network bypass warning code: {log}"
8610 );
8611 }
8612
8613 struct ForcePrepareFailureGuard;
8616
8617 impl ForcePrepareFailureGuard {
8618 fn new() -> Self {
8619 FORCE_PREPARE_FAILURE.with(|flag| flag.set(true));
8620 Self
8621 }
8622 }
8623
8624 impl Drop for ForcePrepareFailureGuard {
8625 fn drop(&mut self) {
8626 FORCE_PREPARE_FAILURE.with(|flag| flag.set(false));
8627 }
8628 }
8629
8630 #[test]
8631 fn execute_shell_fails_closed_when_enforcement_setup_fails() {
8632 let temp = tempfile::tempdir().unwrap();
8633 let policy = CapabilityPolicy {
8634 shell_allow: vec!["bash".to_string()],
8635 ..CapabilityPolicy::default()
8636 };
8637 let marker = temp.path().join("spawned-anyway.marker");
8638 let script = format!("echo ran > '{}'", marker.display());
8639
8640 {
8641 let _guard = ForcePrepareFailureGuard::new();
8642 let error = crate::shell::execute_shell(
8643 "bash",
8644 &["-c", &script],
8645 &[],
8646 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
8647 &policy,
8648 &ShellEnforcement::environment_only().with_test_session_dirs(),
8649 )
8650 .unwrap_err();
8651 assert!(
8652 error.to_string().contains("sandbox"),
8653 "the error must name what failed to initialize: {error}"
8654 );
8655 assert!(
8656 !marker.exists(),
8657 "no subprocess may be spawned at all when enforcement setup fails"
8658 );
8659 }
8660
8661 let result = crate::shell::execute_shell(
8664 "bash",
8665 &["-c", &script],
8666 &[],
8667 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
8668 &policy,
8669 &ShellEnforcement::environment_only().with_test_session_dirs(),
8670 )
8671 .unwrap();
8672 assert_eq!(result.exit_code, 0);
8673 assert!(marker.exists());
8674 }
8675}
8676
8677#[cfg(target_os = "linux")]
8678#[cfg(test)]
8679#[allow(clippy::print_stdout, clippy::print_stderr)]
8680mod landlock_probe_isolation {
8681 #[test]
8693 fn the_landlock_probe_does_not_restrict_the_calling_process() {
8694 let before = crate::namespace_probe::probe_namespaces().sealed_root;
8695 let _ = super::linux_enforce::probe_landlock_full_access();
8696 let after = crate::namespace_probe::probe_namespaces().sealed_root;
8697
8698 assert_eq!(
8699 before, after,
8700 "the Landlock probe changed what the sealed probe reports ({before:?} -> {after:?}) — \
8701 it has restricted the calling process instead of a forked child, which permanently \
8702 forbids the mount family to this process and everything it spawns",
8703 );
8704 }
8705}
8706
8707#[cfg(all(test, target_os = "linux"))]
8708#[allow(clippy::print_stdout, clippy::print_stderr)]
8709mod linux_integration_tests {
8710 use std::path::Path;
8711
8712 use super::*;
8713
8714 fn host_bounding_base() -> ShellEnforcement {
8727 let base = tempfile::tempdir().unwrap().keep();
8730 ShellEnforcement::environment_only().with_runtime_dir(test_runtime_dir(&base), None)
8731 }
8732
8733 #[test]
8745 fn workdir_landlock_grant_withholds_device_and_socket_creation() {
8746 use landlock::{Access, AccessFs, ABI};
8747
8748 assert_eq!(
8749 LANDLOCK_TARGET_ABI, 3,
8750 "this test pins the ABI v3 set; raising the cap needs this test revisited with it"
8751 );
8752 let all_v3 = AccessFs::from_all(ABI::V3);
8753 let granted = linux_enforce::workdir_access_rights(true);
8757
8758 for withheld in [AccessFs::MakeChar, AccessFs::MakeBlock, AccessFs::MakeSock] {
8759 assert!(
8760 all_v3.contains(withheld),
8761 "{withheld:?} is expected to be part of Landlock ABI v3 — if it is not, this \
8762 test's premise (and the workdir grant's comment) needs revisiting"
8763 );
8764 assert!(
8765 !granted.contains(withheld),
8766 "the workdir grant must withhold {withheld:?}: with the full ABI set declared \
8767 by handle_access, withholding it here is what makes it denied rather than merely \
8768 un-granted"
8769 );
8770 }
8771
8772 for added in [AccessFs::Refer, AccessFs::Truncate] {
8773 assert!(
8774 granted.contains(added),
8775 "the workdir grant must carry {added:?}: without Refer a move between workdir \
8776 folders is EXDEV, and without Truncate a v3 ruleset refuses truncating the \
8777 capsule's own files"
8778 );
8779 }
8780
8781 assert!(
8782 granted.contains(AccessFs::MakeFifo),
8783 "MakeFifo stays granted — real build tooling creates named pipes in its working tree"
8784 );
8785
8786 let expected = all_v3 & !(AccessFs::MakeChar | AccessFs::MakeBlock | AccessFs::MakeSock);
8787 assert_eq!(
8788 granted, expected,
8789 "the workdir_exec grant must be exactly Landlock ABI v3 minus \
8790 MakeChar/MakeBlock/MakeSock — nothing else may be added or dropped without updating \
8791 this test and the constant's comment together"
8792 );
8793 }
8794
8795 #[test]
8799 fn workdir_rights_masked_to_abi_v1_equal_the_pre_refer_set() {
8800 use landlock::{Access, AccessFs, ABI};
8801
8802 let all_v1 = AccessFs::from_all(ABI::V1);
8803 let masked = linux_enforce::workdir_access_rights(true) & all_v1;
8804
8805 assert_eq!(
8806 masked,
8807 all_v1 & !(AccessFs::MakeChar | AccessFs::MakeBlock | AccessFs::MakeSock),
8808 "masked to ABI v1, the workdir grant must be the v1 set minus the three Make* rights"
8809 );
8810 assert!(!masked.contains(AccessFs::Refer));
8811 assert!(!masked.contains(AccessFs::Truncate));
8812 assert_eq!(
8813 linux_enforce::workdir_access_rights(false) & all_v1,
8814 masked & !AccessFs::Execute,
8815 "masking must not move the workdir_exec axis"
8816 );
8817 }
8818
8819 #[test]
8826 fn workdir_execute_right_is_granted_only_when_workdir_exec_is_declared() {
8827 use landlock::AccessFs;
8828
8829 assert!(
8830 !linux_enforce::workdir_access_rights(false).contains(AccessFs::Execute),
8831 "the default must withhold Execute from the workdir — that withholding is the whole \
8832 of what enforces capabilities.shell.allow now that the exec supervisor is gone"
8833 );
8834 assert!(
8835 linux_enforce::workdir_access_rights(true).contains(AccessFs::Execute),
8836 "capabilities.filesystem.workdir_exec: true must actually grant Execute, or the \
8837 compile-and-run workflows it exists for silently stop working"
8838 );
8839
8840 assert_eq!(
8842 linux_enforce::workdir_access_rights(true) & !AccessFs::Execute,
8843 linux_enforce::workdir_access_rights(false),
8844 "workdir_exec must be a one-bit axis — no other right may ride along with it"
8845 );
8846 }
8847
8848 fn sealed_with_runtime_dir(base: &Path) -> ShellEnforcement {
8850 ShellEnforcement {
8851 tier: EnforcementTier::KernelSealed,
8852 ..ShellEnforcement::environment_only().with_test_session_dirs()
8853 }
8854 .with_runtime_dir(test_runtime_dir(base), None)
8855 }
8856
8857 fn temp_dir_fd_of(enforcement: &ShellEnforcement) -> Option<Arc<std::os::fd::OwnedFd>> {
8860 enforcement
8861 .runtime_dir
8862 .as_ref()
8863 .and_then(|roots| roots.temp.as_ref())
8864 .map(|temp| Arc::new(temp.path_fd().unwrap()))
8865 }
8866
8867 fn home_dir_fd_of(enforcement: &ShellEnforcement) -> Option<Arc<std::os::fd::OwnedFd>> {
8870 enforcement
8871 .home_root()
8872 .map(|home| Arc::new(home.path_fd().unwrap()))
8873 }
8874
8875 #[test]
8884 fn the_temp_folder_and_runtime_dir_are_opened_for_landlock_and_the_sealed_bind() {
8885 let accessible = tempfile::tempdir().unwrap();
8886 let runtime = tempfile::tempdir().unwrap();
8887 let enforcement = sealed_with_runtime_dir(runtime.path());
8888 let temp_dir_fd = temp_dir_fd_of(&enforcement);
8889 let sources = SealedSourceFds::open(
8890 &enforcement,
8891 temp_dir_fd.as_ref(),
8892 home_dir_fd_of(&enforcement).as_ref(),
8893 )
8894 .expect("a real, writable workdir must yield the sealed sources");
8895 assert_eq!(
8896 sources.tmp_store_path,
8897 enforcement.temp_dir_path().unwrap(),
8898 "the composed root binds the session's temporary folder at /tmp"
8899 );
8900 assert_eq!(
8901 sources.home_dir.1,
8902 enforcement.home_dir_path().unwrap(),
8903 "the composed root binds the session's home folder at its own path"
8904 );
8905 let fds = linux_enforce::open_landlock_fds(linux_enforce::LandlockFdRequest {
8906 sealed: Some(&sources),
8907 temp_dir_fd,
8908 home_dir_fd: Some(Arc::clone(&sources.home_dir.0)),
8909 runtime_dir_fd: Some(Arc::clone(&sources.runtime_dir)),
8910 records_dir_fd: sources.records_dir.as_ref().map(|(fd, _)| Arc::clone(fd)),
8911 ..linux_enforce::LandlockFdRequest::workdir_only(accessible.path())
8912 })
8913 .expect("a real, writable workdir must yield sealed Landlock fds");
8914
8915 assert!(fds.temp_dir_fd().is_some());
8916 assert!(
8917 fds.home_dir_fd().is_some(),
8918 "the home folder takes the temporary folder's rule"
8919 );
8920 assert!(fds.runtime_dir_fd().is_some());
8921 assert!(
8922 fds.records_dir_fd().is_some(),
8923 "the records directory takes the runtime directory's read-only rule"
8924 );
8925 assert_eq!(
8926 std::fs::read_dir(accessible.path()).unwrap().count(),
8927 0,
8928 "enabling sealed must add nothing to the work folder"
8929 );
8930
8931 let fds = linux_enforce::open_landlock_fds(linux_enforce::LandlockFdRequest::workdir_only(
8932 accessible.path(),
8933 ))
8934 .expect("a real, writable workdir must yield Landlock fds on any tier");
8935 assert!(
8936 fds.temp_dir_fd().is_none(),
8937 "an enforcement holding no temporary folder grants none"
8938 );
8939 assert!(fds.home_dir_fd().is_none());
8940 }
8941
8942 #[test]
8945 fn a_sealed_root_needs_the_temp_folder() {
8946 let runtime = tempfile::tempdir().unwrap();
8947 let mut roots = test_runtime_dir(runtime.path());
8948 roots.temp = None;
8949 let enforcement = ShellEnforcement {
8950 tier: EnforcementTier::KernelSealed,
8951 ..ShellEnforcement::environment_only().with_test_session_dirs()
8952 }
8953 .with_runtime_dir(roots, None);
8954 let error =
8955 SealedSourceFds::open(&enforcement, None, home_dir_fd_of(&enforcement).as_ref())
8956 .err()
8957 .expect("refused");
8958 assert!(error.contains("temporary folder"), "{error}");
8959 }
8960
8961 #[test]
8964 fn no_home_folder_refuses_a_sealed_root() {
8965 let runtime = tempfile::tempdir().unwrap();
8966 let roots = crate::runtime_dir::RuntimeDirRoots {
8967 home: None,
8968 ..test_runtime_dir(runtime.path())
8969 };
8970 let enforcement = ShellEnforcement {
8971 tier: EnforcementTier::KernelSealed,
8972 ..ShellEnforcement::environment_only().with_test_session_dirs()
8973 }
8974 .with_runtime_dir(roots, None);
8975 let error =
8976 SealedSourceFds::open(&enforcement, temp_dir_fd_of(&enforcement).as_ref(), None)
8977 .err()
8978 .expect("refused");
8979 assert!(error.contains("no home folder"), "{error}");
8980 assert!(!runtime
8981 .path()
8982 .join(crate::runtime_dir::SEALED_ETC_SUBDIR)
8983 .join("passwd")
8984 .exists());
8985 }
8986
8987 #[test]
8995 fn the_synthetic_etc_files_are_written_into_sealed_etc_and_opened() {
8996 let accessible = tempfile::tempdir().unwrap();
8997 let runtime = tempfile::tempdir().unwrap();
8998 let enforcement = sealed_with_runtime_dir(runtime.path());
8999 let sources = SealedSourceFds::open(
9000 &enforcement,
9001 temp_dir_fd_of(&enforcement).as_ref(),
9002 home_dir_fd_of(&enforcement).as_ref(),
9003 )
9004 .unwrap();
9005 let fds = linux_enforce::open_landlock_fds(linux_enforce::LandlockFdRequest {
9006 sealed: Some(&sources),
9007 ..linux_enforce::LandlockFdRequest::workdir_only(accessible.path())
9008 })
9009 .expect("a real, writable workdir must yield sealed Landlock fds");
9010
9011 let synthetic: Vec<&crate::sealed::SealedEtcPath> = crate::sealed::SEALED_ETC_PATHS
9012 .iter()
9013 .filter(|entry| entry.synthetic.is_some())
9014 .collect();
9015 assert_eq!(
9016 fds.sealed_identity_fd_count(),
9017 synthetic.len(),
9018 "every synthetic /etc entry the composed root binds needs a rule of its own: {synthetic:?}",
9019 );
9020 let etc_dir = runtime.path().join(crate::runtime_dir::SEALED_ETC_SUBDIR);
9021 for entry in &synthetic {
9022 let path = crate::sealed::synthetic_etc_source(&etc_dir, entry.synthetic.unwrap());
9023 assert!(
9024 path.is_file(),
9025 "{} must be written into sealed-etc/",
9026 entry.path
9027 );
9028 }
9029 assert!(
9030 !accessible.path().join(".mur-etc").exists(),
9031 "the work folder gained .mur-etc"
9032 );
9033
9034 let fds = linux_enforce::open_landlock_fds(linux_enforce::LandlockFdRequest::workdir_only(
9035 accessible.path(),
9036 ))
9037 .unwrap();
9038 assert_eq!(fds.sealed_identity_fd_count(), 0);
9039 }
9040
9041 #[test]
9044 fn a_sealed_root_needs_the_runtime_directory() {
9045 let enforcement = ShellEnforcement {
9046 tier: EnforcementTier::KernelSealed,
9047 ..ShellEnforcement::environment_only()
9048 };
9049 let error = SealedSourceFds::open(&enforcement, None, None)
9050 .err()
9051 .expect("refused");
9052 assert!(error.contains("runtime directory"), "{error}");
9053 }
9054
9055 #[test]
9063 fn home_folder_is_the_synthetic_passwd_entrys_home_and_home() {
9064 use std::os::unix::fs::MetadataExt;
9065
9066 let runtime = tempfile::tempdir().unwrap();
9067 let roots = test_runtime_dir(runtime.path());
9068 let home = roots.home.clone().unwrap();
9069 let written = ensure_sealed_identity_files(&roots.sealed_etc, &home).unwrap();
9070 let env = crate::shell::build_shell_env(
9071 &crate::types::CapabilityPolicy::default(),
9072 &[],
9073 Some(home.path()),
9074 None,
9075 None,
9076 None,
9077 )
9078 .unwrap();
9079
9080 let passwd = std::fs::read_to_string(&written[0]).unwrap();
9081 let owner = std::fs::metadata(home.path()).unwrap();
9082 let entry: Vec<&str> = passwd
9083 .lines()
9084 .last()
9085 .expect("the capsule's own entry is the last line")
9086 .split(':')
9087 .collect();
9088
9089 assert_eq!(entry[2], owner.uid().to_string(), "uid field");
9090 assert_eq!(entry[3], owner.gid().to_string(), "gid field");
9091 assert_eq!(
9092 Some(&entry[5].to_string()),
9093 env.get("HOME"),
9094 "pw_dir and $HOME are the same string or the entry is worse than useless",
9095 );
9096 assert_eq!(
9097 entry[5],
9098 runtime
9099 .path()
9100 .join(crate::records_dir::RECORDS_DIR_NAME)
9101 .join(crate::records_dir::HOME_SUBDIR)
9102 .to_str()
9103 .unwrap()
9104 );
9105
9106 let group = std::fs::read_to_string(&written[1]).unwrap();
9107 assert_eq!(
9108 group.lines().last().unwrap().split(':').nth(2),
9109 Some(owner.gid().to_string().as_str()),
9110 "gid field of the capsule's own group",
9111 );
9112 }
9113
9114 fn sealed_planted() -> (
9116 tempfile::TempDir,
9117 PathBuf,
9118 PathBuf,
9119 crate::workdir_fs::WorkdirRoot,
9120 ) {
9121 let dir = tempfile::tempdir().unwrap();
9122 let accessible = dir.path().join("work");
9123 let outside = dir.path().join("outside");
9124 std::fs::create_dir_all(&accessible).unwrap();
9125 std::fs::create_dir_all(&outside).unwrap();
9126 std::fs::write(outside.join("sentinel.txt"), b"operator data").unwrap();
9127 let root = crate::workdir_fs::WorkdirRoot::open(&accessible).unwrap();
9128 (dir, root.path().to_path_buf(), outside, root)
9129 }
9130
9131 #[test]
9134 fn sealed_identity_files_keep_their_inode() {
9135 use std::os::unix::fs::MetadataExt;
9136
9137 let (_dir, _session, _outside, root) = sealed_planted();
9138 let runtime = tempfile::tempdir().unwrap();
9139 let roots = test_runtime_dir(runtime.path());
9140 let passwd = roots.sealed_etc.path().join("passwd");
9141 ensure_sealed_identity_files(&roots.sealed_etc, &root).unwrap();
9142 let first = std::fs::metadata(&passwd).unwrap().ino();
9143 ensure_sealed_identity_files(&roots.sealed_etc, &root).unwrap();
9144 assert_eq!(std::fs::metadata(&passwd).unwrap().ino(), first);
9145 assert!(root.take_refusals().is_empty());
9146 }
9147
9148 #[test]
9160 fn the_composed_root_tmp_rule_carries_exactly_the_workdir_right_set() {
9161 use landlock::AccessFs;
9162
9163 for workdir_exec in [false, true] {
9164 let rights = linux_enforce::workdir_access_rights(workdir_exec);
9165
9166 for needed in [
9167 AccessFs::WriteFile,
9168 AccessFs::ReadFile,
9169 AccessFs::ReadDir,
9170 AccessFs::MakeReg,
9171 AccessFs::MakeDir,
9172 AccessFs::RemoveFile,
9173 AccessFs::RemoveDir,
9174 ] {
9175 assert!(
9176 rights.contains(needed),
9177 "/tmp carries the workdir's rights, so it must carry {needed:?}: without it \
9178 `cc`, `mktemp` and `pip` fail on a mount that is genuinely writable",
9179 );
9180 }
9181
9182 assert_eq!(
9183 rights.contains(AccessFs::Execute),
9184 workdir_exec,
9185 "/tmp is backed by a directory inside the workdir, so Execute there must follow \
9186 capabilities.filesystem.workdir_exec exactly — granting it unconditionally would \
9187 make /tmp a hole through which capsule-written bytes run",
9188 );
9189 }
9190
9191 assert_eq!(
9192 linux_enforce::workdir_access_rights(true) & !AccessFs::Execute,
9193 linux_enforce::workdir_access_rights(false),
9194 "the /tmp rule and the workdir rule share one right-set binding in \
9195 apply_landlock_scope; if that set ever grows a second conditional axis, this test and \
9196 that binding must be revisited together",
9197 );
9198 }
9199
9200 fn unconfined_enforcement(declared: murmur_artifact::ResourceCapabilities) -> ShellEnforcement {
9203 ShellEnforcement {
9204 tier: EnforcementTier::Unconfined,
9205 declared_resources: declared,
9206 temp_mapping: crate::temp_dir::tier_temp_mapping(EnforcementTier::Unconfined),
9207 proc_view: crate::proc_view::tier_proc_view(EnforcementTier::Unconfined),
9208 ..ShellEnforcement::environment_only()
9209 }
9210 .with_test_session_dirs()
9211 }
9212
9213 fn status_line(status: &str, field: &str) -> String {
9216 status
9217 .lines()
9218 .find(|line| line.starts_with(field))
9219 .unwrap_or_else(|| panic!("no {field} line in {status}"))
9220 .to_string()
9221 }
9222
9223 fn spawn_unconfined(enforcement: &ShellEnforcement, script: &str) -> String {
9225 let mut command = std::process::Command::new("bash");
9226 command.args(["-c", script]);
9227 let supervisor = prepare_enforcement(&mut command, enforcement, Path::new("/tmp"))
9228 .expect("the unconfined tier installs nothing that can fail");
9229 assert!(matches!(supervisor, SupervisorHandle::Noop));
9230 let output = command.output().expect("bash runs");
9231 assert!(output.status.success(), "{output:?}");
9232 String::from_utf8(output.stdout).unwrap()
9233 }
9234
9235 #[test]
9239 fn an_unconfined_child_keeps_no_new_privs_capabilities_and_network_namespace() {
9240 let enforcement = unconfined_enforcement(Default::default());
9241 let child = spawn_unconfined(
9242 &enforcement,
9243 "cat /proc/self/status; echo \"netns=$(readlink /proc/self/ns/net)\"",
9244 );
9245 let own = std::fs::read_to_string("/proc/self/status").unwrap();
9246 for field in ["NoNewPrivs:", "CapEff:"] {
9247 assert_eq!(
9248 status_line(&child, field),
9249 status_line(&own, field),
9250 "{child}"
9251 );
9252 }
9253 let own_netns = std::fs::read_link("/proc/self/ns/net").unwrap();
9254 assert!(
9255 child.contains(&format!("netns={}", own_netns.display())),
9256 "{child}"
9257 );
9258 assert_eq!(
9259 enforcement.session_netns.creations(),
9260 0,
9261 "the unconfined tier creates no network namespace"
9262 );
9263 }
9264
9265 #[test]
9268 fn an_unconfined_child_gets_only_the_declared_rlimits() {
9269 let read = |resource| {
9270 let limit = rustix::process::getrlimit(resource);
9271 let show = |value: Option<u64>| {
9272 value.map_or_else(|| "unlimited".to_string(), |value| value.to_string())
9273 };
9274 format!("{} {}", show(limit.current), show(limit.maximum))
9275 };
9276 let undeclared = spawn_unconfined(
9277 &unconfined_enforcement(Default::default()),
9278 "echo \"$(ulimit -Sn) $(ulimit -Hn)\"; echo \"$(ulimit -Sc) $(ulimit -Hc)\"",
9279 );
9280 assert_eq!(
9281 undeclared,
9282 format!(
9283 "{}\n{}\n",
9284 read(rustix::process::Resource::Nofile),
9285 read(rustix::process::Resource::Core)
9286 )
9287 );
9288 let declared = spawn_unconfined(
9289 &unconfined_enforcement(murmur_artifact::ResourceCapabilities {
9290 max_open_files: Some(256),
9291 ..Default::default()
9292 }),
9293 "echo \"$(ulimit -Sn) $(ulimit -Hn)\"; echo \"$(ulimit -Sc) $(ulimit -Hc)\"",
9294 );
9295 assert_eq!(
9296 declared,
9297 format!("256 256\n{}\n", read(rustix::process::Resource::Core))
9298 );
9299 }
9300
9301 #[test]
9302 fn prepare_enforcement_is_noop_for_environment_only_tier_even_on_linux() {
9303 let mut command = std::process::Command::new("true");
9304 let enforcement = ShellEnforcement::environment_only().with_test_session_dirs();
9305 let supervisor = prepare_enforcement(&mut command, &enforcement, Path::new("/tmp"))
9306 .expect("environment_only must never fail");
9307 assert!(matches!(supervisor, SupervisorHandle::Noop));
9308 assert_eq!(
9309 enforcement.session_netns.creations(),
9310 0,
9311 "a tier with no kernel sandbox must not create a network namespace"
9312 );
9313 }
9314
9315 #[test]
9321 fn kernel_tier_allows_exec_within_shell_allowlist() {
9322 if crate::network_namespace::skip_without_egress_namespace(
9323 "kernel_tier_allows_exec_within_shell_allowlist",
9324 ) {
9325 return;
9326 }
9327 let tier = detect_enforcement_tier();
9328 if tier == EnforcementTier::EnvironmentOnly {
9329 eprintln!(
9330 "skipping kernel_tier_allows_exec_within_shell_allowlist: resolved to \
9331 EnvironmentOnly on a Linux host — degrading gracefully"
9332 );
9333 return;
9334 }
9335
9336 let temp = tempfile::tempdir().unwrap();
9337 let policy = CapabilityPolicy {
9338 shell_allow: vec!["bash".to_string()],
9339 ..CapabilityPolicy::default()
9340 };
9341 let enforcement = ShellEnforcement {
9342 tier,
9343 network_allow_ips: Vec::new(),
9344 unix_sockets_allowed: policy.unix_sockets_allowed,
9345 landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
9346 &resolve_exec_allowlist(&policy.shell_allow),
9347 )),
9348 ..host_bounding_base()
9349 };
9350
9351 let result = crate::shell::execute_shell(
9352 "bash",
9353 &["-c", "exit 7"],
9354 &[],
9355 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
9356 &policy,
9357 &enforcement,
9358 )
9359 .unwrap();
9360
9361 assert_eq!(
9362 result.exit_code, 7,
9363 "bash itself is allowlisted, so kernel enforcement must permit its own execve — \
9364 proving the allowlist isn't a blanket deny"
9365 );
9366 }
9367
9368 #[test]
9369 fn kernel_tier_allows_nested_exec_of_second_allowlisted_binary() {
9370 if crate::network_namespace::skip_without_egress_namespace(
9371 "kernel_tier_allows_nested_exec_of_second_allowlisted_binary",
9372 ) {
9373 return;
9374 }
9375 let tier = detect_enforcement_tier();
9376 if tier == EnforcementTier::EnvironmentOnly {
9377 eprintln!(
9378 "skipping kernel_tier_allows_nested_exec_of_second_allowlisted_binary: \
9379 resolved to EnvironmentOnly on a Linux host — degrading gracefully"
9380 );
9381 return;
9382 }
9383
9384 let temp = tempfile::tempdir().unwrap();
9385 let policy = CapabilityPolicy {
9386 shell_allow: vec!["bash".to_string(), "ls".to_string()],
9387 ..CapabilityPolicy::default()
9388 };
9389 let enforcement = ShellEnforcement {
9390 tier,
9391 network_allow_ips: Vec::new(),
9392 unix_sockets_allowed: policy.unix_sockets_allowed,
9393 landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
9394 &resolve_exec_allowlist(&policy.shell_allow),
9395 )),
9396 ..host_bounding_base()
9397 };
9398
9399 let result = crate::shell::execute_shell(
9403 "bash",
9404 &["-c", "ls"],
9405 &[],
9406 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
9407 &policy,
9408 &enforcement,
9409 )
9410 .unwrap();
9411
9412 assert_eq!(
9413 result.exit_code, 0,
9414 "a second allowlisted binary exec'd from inside allowlisted bash must succeed \
9415 (stderr: {})",
9416 result.stderr
9417 );
9418 }
9419
9420 #[test]
9421 fn kernel_tier_denies_network_connect_outside_allowlist() {
9422 if crate::network_namespace::skip_without_egress_namespace(
9423 "kernel_tier_denies_network_connect_outside_allowlist",
9424 ) {
9425 return;
9426 }
9427 let tier = detect_enforcement_tier();
9428 if tier == EnforcementTier::EnvironmentOnly {
9429 eprintln!(
9430 "skipping kernel_tier_denies_network_connect_outside_allowlist: resolved to \
9431 EnvironmentOnly on a Linux host — degrading gracefully"
9432 );
9433 return;
9434 }
9435
9436 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
9441 let port = listener.local_addr().unwrap().port();
9442
9443 let temp = tempfile::tempdir().unwrap();
9444 let policy = CapabilityPolicy {
9445 shell_allow: vec!["bash".to_string()],
9446 ..CapabilityPolicy::default()
9447 };
9448 let enforcement = ShellEnforcement {
9449 tier,
9450 network_allow_rules: Vec::new(),
9453 network_allow_ips: Vec::new(),
9454 unix_sockets_allowed: policy.unix_sockets_allowed,
9455 landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
9456 &resolve_exec_allowlist(&policy.shell_allow),
9457 )),
9458 ..host_bounding_base()
9459 };
9460
9461 let script = format!("exec 3<>/dev/tcp/127.0.0.1/{port}");
9463 let result = crate::shell::execute_shell(
9464 "bash",
9465 &["-c", &script],
9466 &[],
9467 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
9468 &policy,
9469 &enforcement,
9470 )
9471 .unwrap();
9472
9473 assert_ne!(
9474 result.exit_code, 0,
9475 "a direct connect() to a destination outside capabilities.network.allow must fail \
9476 even though the port is really open on the host — the subprocess is in its own \
9477 network namespace with no route to it"
9478 );
9479 }
9480
9481 #[test]
9493 fn kernel_tier_reaches_an_allowlisted_destination_through_the_egress_proxy() {
9494 if crate::network_namespace::skip_without_egress_namespace(
9495 "kernel_tier_reaches_an_allowlisted_destination_through_the_egress_proxy",
9496 ) {
9497 return;
9498 }
9499 let tier = detect_enforcement_tier();
9500 if tier == EnforcementTier::EnvironmentOnly {
9501 eprintln!(
9502 "skipping kernel_tier_reaches_an_allowlisted_destination_through_the_egress_proxy: \
9503 resolved to EnvironmentOnly on a Linux host — degrading gracefully"
9504 );
9505 return;
9506 }
9507
9508 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
9511 let port = listener.local_addr().unwrap().port();
9512 let server = std::thread::spawn(move || {
9513 if let Ok((mut stream, _)) = listener.accept() {
9514 use std::io::Write;
9515 let _ = stream.write_all(b"pong\n");
9516 }
9517 });
9518
9519 let temp = tempfile::tempdir().unwrap();
9520 let allow = vec![format!("http://127.0.0.1:{port}")];
9523 let policy = CapabilityPolicy {
9524 shell_allow: vec!["bash".to_string()],
9525 network_allow: allow.clone(),
9526 ..CapabilityPolicy::default()
9527 };
9528 let rules = crate::network_policy::parse_network_allow_rules(&allow).unwrap();
9529 let enforcement = ShellEnforcement {
9530 tier,
9531 egress_tcp_ports: crate::egress_proxy::egress_listen_ports(&rules),
9532 network_allow_rules: rules,
9533 network_allow_ips: resolve_network_allowlist_ips(&allow).unwrap(),
9534 unix_sockets_allowed: policy.unix_sockets_allowed,
9535 landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
9536 &resolve_exec_allowlist(&policy.shell_allow),
9537 )),
9538 ..host_bounding_base()
9539 };
9540
9541 let script =
9544 format!("exec 3<>/dev/tcp/127.0.0.1/{port} && read -r reply <&3 && echo \"$reply\"");
9545 let result = crate::shell::execute_shell(
9546 "bash",
9547 &["-c", &script],
9548 &[],
9549 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
9550 &policy,
9551 &enforcement,
9552 )
9553 .unwrap();
9554 let _ = server.join();
9555
9556 assert_eq!(
9557 result.exit_code, 0,
9558 "a destination inside capabilities.network.allow must stay reachable — enforcement \
9559 adds denials for out-of-policy destinations, it does not degrade permitted ones \
9560 (stderr: {})",
9561 result.stderr
9562 );
9563 assert!(
9564 result.stdout.contains("pong"),
9565 "the host listener's bytes must come back through the proxy unmodified \
9566 (stdout: {:?}, stderr: {})",
9567 result.stdout,
9568 result.stderr
9569 );
9570 }
9571
9572 #[test]
9573 fn kernel_full_denies_filesystem_access_outside_workdir() {
9574 if crate::network_namespace::skip_without_egress_namespace(
9575 "kernel_full_denies_filesystem_access_outside_workdir",
9576 ) {
9577 return;
9578 }
9579 let tier = detect_enforcement_tier();
9580 if tier != EnforcementTier::KernelFull {
9581 eprintln!(
9582 "skipping kernel_full_denies_filesystem_access_outside_workdir: requires \
9583 EnforcementTier::KernelFull (Landlock ABI, kernel 5.13+); detected {tier:?} \
9584 instead"
9585 );
9586 return;
9587 }
9588
9589 let temp = tempfile::tempdir().unwrap();
9590 let policy = CapabilityPolicy {
9591 shell_allow: vec!["bash".to_string()],
9592 ..CapabilityPolicy::default()
9593 };
9594 let enforcement = ShellEnforcement {
9595 tier,
9596 network_allow_ips: Vec::new(),
9597 unix_sockets_allowed: policy.unix_sockets_allowed,
9598 landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
9599 &resolve_exec_allowlist(&policy.shell_allow),
9600 )),
9601 ..host_bounding_base()
9602 };
9603
9604 let result = crate::shell::execute_shell(
9605 "bash",
9606 &["-c", "cat /etc/hostname"],
9607 &[],
9608 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
9609 &policy,
9610 &enforcement,
9611 )
9612 .unwrap();
9613
9614 assert_ne!(
9615 result.exit_code, 0,
9616 "reading a file outside the Landlock-scoped workdir must fail under KernelFull"
9617 );
9618 }
9619
9620 fn scoped_landlock_enforcement(
9629 test: &str,
9630 policy: &CapabilityPolicy,
9631 min_abi: u8,
9632 ) -> Option<ShellEnforcement> {
9633 if crate::network_namespace::skip_without_egress_namespace(test) {
9634 return None;
9635 }
9636 let probe = HostProbe::probe();
9637 let enforcement = ShellEnforcement::resolve_declared(
9638 policy,
9639 murmur_artifact::ContainmentClass::Scoped,
9640 probe.clone(),
9641 )
9642 .unwrap()
9643 .with_test_session_dirs();
9644 if enforcement.tier != EnforcementTier::KernelFull
9645 || enforcement.landlock_abi < Some(min_abi)
9646 {
9647 eprintln!(
9648 "skipping {test}: needs EnforcementTier::KernelFull with a Landlock ruleset at ABI \
9649 {min_abi} or later; resolved {:?} at ABI {:?} (kernel reports {:?})",
9650 enforcement.tier,
9651 enforcement.landlock_abi,
9652 probe.landlock_kernel_abi()
9653 );
9654 return None;
9655 }
9656 Some(enforcement)
9657 }
9658
9659 fn coreutils_policy(extra: &[&str]) -> CapabilityPolicy {
9660 let mut shell_allow: Vec<String> = ["bash", "mkdir", "mv", "ln", "stat", "cat"]
9661 .iter()
9662 .map(|name| name.to_string())
9663 .collect();
9664 shell_allow.extend(extra.iter().map(|name| name.to_string()));
9665 CapabilityPolicy {
9666 shell_allow,
9667 ..CapabilityPolicy::default()
9668 }
9669 }
9670
9671 fn run_bash_in(
9672 workdir: &Path,
9673 script: &str,
9674 policy: &CapabilityPolicy,
9675 enforcement: &ShellEnforcement,
9676 ) -> crate::shell::ShellResult {
9677 let result = crate::shell::execute_shell(
9678 "bash",
9679 &["-c", script],
9680 &[],
9681 &crate::workdir_fs::WorkdirRoot::open(workdir).unwrap(),
9682 policy,
9683 enforcement,
9684 )
9685 .unwrap();
9686 println!(
9687 "exit {}\n--- stdout\n{}--- stderr\n{}---",
9688 result.exit_code, result.stdout, result.stderr
9689 );
9690 result
9691 }
9692
9693 fn work_and_outside() -> (tempfile::TempDir, PathBuf, PathBuf) {
9696 let base = tempfile::tempdir().unwrap();
9697 let work = base.path().join("work");
9698 let outside = base.path().join("outside");
9699 std::fs::create_dir(&work).unwrap();
9700 std::fs::create_dir(&outside).unwrap();
9701 (base, work, outside)
9702 }
9703
9704 #[test]
9707 fn kernel_tier_moves_files_between_workdir_folders() {
9708 let test = "kernel_tier_moves_files_between_workdir_folders";
9709 let policy = coreutils_policy(&[]);
9710 let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 3) else {
9711 return;
9712 };
9713 assert_eq!(enforcement.tier, EnforcementTier::KernelFull);
9714 assert_eq!(enforcement.landlock_abi, Some(3));
9715
9716 let temp = tempfile::tempdir().unwrap();
9717 let result = run_bash_in(
9718 temp.path(),
9719 r#"set -e
9720mkdir a b
9721echo payload > a/x
9722before=$(stat -c %i a/x)
9723mv a/x b/x
9724after=$(stat -c %i b/x)
9725echo "mv a/x b/x: inode $before -> $after"
9726[ "$before" = "$after" ] || { echo "inode changed, mv copied instead of renaming" >&2; exit 10; }
9727ln b/x a/y
9728[ "$(cat a/y)" = payload ] || exit 11
9729echo "ln b/x a/y: ok"
9730mkdir a/d
9731mv a/d b/d
9732[ -d b/d ] && [ ! -e a/d ] || exit 12
9733echo "mv a/d b/d: ok"
9734"#,
9735 &policy,
9736 &enforcement,
9737 );
9738
9739 assert_eq!(
9740 result.exit_code, 0,
9741 "a move, a hard link and a directory reparent between workdir folders must all \
9742 succeed under Refer"
9743 );
9744 }
9745
9746 #[test]
9753 fn home_folder_is_writable_like_tmp_and_the_records_around_it_are_not() {
9754 let test = "home_folder_is_writable_like_tmp_and_the_records_around_it_are_not";
9755 let policy = coreutils_policy(&["rm", "rmdir"]);
9756 let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
9757 return;
9758 };
9759 eprintln!(
9760 "{test}: tier {:?}, Landlock ruleset ABI {:?} (kernel {:?}), /tmp {:?}",
9761 enforcement.tier,
9762 enforcement.landlock_abi,
9763 HostProbe::probe().landlock_kernel_abi(),
9764 enforcement.installed_temp_mapping()
9765 );
9766 let records = enforcement.records_root().unwrap().clone();
9767 records
9768 .create_dir_all(crate::records_dir::RESULTS_SUBDIR, 0o700)
9769 .unwrap();
9770 let home = enforcement.home_dir_path().unwrap().to_path_buf();
9771
9772 let workdir = tempfile::tempdir().unwrap();
9773 let result = run_bash_in(
9774 workdir.path(),
9775 &format!(
9776 r#"[ "$HOME" = '{home}' ] && echo home-is-the-folder
9777echo a > "$HOME/a" && mv "$HOME/a" "$HOME/b" && mkdir "$HOME/d" && mv "$HOME/b" "$HOME/d/c" \
9778 && rm "$HOME/d/c" && rmdir "$HOME/d" && echo home-writable
9779if echo x > "$MURMUR_RECORDS_DIR/out/result.txt"; then echo result-written; else echo result-refused; fi
9780if mkdir -p "$MURMUR_RECORDS_DIR/children/x"; then echo child-created; else echo child-refused; fi
9781if mv "$HOME" "$HOME.moved"; then echo home-moved; else echo home-move-refused; fi
9782"#,
9783 home = home.display()
9784 ),
9785 &policy,
9786 &enforcement,
9787 );
9788
9789 assert_eq!(
9790 result.stdout,
9791 "home-is-the-folder\nhome-writable\nresult-refused\nchild-refused\nhome-move-refused\n",
9792 "stderr: {}",
9793 result.stderr
9794 );
9795 assert!(home.is_dir());
9796 assert_eq!(std::fs::read_dir(&home).unwrap().count(), 0);
9797 assert!(!records.host_path("out/result.txt").exists());
9798 assert!(!records.host_path("children").exists());
9799 assert!(!home.with_extension("moved").exists());
9800 assert_eq!(std::fs::read_dir(workdir.path()).unwrap().count(), 0);
9801 }
9802
9803 #[test]
9812 fn children_records_are_not_reachable_by_a_parents_commands() {
9813 let test = "children_records_are_not_reachable_by_a_parents_commands";
9814 let policy = coreutils_policy(&["ls"]);
9815 let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
9816 return;
9817 };
9818 let runtime_dir = enforcement.runtime_dir_path().unwrap().to_path_buf();
9819 std::fs::write(runtime_dir.join("MURMUR.md"), "RUNTIME-DIR-CONTROL\n").unwrap();
9820 let base = tempfile::tempdir().unwrap();
9821 let child_records = base
9822 .path()
9823 .join("records/children/worker-0123456789abcdef/ses_child");
9824 let result_file = child_records.join("out/result.txt");
9825 std::fs::create_dir_all(result_file.parent().unwrap()).unwrap();
9826 std::fs::write(&result_file, "CHILD-ANSWER-7Q2W").unwrap();
9827
9828 let workdir = tempfile::tempdir().unwrap();
9829 let result = run_bash_in(
9830 workdir.path(),
9831 &format!(
9832 r#"cat "$MURMUR_RUNTIME_DIR/MURMUR.md"
9833if ls '{dir}'; then echo listed; else echo list-refused; fi
9834if cat '{file}'; then echo read; else echo read-refused; fi
9835"#,
9836 dir = child_records.display(),
9837 file = result_file.display(),
9838 ),
9839 &policy,
9840 &enforcement,
9841 );
9842
9843 assert_eq!(
9844 result.stdout, "RUNTIME-DIR-CONTROL\nlist-refused\nread-refused\n",
9845 "stderr: {}",
9846 result.stderr
9847 );
9848 assert!(
9849 !result.stdout.contains("CHILD-ANSWER") && !result.stderr.contains("CHILD-ANSWER"),
9850 "the child's answer reached the parent's command: {result:?}"
9851 );
9852 for line in result.stderr.lines() {
9853 assert!(
9854 line.contains("Permission denied") || line.contains("No such file or directory"),
9855 "an unexpected refusal: {}",
9856 result.stderr
9857 );
9858 }
9859 assert_eq!(result.stderr.lines().count(), 2, "{}", result.stderr);
9860 }
9861
9862 #[test]
9866 fn kernel_tier_at_abi_v1_refuses_cross_folder_links_with_exdev() {
9867 let test = "kernel_tier_at_abi_v1_refuses_cross_folder_links_with_exdev";
9868 let policy = coreutils_policy(&[]);
9869 let Some(mut enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
9870 return;
9871 };
9872 enforcement.landlock_abi = Some(1);
9873
9874 let temp = tempfile::tempdir().unwrap();
9875 let result = run_bash_in(
9876 temp.path(),
9877 r#"mkdir a b || exit 20
9878echo payload > a/x || exit 21
9879before=$(stat -c %i a/x)
9880mv a/x b/x || exit 22
9881[ -f b/x ] && [ ! -e a/x ] || exit 23
9882after=$(stat -c %i b/x)
9883echo "mv a/x b/x: inode $before -> $after"
9884[ "$before" != "$after" ] || exit 24
9885if ln b/x a/y; then exit 25; fi
9886exit 42
9887"#,
9888 &policy,
9889 &enforcement,
9890 );
9891
9892 assert_eq!(
9893 result.exit_code, 42,
9894 "the capsule must launch at ABI v1 and run the script to its own exit code: mv falls \
9895 back to copy-and-delete (a new inode) and ln fails"
9896 );
9897 assert!(
9898 result.stderr.contains("Invalid cross-device link"),
9899 "ln across workdir folders at ABI v1 must fail with EXDEV; stderr: {}",
9900 result.stderr
9901 );
9902 }
9903
9904 #[test]
9906 fn kernel_tier_refuses_rename_out_of_the_workdir() {
9907 let test = "kernel_tier_refuses_rename_out_of_the_workdir";
9908 let policy = coreutils_policy(&[]);
9909 let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 2) else {
9910 return;
9911 };
9912
9913 let (_base, work, outside) = work_and_outside();
9914 let script = format!(
9915 r#"echo payload > x || exit 20
9916if mv x '{outside}/x'; then exit 21; fi
9917if ln x '{outside}/x2'; then exit 22; fi
9918exit 0
9919"#,
9920 outside = outside.display()
9921 );
9922 let result = run_bash_in(&work, &script, &policy, &enforcement);
9923
9924 assert_eq!(
9925 result.exit_code, 0,
9926 "mv and ln out of the workdir must both fail"
9927 );
9928 assert!(
9929 work.join("x").exists(),
9930 "the source must stay in the workdir"
9931 );
9932 assert!(!outside.join("x").exists(), "nothing may be moved out");
9933 assert!(!outside.join("x2").exists(), "nothing may be linked out");
9934 }
9935
9936 #[test]
9938 fn kernel_tier_refuses_rename_into_the_workdir() {
9939 let test = "kernel_tier_refuses_rename_into_the_workdir";
9940 let policy = coreutils_policy(&[]);
9941 let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 2) else {
9942 return;
9943 };
9944
9945 let (_base, work, outside) = work_and_outside();
9946 std::fs::write(outside.join("y"), "original\n").unwrap();
9947 let script = format!(
9948 r#"if mv '{outside}/y' ./y; then exit 21; fi
9949if ln '{outside}/y' ./y2; then exit 22; fi
9950exit 0
9951"#,
9952 outside = outside.display()
9953 );
9954 let result = run_bash_in(&work, &script, &policy, &enforcement);
9955
9956 assert_eq!(
9957 result.exit_code, 0,
9958 "mv and ln into the workdir must both fail"
9959 );
9960 assert_eq!(
9961 std::fs::read_to_string(outside.join("y")).unwrap(),
9962 "original\n",
9963 "the outside file must be untouched"
9964 );
9965 assert!(!work.join("y").exists(), "nothing may be moved in");
9966 assert!(!work.join("y2").exists(), "nothing may be linked in");
9967 }
9968
9969 #[test]
9972 fn kernel_tier_truncate_outside_the_workdir_is_refused() {
9973 use std::os::unix::fs::PermissionsExt;
9974
9975 let test = "kernel_tier_truncate_outside_the_workdir_is_refused";
9976 let policy = coreutils_policy(&["perl"]);
9977 let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 3) else {
9978 return;
9979 };
9980
9981 let (_base, work, outside) = work_and_outside();
9982 let target = outside.join("t");
9983 std::fs::write(&target, "keep\n").unwrap();
9984 std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o644)).unwrap();
9985 let script = format!(
9986 r#"perl -e 'truncate($ARGV[0], 0) or die "$!\n"' '{}'"#,
9987 target.display()
9988 );
9989
9990 let refused = run_bash_in(&work, &script, &policy, &enforcement);
9991 assert_ne!(
9992 refused.exit_code, 0,
9993 "truncate(2) outside the workdir must be refused at ABI 3"
9994 );
9995 assert!(
9996 refused.stderr.contains("Permission denied"),
9997 "the refusal must be EACCES; stderr: {}",
9998 refused.stderr
9999 );
10000 assert_eq!(std::fs::read_to_string(&target).unwrap(), "keep\n");
10001
10002 let mut at_v1 = enforcement.clone();
10003 at_v1.landlock_abi = Some(1);
10004 let unmediated = run_bash_in(&work, &script, &policy, &at_v1);
10005 assert_eq!(
10006 unmediated.exit_code, 0,
10007 "at ABI v1 truncate(2) is not mediated, which is the gap this test pins as closed above"
10008 );
10009 assert_eq!(std::fs::read_to_string(&target).unwrap(), "");
10010 }
10011
10012 #[test]
10015 fn kernel_tier_truncate_inside_the_workdir_and_dev_null_still_work() {
10016 let test = "kernel_tier_truncate_inside_the_workdir_and_dev_null_still_work";
10017 let policy = coreutils_policy(&["truncate"]);
10018 let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 3) else {
10019 return;
10020 };
10021
10022 let temp = tempfile::tempdir().unwrap();
10023 let result = run_bash_in(
10024 temp.path(),
10025 r#"set -e
10026echo hello > f && truncate -s 0 f && [ ! -s f ]
10027echo again > f && echo over > f && [ "$(cat f)" = over ]
10028: > g
10029echo discard > /dev/null
10030echo ok
10031"#,
10032 &policy,
10033 &enforcement,
10034 );
10035
10036 assert_eq!(
10037 result.exit_code, 0,
10038 "truncating inside the workdir and writing to /dev/null must keep working"
10039 );
10040 }
10041
10042 #[test]
10053 #[ignore = "needs MURMUR_TEST_UV and MURMUR_TEST_WHEEL; see the doc comment"]
10054 fn kernel_tier_uv_installs_a_wheel_in_the_workdir() {
10055 let test = "kernel_tier_uv_installs_a_wheel_in_the_workdir";
10056 let input = |name: &str| {
10057 let value = std::env::var(name)
10058 .unwrap_or_else(|_| panic!("{test} needs {name} set to an absolute path"));
10059 let path = PathBuf::from(value);
10060 assert!(
10061 path.is_absolute() && path.is_file(),
10062 "{name}={} is not a file",
10063 path.display()
10064 );
10065 path
10066 };
10067 let uv = input("MURMUR_TEST_UV");
10068 let wheel = input("MURMUR_TEST_WHEEL");
10069 let python = std::fs::canonicalize("/usr/bin/python3").expect("host python3");
10070 let stdlib = std::process::Command::new(&python)
10071 .args([
10072 "-I",
10073 "-c",
10074 "import sysconfig; print(sysconfig.get_paths()['stdlib'])",
10075 ])
10076 .output()
10077 .expect("host python3 runs");
10078 let stdlib = String::from_utf8(stdlib.stdout).unwrap().trim().to_string();
10079
10080 let policy = CapabilityPolicy {
10081 shell_allow: vec![
10082 "bash".to_string(),
10083 uv.display().to_string(),
10084 python.display().to_string(),
10085 ],
10086 shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
10087 binary: "python3".to_string(),
10088 dirs: vec![murmur_artifact::InterpreterRuntimeDir {
10089 path: stdlib,
10090 list_dir: true,
10091 }],
10092 }],
10093 ..CapabilityPolicy::default()
10094 };
10095 let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 2) else {
10096 return;
10097 };
10098
10099 let wheel_name = wheel.file_name().unwrap().to_str().unwrap().to_string();
10100 let script = format!(
10101 r#"set -e
10102export UV_CACHE_DIR="$PWD/.uv-cache" UV_PYTHON_DOWNLOADS=never
10103'{uv}' venv --python '{python}' .venv
10104'{uv}' pip install --offline --no-index --python .venv/bin/python './{wheel_name}'
10105.venv/bin/python -c 'import six; print(six.__version__)'
10106"#,
10107 uv = uv.display(),
10108 python = python.display(),
10109 );
10110 let install = |enforcement: &ShellEnforcement| {
10111 let temp = tempfile::tempdir().unwrap();
10112 std::fs::copy(&wheel, temp.path().join(&wheel_name)).unwrap();
10113 run_bash_in(temp.path(), &script, &policy, enforcement)
10114 };
10115
10116 let at_probed = install(&enforcement);
10117 assert_eq!(
10118 at_probed.exit_code, 0,
10119 "uv must install the wheel at ABI {:?}",
10120 enforcement.landlock_abi
10121 );
10122 assert!(at_probed.stdout.contains("1.16.0"), "{}", at_probed.stdout);
10123
10124 let mut at_v1 = enforcement.clone();
10125 at_v1.landlock_abi = Some(1);
10126 let refused = install(&at_v1);
10127 let output = format!("{}{}", refused.stdout, refused.stderr);
10128 assert_ne!(refused.exit_code, 0, "at ABI v1 the install must fail");
10129 assert!(
10130 output.contains("os error 18") || output.contains("Invalid cross-device link"),
10131 "at ABI v1 the install must fail with EXDEV: {output}"
10132 );
10133 }
10134
10135 #[test]
10141 fn kernel_full_runs_nontrivial_shell_allowlist_but_a_pass_here_does_not_prove_the_landlock_fix()
10142 {
10143 if crate::network_namespace::skip_without_egress_namespace("kernel_full_runs_nontrivial_shell_allowlist_but_a_pass_here_does_not_prove_the_landlock_fix") {
10144 return;
10145 }
10146 let tier = detect_enforcement_tier();
10147 if tier != EnforcementTier::KernelFull {
10148 eprintln!(
10149 "SKIP — PROVES NOTHING ABOUT THE LANDLOCK FIX ON THIS HOST: \
10150 kernel_full_runs_nontrivial_shell_allowlist_... requires \
10151 EnforcementTier::KernelFull (Landlock ABI, kernel 5.13+); detected {tier:?}. \
10152 This run does NOT execute the derived-grant Landlock code path at all, so a \
10153 green result here is not evidence the workdir-scope exec bug is fixed — only a \
10154 real KernelFull Linux host can prove that."
10155 );
10156 return;
10157 }
10158
10159 let temp = tempfile::tempdir().unwrap();
10160 let policy = CapabilityPolicy {
10165 shell_allow: vec!["bash".to_string(), "cat".to_string()],
10166 ..CapabilityPolicy::default()
10167 };
10168 let exec_allow_paths = resolve_exec_allowlist(&policy.shell_allow);
10169 let enforcement = ShellEnforcement {
10170 tier,
10171 network_allow_ips: Vec::new(),
10172 unix_sockets_allowed: policy.unix_sockets_allowed,
10173 landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
10174 &exec_allow_paths,
10175 )),
10176 ..host_bounding_base()
10177 };
10178
10179 let result = crate::shell::execute_shell(
10182 "bash",
10183 &["-c", "echo landlock-grant-ok | cat"],
10184 &[],
10185 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
10186 &policy,
10187 &enforcement,
10188 )
10189 .expect("execute_shell should return Ok, not Err");
10190
10191 assert_eq!(
10192 result.exit_code, 0,
10193 "bash + cat are both allowlisted and dynamically linked; the derived Landlock grants \
10194 must let each exec and dynamic-link outside the workdir (stderr: {})",
10195 result.stderr
10196 );
10197 assert!(
10198 result.stdout.contains("landlock-grant-ok"),
10199 "the nested `cat` must actually run and pass its stdin through (stdout: {}, stderr: {})",
10200 result.stdout,
10201 result.stderr
10202 );
10203 }
10204
10205 #[test]
10206 fn kernel_full_denies_write_outside_workdir_but_a_pass_here_does_not_prove_the_landlock_fix() {
10207 if crate::network_namespace::skip_without_egress_namespace("kernel_full_denies_write_outside_workdir_but_a_pass_here_does_not_prove_the_landlock_fix") {
10208 return;
10209 }
10210 let tier = detect_enforcement_tier();
10211 if tier != EnforcementTier::KernelFull {
10212 eprintln!(
10213 "SKIP — PROVES NOTHING ABOUT THE LANDLOCK FIX ON THIS HOST: \
10214 kernel_full_denies_write_outside_workdir_... requires \
10215 EnforcementTier::KernelFull (Landlock ABI, kernel 5.13+); detected {tier:?}. \
10216 This run does NOT execute the Landlock code path, so a green result here is not \
10217 evidence writes outside the workdir are actually denied — only a real KernelFull \
10218 Linux host can prove that."
10219 );
10220 return;
10221 }
10222
10223 let workdir = tempfile::tempdir().unwrap();
10224 let outside = tempfile::tempdir().unwrap();
10227 let target = outside.path().join("should-not-be-written.txt");
10228
10229 let policy = CapabilityPolicy {
10230 shell_allow: vec!["bash".to_string()],
10231 ..CapabilityPolicy::default()
10232 };
10233 let exec_allow_paths = resolve_exec_allowlist(&policy.shell_allow);
10234 let enforcement = ShellEnforcement {
10235 tier,
10236 network_allow_ips: Vec::new(),
10237 unix_sockets_allowed: policy.unix_sockets_allowed,
10238 landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
10239 &exec_allow_paths,
10240 )),
10241 ..host_bounding_base()
10242 };
10243
10244 let script = format!("echo pwned > '{}'", target.display());
10248 let result = crate::shell::execute_shell(
10249 "bash",
10250 &["-c", &script],
10251 &[],
10252 &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
10253 &policy,
10254 &enforcement,
10255 )
10256 .expect("execute_shell should return Ok with a nonzero exit code, not Err");
10257
10258 assert!(
10259 !target.exists(),
10260 "a write to a path outside the Landlock-scoped workdir must be denied under KernelFull"
10261 );
10262 assert_ne!(
10263 result.exit_code, 0,
10264 "bash's redirection to an outside path must fail (Landlock denies the open-for-write)"
10265 );
10266 }
10267
10268 fn kernel_full_enforcement(
10272 tier: EnforcementTier,
10273 policy: &CapabilityPolicy,
10274 ) -> ShellEnforcement {
10275 let exec_allow_paths = resolve_exec_allowlist(&policy.shell_allow);
10276 let mut landlock_grants =
10277 LandlockGrant::non_listable_files(resolve_landlock_grants(&exec_allow_paths));
10278 landlock_grants.extend(resolve_interpreter_runtime_grants(
10279 &policy.shell_interpreter_runtime,
10280 ));
10281 ShellEnforcement {
10282 tier,
10283 network_allow_ips: Vec::new(),
10284 unix_sockets_allowed: policy.unix_sockets_allowed,
10285 landlock_grants,
10286 ..host_bounding_base()
10287 }
10288 }
10289
10290 #[test]
10300 fn home_folder_and_tmp_in_the_records_dir_on_a_sealed_root() {
10301 if crate::network_namespace::skip_without_egress_namespace(
10302 "home_folder_and_tmp_in_the_records_dir_on_a_sealed_root",
10303 ) {
10304 return;
10305 }
10306 let probe = HostProbe::probe();
10307 if probe.tier() != EnforcementTier::KernelSealed {
10308 eprintln!(
10309 "SKIP — PROVES NOTHING ABOUT THE SEALED ROOT ON THIS HOST: \
10310 home_folder_and_tmp_in_the_records_dir_on_a_sealed_root needs a host that reaches \
10311 KernelSealed (unprivileged user namespaces plus a usable Landlock ABI); detected \
10312 {:?}. No composed root is built in this run.",
10313 probe.tier()
10314 );
10315 return;
10316 }
10317
10318 let accessible = tempfile::tempdir().unwrap();
10319 let policy = CapabilityPolicy {
10320 shell_allow: vec![
10321 "bash".to_string(),
10322 "cat".to_string(),
10323 "id".to_string(),
10324 "touch".to_string(),
10325 "getent".to_string(),
10326 "cut".to_string(),
10327 ],
10328 ..CapabilityPolicy::default()
10329 };
10330 let runtime = tempfile::tempdir().unwrap();
10331 let enforcement = ShellEnforcement::resolve_declared(
10332 &policy,
10333 murmur_artifact::ContainmentClass::Sealed,
10334 probe.clone(),
10335 )
10336 .expect("a sealed-capable host resolves sealed enforcement")
10337 .with_runtime_dir(test_runtime_dir(runtime.path()), None);
10338 let child_result = runtime
10341 .path()
10342 .join("children/worker-0123456789abcdef/out/result.txt");
10343 std::fs::create_dir_all(child_result.parent().unwrap()).unwrap();
10344 std::fs::write(&child_result, "the child's answer").unwrap();
10345 assert_eq!(enforcement.tier, EnforcementTier::KernelSealed);
10346 eprintln!(
10347 "home_folder_and_tmp_in_the_records_dir_on_a_sealed_root: tier {:?}, Landlock \
10348 ruleset ABI {:?} (kernel {:?})",
10349 enforcement.tier,
10350 enforcement.landlock_abi,
10351 probe.landlock_kernel_abi()
10352 );
10353 let probe_name = format!("sealed-probe-{}", std::process::id());
10354
10355 let result = crate::shell::execute_shell(
10356 "bash",
10357 &[
10358 "-c",
10359 &format!(
10360 "echo t > /tmp/{probe_name} && cat /tmp/{probe_name} && id -un && \
10361 printf '%s\\n' \"$TMPDIR\" && printf '%s\\n' \"$HOME\" && \
10362 getent passwd \"$(id -u)\" | cut -d: -f6 && cd ~ && touch f && echo touched && \
10363 {{ cat '{child}' 2>&1 || true; }}",
10364 child = child_result.display()
10365 ),
10366 ],
10367 &[],
10368 &crate::workdir_fs::WorkdirRoot::open(accessible.path()).unwrap(),
10369 &policy,
10370 &enforcement,
10371 )
10372 .expect("execute_shell returns Ok even when the command itself fails");
10373
10374 assert_eq!(
10375 result.exit_code, 0,
10376 "stdout: {} stderr: {}",
10377 result.stdout, result.stderr
10378 );
10379 let mut lines = result.stdout.lines();
10380 assert_eq!(
10381 lines.next(),
10382 Some("t"),
10383 "the capsule's /tmp must be writable"
10384 );
10385 assert!(
10386 lines.next().is_some_and(|name| !name.is_empty()),
10387 "`id -un` resolves only through the synthetic /etc/passwd the composed root binds: \
10388 {}",
10389 result.stdout
10390 );
10391 assert_eq!(
10392 lines.next(),
10393 Some("/tmp"),
10394 "TMPDIR names /tmp inside the root"
10395 );
10396 let home = enforcement.home_dir_path().unwrap().to_str().unwrap();
10397 assert_eq!(lines.next(), Some(home), "$HOME is the home folder");
10398 assert_eq!(lines.next(), Some(home), "passwd's home is the home folder");
10399 assert_eq!(
10400 lines.next(),
10401 Some("touched"),
10402 "~ is writable inside the root"
10403 );
10404 let child_read = lines.next().unwrap_or_default();
10405 assert!(
10406 child_read.contains("No such file or directory"),
10407 "a child's result is not in the composed root: {}",
10408 result.stdout
10409 );
10410 assert!(
10411 !result.stdout.contains("the child's answer"),
10412 "{}",
10413 result.stdout
10414 );
10415 assert!(Path::new(home).join("f").is_file());
10416
10417 let folder = enforcement.temp_dir_path().unwrap();
10418 assert_eq!(
10419 std::fs::read_to_string(folder.join(&probe_name)).unwrap(),
10420 "t\n",
10421 "the bytes written to /tmp land in the session's temporary folder"
10422 );
10423 assert!(
10424 !Path::new("/tmp").join(&probe_name).exists(),
10425 "nothing reaches the host's /tmp"
10426 );
10427 assert!(runtime
10428 .path()
10429 .join(crate::runtime_dir::SEALED_ETC_SUBDIR)
10430 .join("passwd")
10431 .is_file());
10432 assert_eq!(
10433 std::fs::read_dir(accessible.path()).unwrap().count(),
10434 0,
10435 "enabling sealed must add nothing to the work folder"
10436 );
10437 }
10438
10439 #[test]
10443 fn kernel_full_interpreter_runtime_list_dir_false_opens_file_but_denies_listing() {
10444 if crate::network_namespace::skip_without_egress_namespace(
10445 "kernel_full_interpreter_runtime_list_dir_false_opens_file_but_denies_listing",
10446 ) {
10447 return;
10448 }
10449 let tier = detect_enforcement_tier();
10450 if tier != EnforcementTier::KernelFull {
10451 eprintln!(
10452 "SKIP — PROVES NOTHING ABOUT THE LANDLOCK FIX ON THIS HOST: \
10453 kernel_full_interpreter_runtime_list_dir_false_... requires \
10454 EnforcementTier::KernelFull (Landlock ABI, kernel 5.13+); detected {tier:?}. \
10455 This run does NOT execute the interpreter_runtime Landlock code path, so a green \
10456 result here is not evidence list_dir:false behaves correctly — only a real \
10457 KernelFull Linux host can prove that."
10458 );
10459 return;
10460 }
10461
10462 let workdir = tempfile::tempdir().unwrap();
10463 let stdlib = tempfile::tempdir().unwrap();
10465 let module = stdlib.path().join("mod.py");
10466 std::fs::write(&module, "x = 1\n").unwrap();
10467
10468 let policy = CapabilityPolicy {
10469 shell_allow: vec!["bash".to_string(), "cat".to_string(), "ls".to_string()],
10470 shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
10471 binary: "bash".to_string(),
10472 dirs: vec![murmur_artifact::InterpreterRuntimeDir {
10473 path: stdlib.path().to_string_lossy().into_owned(),
10474 list_dir: false,
10475 }],
10476 }],
10477 ..CapabilityPolicy::default()
10478 };
10479 let enforcement = kernel_full_enforcement(tier, &policy);
10480
10481 let read = crate::shell::execute_shell(
10484 "bash",
10485 &["-c", &format!("cat '{}'", module.display())],
10486 &[],
10487 &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
10488 &policy,
10489 &enforcement,
10490 )
10491 .expect("execute_shell should return Ok");
10492 assert_eq!(
10493 read.exit_code, 0,
10494 "list_dir:false must still permit opening a known file inside the grant (stderr: {})",
10495 read.stderr
10496 );
10497 assert!(read.stdout.contains("x = 1"), "stdout: {}", read.stdout);
10498
10499 let list = crate::shell::execute_shell(
10501 "bash",
10502 &["-c", &format!("ls '{}'", stdlib.path().display())],
10503 &[],
10504 &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
10505 &policy,
10506 &enforcement,
10507 )
10508 .expect("execute_shell should return Ok with a nonzero exit code");
10509 assert_ne!(
10510 list.exit_code, 0,
10511 "list_dir:false must deny enumerating the granted directory's own entries"
10512 );
10513 }
10514
10515 #[test]
10516 fn kernel_full_interpreter_runtime_list_dir_true_lists_dir_but_not_its_parent() {
10517 if crate::network_namespace::skip_without_egress_namespace(
10518 "kernel_full_interpreter_runtime_list_dir_true_lists_dir_but_not_its_parent",
10519 ) {
10520 return;
10521 }
10522 let tier = detect_enforcement_tier();
10523 if tier != EnforcementTier::KernelFull {
10524 eprintln!(
10525 "SKIP — PROVES NOTHING ABOUT THE LANDLOCK FIX ON THIS HOST: \
10526 kernel_full_interpreter_runtime_list_dir_true_... requires \
10527 EnforcementTier::KernelFull (Landlock ABI, kernel 5.13+); detected {tier:?}. \
10528 This run does NOT execute the interpreter_runtime Landlock code path, so a green \
10529 result here is not evidence list_dir:true (and its non-widening to the parent) \
10530 behaves correctly — only a real KernelFull Linux host can prove that."
10531 );
10532 return;
10533 }
10534
10535 let workdir = tempfile::tempdir().unwrap();
10536 let parent = tempfile::tempdir().unwrap();
10538 let stdlib = parent.path().join("stdlib");
10539 std::fs::create_dir(&stdlib).unwrap();
10540 std::fs::write(stdlib.join("mod.py"), "x = 1\n").unwrap();
10541
10542 let policy = CapabilityPolicy {
10543 shell_allow: vec!["bash".to_string(), "ls".to_string()],
10544 shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
10545 binary: "bash".to_string(),
10546 dirs: vec![murmur_artifact::InterpreterRuntimeDir {
10547 path: stdlib.to_string_lossy().into_owned(),
10548 list_dir: true,
10549 }],
10550 }],
10551 ..CapabilityPolicy::default()
10552 };
10553 let enforcement = kernel_full_enforcement(tier, &policy);
10554
10555 let list = crate::shell::execute_shell(
10557 "bash",
10558 &["-c", &format!("ls '{}'", stdlib.display())],
10559 &[],
10560 &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
10561 &policy,
10562 &enforcement,
10563 )
10564 .expect("execute_shell should return Ok");
10565 assert_eq!(
10566 list.exit_code, 0,
10567 "list_dir:true must permit enumerating the granted directory (stderr: {})",
10568 list.stderr
10569 );
10570 assert!(list.stdout.contains("mod.py"), "stdout: {}", list.stdout);
10571
10572 let list_parent = crate::shell::execute_shell(
10575 "bash",
10576 &["-c", &format!("ls '{}'", parent.path().display())],
10577 &[],
10578 &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
10579 &policy,
10580 &enforcement,
10581 )
10582 .expect("execute_shell should return Ok with a nonzero exit code");
10583 assert_ne!(
10584 list_parent.exit_code, 0,
10585 "a grant on stdlib/ must not make its (ungranted) parent enumerable"
10586 );
10587 }
10588
10589 #[test]
10600 fn kernel_full_non_executable_grant_lists_its_tree_but_refuses_to_run_from_it() {
10601 if crate::network_namespace::skip_without_egress_namespace(
10602 "kernel_full_non_executable_grant_lists_its_tree_but_refuses_to_run_from_it",
10603 ) {
10604 return;
10605 }
10606 let host_tier = detect_enforcement_tier();
10607 if !matches!(
10608 host_tier,
10609 EnforcementTier::KernelFull | EnforcementTier::KernelSealed
10610 ) {
10611 eprintln!(
10612 "SKIP — PROVES NOTHING ABOUT THE LANDLOCK FIX ON THIS HOST: \
10613 kernel_full_non_executable_grant_... needs a usable Landlock ABI (kernel 5.13+); \
10614 detected {host_tier:?}. This run does NOT install a Landlock domain, so a green \
10615 result is not evidence that withholding Execute denies execve."
10616 );
10617 return;
10618 }
10619
10620 let workdir = tempfile::tempdir().unwrap();
10621 let tree = tempfile::tempdir().unwrap();
10623 std::fs::write(tree.path().join("entry.txt"), "x\n").unwrap();
10624 let runme = tree.path().join("runme");
10625 std::fs::copy("/bin/echo", &runme).unwrap();
10626
10627 let policy = CapabilityPolicy {
10628 shell_allow: vec!["bash".to_string(), "ls".to_string()],
10629 ..CapabilityPolicy::default()
10630 };
10631 let with_tree_grant = |executable: bool| {
10632 let mut enforcement = kernel_full_enforcement(EnforcementTier::KernelFull, &policy);
10633 enforcement.landlock_grants.push(LandlockGrant {
10634 path: tree.path().to_path_buf(),
10635 list_dir: true,
10636 executable,
10637 });
10638 enforcement
10639 };
10640 let run = |enforcement: &ShellEnforcement, command: String| {
10641 crate::shell::execute_shell(
10642 "bash",
10643 &["-c", &command],
10644 &[],
10645 &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
10646 &policy,
10647 enforcement,
10648 )
10649 .expect("execute_shell should return Ok even when the command itself fails")
10650 };
10651
10652 let denied = with_tree_grant(false);
10653 let list = run(&denied, format!("ls '{}'", tree.path().display()));
10654 assert_eq!(
10655 list.exit_code, 0,
10656 "a non-executable grant must still be enumerable — that is its entire purpose \
10657 (stderr: {})",
10658 list.stderr
10659 );
10660 assert!(list.stdout.contains("entry.txt"), "stdout: {}", list.stdout);
10661
10662 let exec_denied = run(&denied, format!("'{}' ran", runme.display()));
10663 assert_ne!(
10664 exec_denied.exit_code, 0,
10665 "withholding Execute must deny execve inside the granted tree; a program ran instead \
10666 (stdout: {})",
10667 exec_denied.stdout
10668 );
10669 assert!(
10670 !exec_denied.stdout.contains("ran"),
10671 "the binary produced output, so it executed: {}",
10672 exec_denied.stdout
10673 );
10674
10675 let allowed = with_tree_grant(true);
10678 let exec_allowed = run(&allowed, format!("'{}' ran", runme.display()));
10679 assert_eq!(
10680 exec_allowed.exit_code, 0,
10681 "the same tree granted Execute must run (stderr: {})",
10682 exec_allowed.stderr
10683 );
10684 assert!(
10685 exec_allowed.stdout.contains("ran"),
10686 "stdout: {}",
10687 exec_allowed.stdout
10688 );
10689 }
10690
10691 #[test]
10704 fn kernel_full_non_executable_file_grant_is_readable_and_survives_a_bad_list_dir_claim() {
10705 if crate::network_namespace::skip_without_egress_namespace(
10706 "kernel_full_non_executable_file_grant_is_readable_and_survives_a_bad_list_dir_claim",
10707 ) {
10708 return;
10709 }
10710 let host_tier = detect_enforcement_tier();
10711 if !matches!(
10712 host_tier,
10713 EnforcementTier::KernelFull | EnforcementTier::KernelSealed
10714 ) {
10715 eprintln!(
10716 "SKIP — PROVES NOTHING ABOUT THE LANDLOCK FIX ON THIS HOST: \
10717 kernel_full_non_executable_file_grant_... needs a usable Landlock ABI (kernel \
10718 5.13+); detected {host_tier:?}. This run does NOT install a Landlock domain, so a \
10719 green result is not evidence that a file grant reads, nor that a ReadDir claim on \
10720 a file is narrowed instead of refusing the launch."
10721 );
10722 return;
10723 }
10724
10725 let workdir = tempfile::tempdir().unwrap();
10726 let store = tempfile::tempdir().unwrap();
10728 let bundle = store.path().join("ca-certificates.crt");
10729 std::fs::write(&bundle, "-----BEGIN CERTIFICATE-----\n").unwrap();
10730
10731 let policy = CapabilityPolicy {
10732 shell_allow: vec!["bash".to_string(), "cat".to_string()],
10733 ..CapabilityPolicy::default()
10734 };
10735 let with_file_grant = |list_dir: bool| {
10736 let mut enforcement = kernel_full_enforcement(EnforcementTier::KernelFull, &policy);
10737 enforcement.landlock_grants.push(LandlockGrant {
10738 path: bundle.clone(),
10739 list_dir,
10740 executable: false,
10741 });
10742 enforcement
10743 };
10744 let read = |enforcement: &ShellEnforcement| {
10745 crate::shell::execute_shell(
10746 "bash",
10747 &["-c", &format!("cat '{}'", bundle.display())],
10748 &[],
10749 &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
10750 &policy,
10751 enforcement,
10752 )
10753 .expect("execute_shell should return Ok even when the command itself fails")
10754 };
10755
10756 let honest = read(&with_file_grant(false));
10757 assert_eq!(
10758 honest.exit_code, 0,
10759 "ReadFile alone must open a granted regular file — this is what the /etc allowlist's \
10760 file entries rely on (stderr: {})",
10761 honest.stderr
10762 );
10763 assert!(
10764 honest.stdout.contains("BEGIN CERTIFICATE"),
10765 "stdout: {}",
10766 honest.stdout
10767 );
10768
10769 let overclaimed = read(&with_file_grant(true));
10770 assert_eq!(
10771 overclaimed.exit_code, 0,
10772 "a list_dir:true claim on a regular file must be narrowed to ReadFile, not turned \
10773 into an EINVAL that refuses the launch (stderr: {})",
10774 overclaimed.stderr
10775 );
10776 assert!(
10777 overclaimed.stdout.contains("BEGIN CERTIFICATE"),
10778 "stdout: {}",
10779 overclaimed.stdout
10780 );
10781 }
10782
10783 struct ForceNoNewPrivsFailureGuard;
10793
10794 impl ForceNoNewPrivsFailureGuard {
10795 fn new() -> Self {
10796 FORCE_NO_NEW_PRIVS_FAILURE.with(|flag| flag.set(true));
10797 Self
10798 }
10799 }
10800
10801 impl Drop for ForceNoNewPrivsFailureGuard {
10802 fn drop(&mut self) {
10803 FORCE_NO_NEW_PRIVS_FAILURE.with(|flag| flag.set(false));
10804 }
10805 }
10806
10807 struct ForceLandlockFailureGuard;
10808
10809 impl ForceLandlockFailureGuard {
10810 fn new() -> Self {
10811 FORCE_LANDLOCK_FAILURE.with(|flag| flag.set(true));
10812 Self
10813 }
10814 }
10815
10816 impl Drop for ForceLandlockFailureGuard {
10817 fn drop(&mut self) {
10818 FORCE_LANDLOCK_FAILURE.with(|flag| flag.set(false));
10819 }
10820 }
10821
10822 fn kernel_full_empty_grants() -> ShellEnforcement {
10826 ShellEnforcement {
10827 tier: EnforcementTier::KernelFull,
10828 network_allow_ips: Vec::new(),
10829 unix_sockets_allowed: false,
10830 landlock_grants: Vec::new(),
10831 ..host_bounding_base()
10832 }
10833 }
10834
10835 fn workdir_resolution_error() -> String {
10839 let temp = tempfile::tempdir().unwrap();
10840 let missing = temp.path().join("does-not-exist-workdir");
10841 let marker = temp.path().join("never-spawned.marker");
10842 let mut command = std::process::Command::new("bash");
10843 command
10844 .arg("-c")
10845 .arg(format!("echo ran > '{}'", marker.display()));
10846
10847 let error = prepare_enforcement(&mut command, &kernel_full_empty_grants(), &missing)
10848 .expect_err("an unresolvable workdir must fail before fork()");
10849 assert!(
10850 !marker.exists(),
10851 "prepare_enforcement must never spawn a subprocess: {error}"
10852 );
10853 error
10854 }
10855
10856 fn child_setup_failure_error<G>(arm: impl FnOnce() -> G) -> String {
10860 child_setup_failure_typed(&kernel_full_empty_grants(), arm).to_string()
10861 }
10862
10863 fn child_setup_failure_typed<G>(
10868 enforcement: &ShellEnforcement,
10869 arm: impl FnOnce() -> G,
10870 ) -> crate::shell::ShellExecError {
10871 let temp = tempfile::tempdir().unwrap();
10872 let policy = CapabilityPolicy {
10873 shell_allow: vec!["bash".to_string()],
10874 ..CapabilityPolicy::default()
10875 };
10876 let marker = temp.path().join("spawned-anyway.marker");
10877 let script = format!("echo ran > '{}'", marker.display());
10878
10879 let _guard = arm();
10880 let error = crate::shell::execute_shell(
10881 "bash",
10882 &["-c", &script],
10883 &[],
10884 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
10885 &policy,
10886 enforcement,
10887 )
10888 .expect_err("a forced child-side setup failure must make execute_shell return Err");
10889 assert!(
10890 !marker.exists(),
10891 "no subprocess may run when pre_exec setup fails: {error}"
10892 );
10893 error
10894 }
10895
10896 #[test]
10897 fn pre_exec_workdir_resolution_failure_is_caught_before_fork() {
10898 let error = workdir_resolution_error();
10899 assert!(
10900 error.contains("workdir") && error.contains("does-not-exist-workdir"),
10901 "the error must name the workdir path that failed to resolve: {error}"
10902 );
10903 assert!(
10904 !error.contains("os error 22"),
10905 "must not collapse to the bare EINVAL string: {error}"
10906 );
10907 }
10908
10909 #[test]
10910 fn pre_exec_no_new_privs_failure_is_distinct_and_fails_closed() {
10911 if crate::network_namespace::skip_without_egress_namespace(
10912 "pre_exec_no_new_privs_failure_is_distinct_and_fails_closed",
10913 ) {
10914 return;
10915 }
10916 let error = child_setup_failure_error(ForceNoNewPrivsFailureGuard::new);
10917 assert!(
10918 error.contains("no_new_privs"),
10919 "the error must name the no_new_privs step specifically: {error}"
10920 );
10921 assert!(
10922 !error.contains("os error 22"),
10923 "must not collapse to the bare EINVAL string: {error}"
10924 );
10925 }
10926
10927 #[test]
10928 fn pre_exec_landlock_failure_is_distinct_and_fails_closed() {
10929 if crate::network_namespace::skip_without_egress_namespace(
10930 "pre_exec_landlock_failure_is_distinct_and_fails_closed",
10931 ) {
10932 return;
10933 }
10934 let error = child_setup_failure_error(ForceLandlockFailureGuard::new);
10935 assert!(
10936 error.contains("landlock"),
10937 "the error must name the Landlock construction step specifically: {error}"
10938 );
10939 assert!(
10940 !error.contains("os error 22"),
10941 "must not collapse to the bare EINVAL string: {error}"
10942 );
10943 }
10944
10945 #[test]
10946 fn pre_exec_setup_failures_produce_pairwise_distinct_messages() {
10947 if crate::network_namespace::skip_without_egress_namespace(
10948 "pre_exec_setup_failures_produce_pairwise_distinct_messages",
10949 ) {
10950 return;
10951 }
10952 let workdir_msg = workdir_resolution_error();
10953 let no_new_privs_msg = child_setup_failure_error(ForceNoNewPrivsFailureGuard::new);
10954 let landlock_msg = child_setup_failure_error(ForceLandlockFailureGuard::new);
10955
10956 let messages = [
10957 ("workdir", &workdir_msg),
10958 ("no_new_privs", &no_new_privs_msg),
10959 ("landlock", &landlock_msg),
10960 ];
10961 for (i, (left_name, left)) in messages.iter().enumerate() {
10962 for (right_name, right) in &messages[i + 1..] {
10963 assert_ne!(left, right, "{left_name} vs {right_name} must differ");
10964 }
10965 }
10966
10967 for message in [&workdir_msg, &no_new_privs_msg, &landlock_msg] {
10968 assert!(!message.is_empty(), "a distinct message must not be empty");
10969 assert!(
10970 !message.contains("os error 22"),
10971 "no failure may read as a bare EINVAL: {message}"
10972 );
10973 }
10974 }
10975
10976 #[test]
10982 fn a_composed_root_failure_is_typed_and_session_fatal_not_just_another_message() {
10983 if crate::network_namespace::skip_without_egress_namespace(
10984 "a_composed_root_failure_is_typed_and_session_fatal_not_just_another_message",
10985 ) {
10986 return;
10987 }
10988 let error =
10989 child_setup_failure_typed(&sealed_test_enforcement(), ForceSealedRootFailureGuard::new);
10990
10991 assert!(
10992 matches!(
10993 error,
10994 crate::shell::ShellExecError::SealedRootConstructionFailed { .. }
10995 ),
10996 "a sealed-root diagnostic must survive as its own variant, not collapse into \
10997 ShellExecError::Failed: {error}"
10998 );
10999 let fatal = error
11000 .session_fatal()
11001 .expect("a composed-root failure must end the session, not just the tool call");
11002 assert!(
11003 matches!(
11004 fatal,
11005 crate::errors::RuntimeError::SealedRootConstructionFailed { .. }
11006 ),
11007 "must map to the RuntimeError the CLI renders as E-RUN-014: {fatal}"
11008 );
11009 let rendered = error.to_string();
11010 assert!(
11011 rendered.contains("composed root") && rendered.contains("unshare"),
11012 "the message must name both the mechanism and the step that failed: {rendered}"
11013 );
11014 assert!(
11015 !rendered.contains("os error 22"),
11016 "must not collapse to the bare EINVAL string: {rendered}"
11017 );
11018 }
11019
11020 #[test]
11024 fn an_ordinary_pre_exec_failure_is_not_session_fatal() {
11025 let landlock =
11026 child_setup_failure_typed(&kernel_full_empty_grants(), ForceLandlockFailureGuard::new);
11027 assert!(
11028 landlock.session_fatal().is_none(),
11029 "a Landlock setup failure is the tool call's problem, not the session's: {landlock}"
11030 );
11031
11032 let no_new_privs = child_setup_failure_typed(
11033 &kernel_full_empty_grants(),
11034 ForceNoNewPrivsFailureGuard::new,
11035 );
11036 assert!(
11037 no_new_privs.session_fatal().is_none(),
11038 "a no_new_privs failure is the tool call's problem, not the session's: {no_new_privs}"
11039 );
11040 }
11041
11042 fn server_policy(extra: &[&str]) -> CapabilityPolicy {
11050 CapabilityPolicy {
11051 shell_allow: ["bash", "nc", "sleep"]
11052 .iter()
11053 .chain(extra)
11054 .map(|binary| binary.to_string())
11055 .collect(),
11056 ..CapabilityPolicy::default()
11057 }
11058 }
11059
11060 fn session_enforcement(policy: &CapabilityPolicy) -> ShellEnforcement {
11062 let enforcement = ShellEnforcement::resolve_declared(
11063 policy,
11064 murmur_artifact::ContainmentClass::Scoped,
11065 HostProbe::probe(),
11066 )
11067 .expect("the policy resolves")
11068 .with_test_session_dirs();
11069 assert_eq!(
11070 enforcement.tier,
11071 EnforcementTier::KernelFull,
11072 "a scoped capsule on this host runs on KernelFull"
11073 );
11074 enforcement
11075 }
11076
11077 fn one_shot_server(reply: &str, port: u16) -> String {
11080 format!(
11081 "(printf \"{reply}\\n\" | nc -N -l 127.0.0.1 {port}) >/dev/null 2>&1 & echo $! > server.pid"
11082 )
11083 }
11084
11085 fn retrying_client(port: u16) -> String {
11088 format!(
11089 "for i in {{1..50}}; do exec 3<>/dev/tcp/127.0.0.1/{port} 2>/dev/null && break; \
11090 sleep 0.1; done; read -r reply <&3; echo \"$reply\""
11091 )
11092 }
11093
11094 fn bash(
11095 script: &str,
11096 workdir: &Path,
11097 policy: &CapabilityPolicy,
11098 enforcement: &ShellEnforcement,
11099 ) -> Result<crate::shell::ShellResult, crate::shell::ShellExecError> {
11100 crate::shell::execute_shell(
11101 "bash",
11102 &["-c", script],
11103 &[],
11104 &crate::workdir_fs::WorkdirRoot::open(workdir).unwrap(),
11105 policy,
11106 enforcement,
11107 )
11108 }
11109
11110 fn pid_in(workdir: &Path, file: &str) -> i32 {
11111 std::fs::read_to_string(workdir.join(file))
11112 .unwrap_or_else(|error| panic!("{file} was not written: {error}"))
11113 .trim()
11114 .parse()
11115 .unwrap_or_else(|error| panic!("{file} holds no pid: {error}"))
11116 }
11117
11118 fn process_exists(pid: i32) -> bool {
11119 Path::new(&format!("/proc/{pid}")).exists()
11120 }
11121
11122 fn process_gone_within(pid: i32, timeout: std::time::Duration) -> bool {
11125 let deadline = std::time::Instant::now() + timeout;
11126 while process_exists(pid) {
11127 if std::time::Instant::now() >= deadline {
11128 return false;
11129 }
11130 std::thread::sleep(std::time::Duration::from_millis(20));
11131 }
11132 true
11133 }
11134
11135 #[test]
11137 #[allow(clippy::print_stdout)]
11138 fn a_listener_started_by_one_command_answers_the_next() {
11139 if crate::network_namespace::skip_without_egress_namespace(
11140 "a_listener_started_by_one_command_answers_the_next",
11141 ) {
11142 return;
11143 }
11144 let temp = tempfile::tempdir().unwrap();
11145 let policy = server_policy(&[]);
11146 let enforcement = session_enforcement(&policy);
11147
11148 let started = std::time::Instant::now();
11149 let server = bash(
11150 &one_shot_server("pong", 8080),
11151 temp.path(),
11152 &policy,
11153 &enforcement,
11154 )
11155 .expect("the server command runs");
11156 let server_ms = started.elapsed().as_millis();
11157 assert_eq!(server.exit_code, 0, "stderr: {}", server.stderr);
11158 assert!(
11159 started.elapsed() < std::time::Duration::from_secs(5),
11160 "a backgrounded server with its output redirected must not hold the command open"
11161 );
11162
11163 let client = bash(&retrying_client(8080), temp.path(), &policy, &enforcement)
11164 .expect("the client command runs");
11165 println!(
11166 "tier {:?}; command 1 exit {} in {server_ms} ms; command 2 exit {} stdout {:?}; \
11167 namespaces created: {}",
11168 enforcement.tier,
11169 server.exit_code,
11170 client.exit_code,
11171 client.stdout.trim(),
11172 enforcement.session_netns.creations()
11173 );
11174 assert_eq!(client.exit_code, 0, "stderr: {}", client.stderr);
11175 assert_eq!(client.stdout.trim(), "pong");
11176 assert_eq!(enforcement.session_netns.creations(), 1);
11177
11178 enforcement.end_session();
11179 }
11180
11181 #[test]
11184 #[allow(clippy::print_stdout)]
11185 fn a_demoted_server_stays_reachable_from_later_commands() {
11186 use crate::detached::{DetachPolicy, DetachedRegistry, DetachedReport};
11187
11188 if crate::network_namespace::skip_without_egress_namespace(
11189 "a_demoted_server_stays_reachable_from_later_commands",
11190 ) {
11191 return;
11192 }
11193 let temp = tempfile::tempdir().unwrap();
11194 let base = tempfile::tempdir().unwrap();
11195 let policy = server_policy(&[]);
11196 let enforcement =
11197 session_enforcement(&policy).with_runtime_dir(test_runtime_dir(base.path()), None);
11198 let (registry, mut reports) = DetachedRegistry::new();
11199
11200 let outcome = crate::shell::run_shell(
11201 "nc",
11202 &["-l", "127.0.0.1", "8080"],
11203 &[],
11204 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
11205 &policy,
11206 &enforcement,
11207 Some(DetachPolicy {
11208 grace: Some(std::time::Duration::ZERO),
11209 delivered: true,
11210 registry: Arc::clone(®istry),
11211 command: "-l 127.0.0.1 8080".to_string(),
11212 context_id: "ctx_demoted_server".to_string(),
11213 provenance: None,
11214 abandoned: None,
11215 canceled: None,
11216 }),
11217 )
11218 .expect("the server command runs");
11219 let crate::shell::ShellOutcome::Detached(info) = outcome else {
11220 panic!("a zero grace period demotes the server, got {outcome:?}");
11221 };
11222
11223 let client = bash(
11224 "for i in {1..50}; do exec 3<>/dev/tcp/127.0.0.1/8080 2>/dev/null && break; \
11225 sleep 0.1; done; echo ping >&3",
11226 temp.path(),
11227 &policy,
11228 &enforcement,
11229 )
11230 .expect("the client command runs");
11231 assert_eq!(client.exit_code, 0, "stderr: {}", client.stderr);
11232
11233 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
11234 let completion = loop {
11235 match reports.try_recv() {
11236 Ok(DetachedReport::Completed(completion)) => break completion,
11237 Ok(DetachedReport::Lost(_)) => {}
11238 Err(_) if std::time::Instant::now() < deadline => {
11239 std::thread::sleep(std::time::Duration::from_millis(20));
11240 }
11241 Err(error) => panic!("the demoted server never completed: {error}"),
11242 }
11243 };
11244 let log = std::fs::read_to_string(&completion.output_path)
11245 .expect("the demoted command's log is written");
11246 println!(
11247 "demoted as {}; client exit {}; completion {} exit {}; log has ping: {}",
11248 info.work_id,
11249 client.exit_code,
11250 completion.work_id,
11251 completion.exit_code,
11252 log.contains("ping")
11253 );
11254 assert_eq!(completion.work_id, info.work_id);
11255 assert_eq!(completion.exit_code, 0, "log: {log}");
11256 assert!(log.contains("ping"), "log: {log}");
11257
11258 enforcement.end_session();
11259 }
11260
11261 #[test]
11266 #[allow(clippy::print_stdout)]
11267 fn a_truncated_log_reads_back_through_the_shell_tool_and_refuses_a_write() {
11268 if crate::network_namespace::skip_without_egress_namespace(
11271 "a_truncated_log_reads_back_through_the_shell_tool_and_refuses_a_write",
11272 ) {
11273 return;
11274 }
11275 let base = home_tempdir();
11276 let workdir = home_tempdir();
11277 let policy = CapabilityPolicy {
11278 shell_allow: vec!["bash".to_string(), "grep".to_string()],
11279 ..CapabilityPolicy::default()
11280 };
11281 let enforcement = ShellEnforcement::resolve_declared(
11282 &policy,
11283 murmur_artifact::ContainmentClass::Scoped,
11284 HostProbe::probe(),
11285 )
11286 .expect("the policy resolves")
11287 .with_runtime_dir(test_runtime_dir(base.path()), None);
11288 let landlock = matches!(
11289 enforcement.tier,
11290 EnforcementTier::KernelFull | EnforcementTier::KernelSealed
11291 );
11292 println!(
11293 "tier {:?}; landlock ABI {:?}",
11294 enforcement.tier,
11295 detect_landlock_abi()
11296 );
11297
11298 let printed = run_bash_in(
11300 workdir.path(),
11301 "for ((i = 1; i <= 20000; i++)); do echo $i; done",
11302 &policy,
11303 &enforcement,
11304 );
11305 assert_eq!(printed.exit_code, 0, "{}", printed.stderr);
11306 assert!(printed.truncated);
11307 let path = printed
11308 .full_output_path
11309 .clone()
11310 .expect("a truncated command names its log");
11311 assert!(path.starts_with(enforcement.records_dir_path().unwrap().to_str().unwrap()));
11312 let before = std::fs::read(&path).unwrap();
11313
11314 let through_bash = run_bash_in(
11315 workdir.path(),
11316 &format!("grep -x 20000 {path}"),
11317 &policy,
11318 &enforcement,
11319 );
11320 assert_eq!(through_bash.exit_code, 0, "{}", through_bash.stderr);
11321 assert_eq!(through_bash.stdout, "20000\n");
11322
11323 let through_grep = crate::shell::execute_shell(
11324 "grep",
11325 &["-x", "20000", &path],
11326 &[],
11327 &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
11328 &policy,
11329 &enforcement,
11330 )
11331 .unwrap();
11332 assert_eq!(through_grep.exit_code, 0, "{}", through_grep.stderr);
11333 assert_eq!(through_grep.stdout, "20000\n");
11334
11335 if landlock {
11336 let write = run_bash_in(
11337 workdir.path(),
11338 &format!("echo x >> {path}"),
11339 &policy,
11340 &enforcement,
11341 );
11342 assert_ne!(write.exit_code, 0, "a command appended to its own log");
11343 assert_eq!(std::fs::read(&path).unwrap(), before);
11344 } else {
11345 println!(
11346 "[SKIP-HOST] a_truncated_log_reads_back_through_the_shell_tool_and_refuses_a_write: \
11347 the write half needs Landlock; detected {:?}",
11348 enforcement.tier
11349 );
11350 }
11351 assert!(!workdir.path().join("logs").exists());
11352 enforcement.end_session();
11353 }
11354
11355 #[test]
11358 #[allow(clippy::print_stdout)]
11359 fn two_sessions_do_not_share_loopback() {
11360 if crate::network_namespace::skip_without_egress_namespace(
11361 "two_sessions_do_not_share_loopback",
11362 ) {
11363 return;
11364 }
11365 let port = {
11366 let probe = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
11367 probe.local_addr().unwrap().port()
11368 };
11369 let policy = server_policy(&[]);
11370 let (dir_a, dir_b) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
11371 let session_a = session_enforcement(&policy);
11372 let session_b = session_enforcement(&policy);
11373
11374 for (dir, session, reply) in [
11375 (&dir_a, &session_a, "pong-a"),
11376 (&dir_b, &session_b, "pong-b"),
11377 ] {
11378 let server = bash(&one_shot_server(reply, port), dir.path(), &policy, session)
11379 .expect("the server command runs");
11380 assert_eq!(server.exit_code, 0, "stderr: {}", server.stderr);
11381 }
11382 std::thread::sleep(std::time::Duration::from_millis(300));
11384 let server_in = |dir: &tempfile::TempDir, session: &ShellEnforcement| -> Option<i32> {
11389 let join = session
11390 .session_netns
11391 .establish(
11392 &session.egress_tcp_ports,
11393 crate::egress_proxy::EgressPolicy::new(
11394 session.network_allow_rules.clone(),
11395 session.network_allow_ips.clone(),
11396 ),
11397 )
11398 .expect("the session's namespace is established");
11399 let pid_ns = std::fs::read_link(format!("/proc/self/fd/{}", join.pid_ns_fd()))
11400 .expect("the session's PID namespace handle is readable");
11401 let namespace_pid = pid_in(dir.path(), "server.pid").to_string();
11402 std::fs::read_dir("/proc")
11403 .expect("/proc is readable")
11404 .filter_map(Result::ok)
11405 .filter_map(|entry| entry.file_name().to_str()?.parse::<i32>().ok())
11406 .find(|pid| {
11407 std::fs::read_link(format!("/proc/{pid}/ns/pid"))
11408 .is_ok_and(|namespace| namespace == pid_ns)
11409 && std::fs::read_to_string(format!("/proc/{pid}/status")).is_ok_and(
11410 |status| {
11411 status
11412 .lines()
11413 .find_map(|line| line.strip_prefix("NSpid:"))
11414 .and_then(|fields| fields.split_whitespace().last())
11415 == Some(namespace_pid.as_str())
11416 },
11417 )
11418 })
11419 };
11420 let servers: std::collections::HashSet<i32> = [(&dir_a, &session_a), (&dir_b, &session_b)]
11421 .into_iter()
11422 .filter_map(|(dir, session)| server_in(dir, session))
11423 .collect();
11424 assert_eq!(
11425 servers.len(),
11426 2,
11427 "a server's bind on {port} failed: {servers:?}"
11428 );
11429
11430 let host = std::net::TcpStream::connect(("127.0.0.1", port));
11431 let host_error = host.expect_err("the host must not reach a session's loopback server");
11432 assert_eq!(host_error.kind(), std::io::ErrorKind::ConnectionRefused);
11433
11434 let client_b = bash(&retrying_client(port), dir_b.path(), &policy, &session_b)
11435 .expect("B's client runs");
11436 assert_eq!(client_b.exit_code, 0, "stderr: {}", client_b.stderr);
11437
11438 let second_b = bash(
11440 &format!("exec 3<>/dev/tcp/127.0.0.1/{port}"),
11441 dir_b.path(),
11442 &policy,
11443 &session_b,
11444 )
11445 .expect("B's second client runs");
11446
11447 let client_a = bash(&retrying_client(port), dir_a.path(), &policy, &session_a)
11448 .expect("A's client runs");
11449 println!(
11450 "port {port}; host connect: {host_error}; B read {:?}; B's second client exit {} \
11451 stderr {:?}; A read {:?}",
11452 client_b.stdout.trim(),
11453 second_b.exit_code,
11454 second_b.stderr.trim(),
11455 client_a.stdout.trim()
11456 );
11457 assert_eq!(client_b.stdout.trim(), "pong-b");
11458 assert_ne!(second_b.exit_code, 0);
11459 assert!(
11460 second_b.stderr.contains("Connection refused"),
11461 "stderr: {}",
11462 second_b.stderr
11463 );
11464 assert_eq!(client_a.exit_code, 0, "stderr: {}", client_a.stderr);
11465 assert_eq!(client_a.stdout.trim(), "pong-a");
11466
11467 session_a.end_session();
11468 session_b.end_session();
11469 }
11470
11471 #[test]
11474 #[allow(clippy::print_stdout)]
11475 fn the_session_proxy_serves_every_command() {
11476 if crate::network_namespace::skip_without_egress_namespace(
11477 "the_session_proxy_serves_every_command",
11478 ) {
11479 return;
11480 }
11481 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
11483 let port = listener.local_addr().unwrap().port();
11484 std::thread::spawn(move || {
11485 for stream in listener.incoming() {
11486 use std::io::Write;
11487 if let Ok(mut stream) = stream {
11488 let _ = stream.write_all(b"pong\n");
11489 }
11490 }
11491 });
11492
11493 let temp = tempfile::tempdir().unwrap();
11494 let policy = CapabilityPolicy {
11495 network_allow: vec![format!("http://127.0.0.1:{port}")],
11496 ..server_policy(&[])
11497 };
11498 let enforcement = session_enforcement(&policy);
11499 let through_proxy =
11500 format!("exec 3<>/dev/tcp/127.0.0.1/{port} && read -r reply <&3 && echo \"$reply\"");
11501
11502 let first = bash(&through_proxy, temp.path(), &policy, &enforcement).unwrap();
11503 let unlisted = bash(
11504 "exec 3<>/dev/tcp/10.255.255.1/9",
11505 temp.path(),
11506 &policy,
11507 &enforcement,
11508 )
11509 .unwrap();
11510 let third = bash(&through_proxy, temp.path(), &policy, &enforcement).unwrap();
11511 println!(
11512 "first {:?} exit {}; unlisted exit {} stderr {:?}; third {:?} exit {}; namespaces \
11513 created: {}",
11514 first.stdout.trim(),
11515 first.exit_code,
11516 unlisted.exit_code,
11517 unlisted.stderr.trim(),
11518 third.stdout.trim(),
11519 third.exit_code,
11520 enforcement.session_netns.creations()
11521 );
11522 assert_eq!(first.exit_code, 0, "stderr: {}", first.stderr);
11523 assert_eq!(first.stdout.trim(), "pong");
11524 assert_ne!(unlisted.exit_code, 0, "an unlisted destination must fail");
11525 assert_eq!(third.exit_code, 0, "stderr: {}", third.stderr);
11526 assert_eq!(third.stdout.trim(), "pong");
11527 assert_eq!(enforcement.session_netns.creations(), 1);
11528
11529 enforcement.end_session();
11530 }
11531
11532 #[test]
11535 #[allow(clippy::print_stdout)]
11536 fn concurrent_first_commands_share_one_namespace() {
11537 if crate::network_namespace::skip_without_egress_namespace(
11538 "concurrent_first_commands_share_one_namespace",
11539 ) {
11540 return;
11541 }
11542 let temp = tempfile::tempdir().unwrap();
11543 let policy = server_policy(&[]);
11544 let enforcement = session_enforcement(&policy);
11545 let servers = (8081..=8083)
11548 .map(|port| one_shot_server("pong", port).replace("server.pid", &format!("{port}.pid")))
11549 .collect::<Vec<_>>()
11550 .join("; ");
11551 let scripts = [
11552 servers,
11553 retrying_client(8081),
11554 retrying_client(8082),
11555 retrying_client(8083),
11556 ];
11557
11558 let barrier = std::sync::Barrier::new(scripts.len());
11559 let results: Vec<crate::shell::ShellResult> = std::thread::scope(|scope| {
11560 let handles: Vec<_> = scripts
11561 .iter()
11562 .map(|script| {
11563 let enforcement = enforcement.clone();
11564 let (barrier, policy, workdir) = (&barrier, &policy, temp.path());
11565 scope.spawn(move || {
11566 barrier.wait();
11567 bash(script, workdir, policy, &enforcement).expect("the command runs")
11568 })
11569 })
11570 .collect();
11571 handles
11572 .into_iter()
11573 .map(|handle| handle.join().unwrap())
11574 .collect()
11575 });
11576
11577 for (index, result) in results.iter().enumerate() {
11578 println!(
11579 "command {index}: exit {} stdout {:?}",
11580 result.exit_code,
11581 result.stdout.trim()
11582 );
11583 assert_eq!(result.exit_code, 0, "stderr: {}", result.stderr);
11584 }
11585 for client in &results[1..] {
11586 assert_eq!(client.stdout.trim(), "pong");
11587 }
11588 println!(
11589 "namespaces created: {}",
11590 enforcement.session_netns.creations()
11591 );
11592 assert_eq!(enforcement.session_netns.creations(), 1);
11593
11594 enforcement.end_session();
11595 }
11596
11597 #[test]
11600 #[allow(clippy::print_stdout)]
11601 fn ending_the_session_kills_everything_it_started() {
11602 if crate::cgroup::skip_without_host_support(
11603 "ending_the_session_kills_everything_it_started",
11604 ) {
11605 return;
11606 }
11607 let temp = tempfile::tempdir().unwrap();
11608 let policy = server_policy(&["setsid"]);
11609 let scope =
11610 crate::cgroup::prepare_scope(true, &policy.resources, "ses_end_kills", temp.path())
11611 .expect("a delegated scope")
11612 .scope
11613 .expect("a required scope on Linux is never None");
11614 let enforcement =
11615 session_enforcement(&policy).with_host_bounding(Some(Arc::clone(&scope)), None);
11616
11617 let started = bash(
11618 "setsid sleep 300 >/dev/null 2>&1 & echo $! > sleeper.pid; \
11619 nc -l 127.0.0.1 8080 >/dev/null 2>&1 & echo $! > server.pid",
11620 temp.path(),
11621 &policy,
11622 &enforcement,
11623 )
11624 .expect("the command runs");
11625 assert_eq!(started.exit_code, 0, "stderr: {}", started.stderr);
11626 let sleeper = host_pid(pid_in(temp.path(), "sleeper.pid"));
11628 let server = host_pid(pid_in(temp.path(), "server.pid"));
11629 assert!(process_exists(sleeper), "the setsid sleeper is running");
11630 assert!(process_exists(server), "the background server is running");
11631
11632 let ending = std::time::Instant::now();
11633 enforcement.end_session();
11634 let took = ending.elapsed();
11635
11636 let sleeper_gone = process_gone_within(sleeper, std::time::Duration::from_secs(2));
11637 let server_gone = process_gone_within(server, std::time::Duration::from_secs(2));
11638 let procs = std::fs::read_to_string(scope.path().join("cgroup.procs")).unwrap();
11639 let after = bash("true", temp.path(), &policy, &enforcement);
11640 println!(
11641 "end_session took {} ms; sleeper {sleeper} gone: {sleeper_gone}; server {server} \
11642 gone: {server_gone}; cgroup.procs after: {:?}; next command: {:?}",
11643 took.as_millis(),
11644 procs.trim(),
11645 after.as_ref().map(|result| result.exit_code)
11646 );
11647 assert!(took < std::time::Duration::from_secs(3), "took {took:?}");
11648 assert!(sleeper_gone, "the setsid sleeper outlived its session");
11649 assert!(server_gone, "the background server outlived its session");
11650 assert!(procs.trim().is_empty(), "cgroup.procs: {procs}");
11651 let refusal = after
11652 .expect_err("a command after the session ended must be refused")
11653 .to_string();
11654 assert!(refusal.contains("session has ended"), "{refusal}");
11655 }
11656
11657 fn home_tempdir() -> tempfile::TempDir {
11665 tempfile::tempdir_in(std::env::var_os("HOME").expect("HOME is set")).unwrap()
11666 }
11667
11668 fn mapped_enforcement(
11673 test: &str,
11674 policy: &CapabilityPolicy,
11675 class: murmur_artifact::ContainmentClass,
11676 base: &Path,
11677 workdir: &Path,
11678 ) -> Option<ShellEnforcement> {
11679 if crate::network_namespace::skip_without_egress_namespace(test) {
11680 return None;
11681 }
11682 let probe = HostProbe::probe();
11683 let mount_namespace_usable = probe.mount_namespace_usable();
11684 let enforcement = ShellEnforcement::resolve_declared(policy, class, probe)
11685 .unwrap()
11686 .with_runtime_dir(test_runtime_dir(base), None);
11687 let mapping =
11688 crate::temp_dir::temp_mapping(enforcement.tier, mount_namespace_usable, workdir);
11689 if mapping != crate::temp_dir::TempMapping::MountNamespace {
11690 crate::runtime_err!(
11691 "[SKIP-HOST] {test}: this host maps no private /tmp at {class:?} ({mapping:?} on \
11692 {:?})",
11693 enforcement.tier
11694 );
11695 return None;
11696 }
11697 Some(enforcement.with_temp_mapping(mapping))
11698 }
11699
11700 fn unmapped_kernel_full_enforcement(
11703 test: &str,
11704 policy: &CapabilityPolicy,
11705 base: &Path,
11706 workdir: &Path,
11707 ) -> Option<ShellEnforcement> {
11708 if crate::network_namespace::skip_without_egress_namespace(test) {
11709 return None;
11710 }
11711 let live = HostProbe::probe();
11712 if !matches!(
11713 live.tier(),
11714 EnforcementTier::KernelFull | EnforcementTier::KernelSealed
11715 ) {
11716 crate::runtime_err!(
11717 "[SKIP-HOST] {test}: needs Landlock; detected {:?}",
11718 live.tier()
11719 );
11720 return None;
11721 }
11722 let refused = crate::namespace_probe::MountAttempt::Failed(
11723 crate::namespace_probe::MountAttemptError::Step {
11724 step: crate::namespace_probe::MountStep::UnshareMount,
11725 errno: libc::EPERM,
11726 },
11727 );
11728 let probe = HostProbe::from_parts(
11729 Some(true),
11730 crate::namespace_probe::NamespaceReading {
11731 mount: refused.clone(),
11732 sealed_root: refused,
11733 ..live.namespaces().clone()
11734 },
11735 )
11736 .with_landlock_kernel_abi(live.landlock_kernel_abi());
11737 assert!(!probe.mount_namespace_usable());
11738 let enforcement = ShellEnforcement::resolve_declared(
11739 policy,
11740 murmur_artifact::ContainmentClass::Scoped,
11741 probe,
11742 )
11743 .unwrap()
11744 .with_runtime_dir(test_runtime_dir(base), None);
11745 assert_eq!(enforcement.tier, EnforcementTier::KernelFull);
11746 let mapping = crate::temp_dir::temp_mapping(enforcement.tier, false, workdir);
11747 assert_eq!(
11748 mapping,
11749 crate::temp_dir::TempMapping::NotMapped(
11750 crate::temp_dir::NotMappedReason::NoMountNamespace
11751 )
11752 );
11753 Some(enforcement.with_temp_mapping(mapping))
11754 }
11755
11756 fn unique_tmp_name(label: &str) -> String {
11758 static NEXT: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
11759 format!(
11760 "murmur-probe-{label}-{}-{}",
11761 std::process::id(),
11762 NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
11763 )
11764 }
11765
11766 #[test]
11770 fn the_tmp_map_spec_reexposes_exact_paths_beneath_tmp_read_only_but_the_workdir() {
11771 let base = tempfile::tempdir().unwrap();
11772 let workdir = tempfile::tempdir().unwrap();
11773 let grant_dir = tempfile::tempdir().unwrap();
11774 let grant = grant_dir.path().join("bin");
11775 std::fs::write(&grant, b"#!/bin/sh\n").unwrap();
11776 let mut enforcement = ShellEnforcement {
11777 tier: EnforcementTier::KernelFull,
11778 ..ShellEnforcement::environment_only().with_test_session_dirs()
11779 }
11780 .with_runtime_dir(test_runtime_dir(base.path()), None)
11781 .with_temp_mapping(crate::temp_dir::TempMapping::MountNamespace);
11782 enforcement.landlock_grants =
11783 LandlockGrant::non_listable_files(vec![grant.clone(), PathBuf::from("/usr/bin/env")]);
11784 let folder = enforcement
11785 .runtime_dir
11786 .as_ref()
11787 .and_then(|roots| roots.temp.as_ref())
11788 .unwrap()
11789 .path_fd()
11790 .unwrap();
11791
11792 let spec = build_tmp_map_spec(&enforcement, &folder, workdir.path()).unwrap();
11793
11794 let canonical = |path: &Path| {
11795 std::fs::canonicalize(path)
11796 .unwrap()
11797 .to_string_lossy()
11798 .into_owned()
11799 };
11800 let mut expected = vec![
11801 (canonical(workdir.path()), false),
11802 (
11803 canonical(&base.path().join(crate::runtime_dir::RUNTIME_SUBDIR)),
11804 true,
11805 ),
11806 (
11807 canonical(&base.path().join(crate::records_dir::RECORDS_DIR_NAME)),
11808 true,
11809 ),
11810 (
11811 canonical(
11812 &base
11813 .path()
11814 .join(crate::records_dir::RECORDS_DIR_NAME)
11815 .join(crate::records_dir::HOME_SUBDIR),
11816 ),
11817 false,
11818 ),
11819 (canonical(&grant), true),
11820 ];
11821 expected.sort();
11822 assert_eq!(spec.reexposed_sources(), expected);
11823 }
11824
11825 #[test]
11828 fn a_literal_tmp_path_reaches_the_session_temp_folder() {
11829 const TEST: &str = "a_literal_tmp_path_reaches_the_session_temp_folder";
11830 for class in [
11831 murmur_artifact::ContainmentClass::Scoped,
11832 murmur_artifact::ContainmentClass::Advisory,
11833 ] {
11834 let base = home_tempdir();
11835 let workdir = home_tempdir();
11836 let policy = coreutils_policy(&[]);
11837 let Some(enforcement) =
11838 mapped_enforcement(TEST, &policy, class, base.path(), workdir.path())
11839 else {
11840 return;
11841 };
11842 let name = unique_tmp_name("literal");
11843 let result = run_bash_in(
11844 workdir.path(),
11845 &format!("echo x > /tmp/{name} && cat /tmp/{name} && printf '%s\\n' \"$TMPDIR\""),
11846 &policy,
11847 &enforcement,
11848 );
11849 assert_eq!(result.exit_code, 0, "{class:?}: {}", result.stderr);
11850 assert_eq!(result.stdout, "x\n/tmp\n", "{class:?}");
11851 let folder = enforcement.temp_dir_path().unwrap();
11852 assert_eq!(std::fs::read_to_string(folder.join(&name)).unwrap(), "x\n");
11853 assert!(
11854 !Path::new("/tmp").join(&name).exists(),
11855 "{class:?}: the host's /tmp gained {name}"
11856 );
11857 }
11858 }
11859
11860 #[test]
11863 fn a_literal_tmp_path_reaches_the_session_temp_folder_on_the_seccomp_only_tier() {
11864 const TEST: &str =
11865 "a_literal_tmp_path_reaches_the_session_temp_folder_on_the_seccomp_only_tier";
11866 let base = home_tempdir();
11867 let workdir = home_tempdir();
11868 let policy = coreutils_policy(&[]);
11869 let Some(enforcement) = mapped_enforcement(
11870 TEST,
11871 &policy,
11872 murmur_artifact::ContainmentClass::Scoped,
11873 base.path(),
11874 workdir.path(),
11875 ) else {
11876 return;
11877 };
11878 let enforcement = ShellEnforcement {
11879 tier: EnforcementTier::KernelSeccompOnly,
11880 ..enforcement
11881 };
11882 assert_eq!(
11883 enforcement.installed_temp_mapping(),
11884 crate::temp_dir::TempMapping::MountNamespace
11885 );
11886 let name = unique_tmp_name("seccomp-only");
11887 let result = run_bash_in(
11888 workdir.path(),
11889 &format!("echo x > /tmp/{name} && cat /tmp/{name} && printf '%s\\n' \"$TMPDIR\""),
11890 &policy,
11891 &enforcement,
11892 );
11893 assert_eq!(result.exit_code, 0, "{}", result.stderr);
11894 assert_eq!(result.stdout, "x\n/tmp\n");
11895 let folder = enforcement.temp_dir_path().unwrap();
11896 assert_eq!(std::fs::read_to_string(folder.join(&name)).unwrap(), "x\n");
11897 assert!(
11898 !Path::new("/tmp").join(&name).exists(),
11899 "the host's /tmp gained {name}"
11900 );
11901 }
11902
11903 #[test]
11905 fn two_sessions_do_not_share_tmp() {
11906 const TEST: &str = "two_sessions_do_not_share_tmp";
11907 let policy = coreutils_policy(&[]);
11908 let (base_a, base_b) = (home_tempdir(), home_tempdir());
11909 let (work_a, work_b) = (home_tempdir(), home_tempdir());
11910 let class = murmur_artifact::ContainmentClass::Scoped;
11911 let Some(a) = mapped_enforcement(TEST, &policy, class, base_a.path(), work_a.path()) else {
11912 return;
11913 };
11914 let Some(b) = mapped_enforcement(TEST, &policy, class, base_b.path(), work_b.path()) else {
11915 return;
11916 };
11917 let name = unique_tmp_name("shared");
11918
11919 let first = run_bash_in(work_a.path(), &format!("echo a > /tmp/{name}"), &policy, &a);
11920 assert_eq!(first.exit_code, 0, "{}", first.stderr);
11921 let second = run_bash_in(
11922 work_b.path(),
11923 &format!("test ! -e /tmp/{name} && echo b > /tmp/{name} && cat /tmp/{name}"),
11924 &policy,
11925 &b,
11926 );
11927 assert_eq!(second.exit_code, 0, "{}", second.stderr);
11928 assert_eq!(second.stdout, "b\n");
11929 let again = run_bash_in(work_a.path(), &format!("cat /tmp/{name}"), &policy, &a);
11930 assert_eq!(again.stdout, "a\n", "{}", again.stderr);
11931 assert_ne!(a.temp_dir_path(), b.temp_dir_path());
11932 assert!(!Path::new("/tmp").join(&name).exists());
11933 }
11934
11935 #[test]
11938 fn a_workdir_under_tmp_stays_reachable_when_tmp_is_mapped() {
11939 const TEST: &str = "a_workdir_under_tmp_stays_reachable_when_tmp_is_mapped";
11940 let base = tempfile::tempdir().unwrap();
11941 let workdir = tempfile::tempdir().unwrap();
11942 assert!(workdir.path().starts_with(crate::temp_dir::canonical_tmp()));
11943 let policy = coreutils_policy(&[]);
11944 let Some(enforcement) = mapped_enforcement(
11945 TEST,
11946 &policy,
11947 murmur_artifact::ContainmentClass::Scoped,
11948 base.path(),
11949 workdir.path(),
11950 ) else {
11951 return;
11952 };
11953 std::fs::write(
11954 enforcement.runtime_dir_path().unwrap().join("probe"),
11955 b"runtime\n",
11956 )
11957 .unwrap();
11958 let name = unique_tmp_name("under");
11959 let result = run_bash_in(
11960 workdir.path(),
11961 &format!(
11962 "echo a > \"$(pwd)/f\" && cat \"$(pwd)/f\" && cat \"$MURMUR_RUNTIME_DIR/probe\" && \
11963 echo h > \"$HOME/h\" && cat \"$HOME/h\" && echo t > /tmp/{name} && \
11964 cat /tmp/{name}"
11965 ),
11966 &policy,
11967 &enforcement,
11968 );
11969 assert_eq!(result.exit_code, 0, "{}", result.stderr);
11970 assert_eq!(result.stdout, "a\nruntime\nh\nt\n");
11971 assert_eq!(
11972 std::fs::read_to_string(workdir.path().join("f")).unwrap(),
11973 "a\n"
11974 );
11975
11976 let sibling = base.path().join("sibling");
11980 std::fs::write(&sibling, b"host\n").unwrap();
11981 let record = enforcement.records_dir_path().unwrap().join("trace.jsonl");
11982 std::fs::write(&record, b"{}\n").unwrap();
11983 let refused = run_bash_in(
11984 workdir.path(),
11985 &format!(
11986 "test ! -e {sibling} && echo absent; echo z >> \"$MURMUR_RECORDS_DIR/trace.jsonl\"",
11987 sibling = sibling.display()
11988 ),
11989 &policy,
11990 &enforcement,
11991 );
11992 assert!(refused.stdout.starts_with("absent\n"), "{}", refused.stderr);
11993 assert_ne!(refused.exit_code, 0);
11994 assert!(
11995 refused.stderr.contains("Read-only file system"),
11996 "{}",
11997 refused.stderr
11998 );
11999 assert_eq!(std::fs::read(&record).unwrap(), b"{}\n");
12000 let folder = enforcement.temp_dir_path().unwrap();
12001 assert_eq!(std::fs::read_to_string(folder.join(&name)).unwrap(), "t\n");
12002 assert!(!Path::new("/tmp").join(&name).exists());
12003 }
12004
12005 #[test]
12008 fn without_a_mount_namespace_tmp_is_refused_and_tmpdir_works() {
12009 const TEST: &str = "without_a_mount_namespace_tmp_is_refused_and_tmpdir_works";
12010 let base = home_tempdir();
12011 let workdir = home_tempdir();
12012 let policy = coreutils_policy(&[]);
12013 let Some(enforcement) =
12014 unmapped_kernel_full_enforcement(TEST, &policy, base.path(), workdir.path())
12015 else {
12016 return;
12017 };
12018 let name = unique_tmp_name("refused");
12019 let refused = run_bash_in(
12020 workdir.path(),
12021 &format!("echo y > /tmp/{name}"),
12022 &policy,
12023 &enforcement,
12024 );
12025 assert_ne!(refused.exit_code, 0);
12026 assert!(
12027 refused.stderr.contains("Permission denied"),
12028 "{}",
12029 refused.stderr
12030 );
12031 assert!(!Path::new("/tmp").join(&name).exists());
12032
12033 let folder = enforcement.temp_dir_path().unwrap().to_path_buf();
12034 let works = run_bash_in(
12035 workdir.path(),
12036 "echo z > \"$TMPDIR/m\" && cat \"$TMPDIR/m\" && printf '%s\\n' \"$TMPDIR\" \"$TMP\" \
12037 \"$TEMP\"",
12038 &policy,
12039 &enforcement,
12040 );
12041 assert_eq!(works.exit_code, 0, "{}", works.stderr);
12042 let folder_text = folder.to_string_lossy();
12043 assert_eq!(
12044 works.stdout,
12045 format!("z\n{folder_text}\n{folder_text}\n{folder_text}\n")
12046 );
12047 assert_eq!(std::fs::read_to_string(folder.join("m")).unwrap(), "z\n");
12048 }
12049
12050 #[test]
12053 fn a_record_cannot_be_moved_or_linked_into_tmp() {
12054 const TEST: &str = "a_record_cannot_be_moved_or_linked_into_tmp";
12055 let policy = coreutils_policy(&[]);
12056 for mapped in [true, false] {
12057 let base = home_tempdir();
12058 let workdir = home_tempdir();
12059 let enforcement = if mapped {
12060 mapped_enforcement(
12061 TEST,
12062 &policy,
12063 murmur_artifact::ContainmentClass::Scoped,
12064 base.path(),
12065 workdir.path(),
12066 )
12067 } else {
12068 unmapped_kernel_full_enforcement(TEST, &policy, base.path(), workdir.path())
12069 };
12070 let Some(enforcement) = enforcement else {
12071 return;
12072 };
12073 let records = enforcement.records_dir_path().unwrap().to_path_buf();
12074 let record = records.join("trace.jsonl");
12075 std::fs::write(&record, b"{\"event\":\"session_start\"}\n").unwrap();
12076 let before = std::fs::read(&record).unwrap();
12077 for script in [
12078 "ln \"$MURMUR_RECORDS_DIR/trace.jsonl\" /tmp/l",
12079 "ln \"$MURMUR_RECORDS_DIR/trace.jsonl\" \"$TMPDIR/l2\"",
12080 "ln \"$MURMUR_RECORDS_DIR/trace.jsonl\" \"$MURMUR_RECORDS_DIR/tmp/l3\"",
12081 "mv \"$MURMUR_RECORDS_DIR/trace.jsonl\" \"$TMPDIR/m\"",
12082 "mv \"$MURMUR_RECORDS_DIR/trace.jsonl\" \"$MURMUR_RECORDS_DIR/tmp/m2\"",
12083 "echo z >> \"$MURMUR_RECORDS_DIR/trace.jsonl\"",
12084 "mv \"$MURMUR_RECORDS_DIR/tmp\" \"$MURMUR_RECORDS_DIR/tmp-moved\"",
12085 ] {
12086 let result = run_bash_in(workdir.path(), script, &policy, &enforcement);
12087 assert_ne!(
12088 result.exit_code, 0,
12089 "mapped: {mapped}: `{script}` succeeded"
12090 );
12091 }
12092 assert_eq!(std::fs::read(&record).unwrap(), before, "mapped: {mapped}");
12093 assert!(records
12094 .join(crate::temp_dir::SESSION_TEMP_DIR_NAME)
12095 .is_dir());
12096 assert!(!records.join("tmp-moved").exists());
12097 }
12098 }
12099
12100 #[test]
12103 fn tmp_follows_workdir_exec() {
12104 const TEST: &str = "tmp_follows_workdir_exec";
12105 for (workdir_exec, expected) in [(false, 126), (true, 0)] {
12106 let base = home_tempdir();
12107 let workdir = home_tempdir();
12108 let policy = CapabilityPolicy {
12109 workdir_exec_allowed: workdir_exec,
12110 ..coreutils_policy(&["cp", "true"])
12111 };
12112 let Some(enforcement) = mapped_enforcement(
12113 TEST,
12114 &policy,
12115 murmur_artifact::ContainmentClass::Advisory,
12116 base.path(),
12117 workdir.path(),
12118 ) else {
12119 return;
12120 };
12121 let result = run_bash_in(
12122 workdir.path(),
12123 "cp /bin/true /tmp/t && /tmp/t",
12124 &policy,
12125 &enforcement,
12126 );
12127 assert_eq!(
12128 result.exit_code, expected,
12129 "workdir_exec: {workdir_exec}: {}",
12130 result.stderr
12131 );
12132 if !workdir_exec {
12133 assert!(
12134 result.stderr.contains("Permission denied"),
12135 "{}",
12136 result.stderr
12137 );
12138 }
12139 }
12140 }
12141
12142 #[test]
12144 fn a_failed_tmp_mapping_spawns_nothing() {
12145 const TEST: &str = "a_failed_tmp_mapping_spawns_nothing";
12146 let base = home_tempdir();
12147 let workdir = home_tempdir();
12148 let policy = coreutils_policy(&[]);
12149 let Some(enforcement) = mapped_enforcement(
12150 TEST,
12151 &policy,
12152 murmur_artifact::ContainmentClass::Scoped,
12153 base.path(),
12154 workdir.path(),
12155 ) else {
12156 return;
12157 };
12158 let error = {
12159 let _forced = ForceTmpMapFailureGuard::new();
12160 crate::shell::execute_shell(
12161 "bash",
12162 &["-c", "echo ran > \"$(pwd)/marker\""],
12163 &[],
12164 &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
12165 &policy,
12166 &enforcement,
12167 )
12168 .expect_err("a failed mapping fails the spawn")
12169 };
12170 let message = error.to_string();
12171 println!("{message}");
12172 assert!(
12173 message.contains("could not map /tmp onto this session's temporary folder"),
12174 "{message}"
12175 );
12176 assert!(
12177 matches!(error, crate::shell::ShellExecError::Failed(_)),
12178 "an ordinary spawn failure, not a session-fatal one: {error:?}"
12179 );
12180 assert!(!workdir.path().join("marker").exists(), "the command ran");
12181 }
12182
12183 #[test]
12186 fn a_link_swapped_onto_the_temp_folder_redirects_no_bind() {
12187 const TEST: &str = "a_link_swapped_onto_the_temp_folder_redirects_no_bind";
12188 let base = home_tempdir();
12189 let workdir = home_tempdir();
12190 let policy = coreutils_policy(&[]);
12191 let Some(enforcement) = mapped_enforcement(
12192 TEST,
12193 &policy,
12194 murmur_artifact::ContainmentClass::Scoped,
12195 base.path(),
12196 workdir.path(),
12197 ) else {
12198 return;
12199 };
12200 let folder = enforcement.temp_dir_path().unwrap().to_path_buf();
12201 let decoy = base.path().join("decoy");
12202 std::fs::create_dir(&decoy).unwrap();
12203 std::fs::rename(&folder, base.path().join("folder-moved")).unwrap();
12204 std::os::unix::fs::symlink(&decoy, &folder).unwrap();
12205
12206 let error = crate::shell::execute_shell(
12207 "bash",
12208 &["-c", "echo x > /tmp/swapped"],
12209 &[],
12210 &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
12211 &policy,
12212 &enforcement,
12213 )
12214 .expect_err("a swapped folder fails the spawn")
12215 .to_string();
12216 println!("{error}");
12217 assert!(
12218 error.contains("could not map /tmp onto this session's temporary folder"),
12219 "{error}"
12220 );
12221 assert!(error.contains("opening the temporary folder"), "{error}");
12222 assert!(
12223 error.contains(&std::io::Error::from_raw_os_error(libc::ESTALE).to_string()),
12224 "{error}"
12225 );
12226 assert_eq!(
12227 std::fs::read_dir(&decoy).unwrap().count(),
12228 0,
12229 "the link's target was written"
12230 );
12231 }
12232
12233 fn derived_interpreter_policy(binary: &str, work: &Path) -> CapabilityPolicy {
12237 let mut policy = CapabilityPolicy {
12238 shell_allow: vec![binary.to_string()],
12239 ..CapabilityPolicy::default()
12240 };
12241 let cache = tempfile::tempdir().unwrap();
12242 policy.shell_derived_interpreter_runtime =
12243 crate::interpreter_runtime::derive_interpreter_runtimes(
12244 &policy.shell_allow,
12245 &[],
12246 &[work],
12247 &crate::host_facts::HostFactsCache::at(cache.path().join("host_facts")),
12248 )
12249 .runtimes;
12250 policy
12251 }
12252
12253 fn unsandboxed_python(program: &str) -> String {
12255 let output = std::process::Command::new("python3")
12256 .args(["-I", "-c", program])
12257 .output()
12258 .unwrap();
12259 assert!(output.status.success(), "{output:?}");
12260 String::from_utf8(output.stdout).unwrap().trim().to_string()
12261 }
12262
12263 fn assert_derives_stdlib_and_sqlite(policy: &CapabilityPolicy) -> PathBuf {
12266 let stdlib = PathBuf::from(unsandboxed_python(
12267 "import encodings, os; print(os.path.dirname(os.path.dirname(encodings.__file__)))",
12268 ));
12269 let dynload = PathBuf::from(unsandboxed_python(
12270 "import _sqlite3, os; print(os.path.dirname(os.path.realpath(_sqlite3.__file__)))",
12271 ));
12272 let derived = &policy.shell_derived_interpreter_runtime;
12273 println!("derived: {derived:#?}");
12274 let dirs: Vec<&PathBuf> = derived.iter().flat_map(|runtime| &runtime.dirs).collect();
12275 let canonical = |path: &Path| std::fs::canonicalize(path).unwrap();
12276 assert!(
12277 dirs.contains(&&canonical(&stdlib)),
12278 "{stdlib:?} not in {dirs:?}"
12279 );
12280 assert!(
12281 dirs.contains(&&canonical(&dynload)),
12282 "{dynload:?} not in {dirs:?}"
12283 );
12284 canonical(&stdlib)
12285 }
12286
12287 fn python_probe_outside(secret: &Path, parent: &Path) -> String {
12290 format!(
12291 "import os\n\
12292 for label, act in (('open', lambda: open({secret:?}).read()), \
12293 ('listdir', lambda: os.listdir({parent:?}))):\n\
12294 \x20 try:\n\
12295 \x20 act(); print(label, 'allowed')\n\
12296 \x20 except Exception as error:\n\
12297 \x20 print(label, type(error).__name__)\n",
12298 secret = secret.to_string_lossy(),
12299 parent = parent.to_string_lossy(),
12300 )
12301 }
12302
12303 fn execute(
12304 binary: &str,
12305 args: &[&str],
12306 accessible: &Path,
12307 policy: &CapabilityPolicy,
12308 enforcement: &ShellEnforcement,
12309 ) -> crate::shell::ShellResult {
12310 let result = crate::shell::execute_shell(
12311 binary,
12312 args,
12313 &[],
12314 &crate::workdir_fs::WorkdirRoot::open(accessible).unwrap(),
12315 policy,
12316 enforcement,
12317 )
12318 .unwrap();
12319 println!(
12320 "exit {}\n--- stdout\n{}--- stderr\n{}---",
12321 result.exit_code, result.stdout, result.stderr
12322 );
12323 result
12324 }
12325
12326 fn trust_store_policy(
12329 shell_allow: &[&str],
12330 network_allow: &[&str],
12331 work: &Path,
12332 ) -> CapabilityPolicy {
12333 let mut policy = CapabilityPolicy {
12334 shell_allow: shell_allow.iter().map(|name| name.to_string()).collect(),
12335 network_allow: network_allow.iter().map(|rule| rule.to_string()).collect(),
12336 ..CapabilityPolicy::default()
12337 };
12338 policy.shell_trust_store = crate::trust_store::derive_trust_store(
12339 &policy.shell_allow,
12340 &policy.network_allow,
12341 &[work],
12342 );
12343 println!("derived trust store: {:#?}", policy.shell_trust_store);
12344 policy
12345 }
12346
12347 fn host_ca_bundle() -> Option<PathBuf> {
12349 crate::trust_store::TRUST_STORE_CANDIDATES
12350 .iter()
12351 .filter(|candidate| !candidate.directory)
12352 .map(|candidate| PathBuf::from(candidate.path))
12353 .find(|path| std::fs::metadata(path).is_ok_and(|m| m.is_file()))
12354 }
12355
12356 #[test]
12359 fn a_scoped_curl_with_network_allow_completes_an_https_request() {
12360 let test = "a_scoped_curl_with_network_allow_completes_an_https_request";
12361 if find_on_path("curl").is_none() {
12362 eprintln!("SKIP — PROVES NOTHING: {test} needs curl on PATH");
12363 return;
12364 }
12365 let unsandboxed = std::process::Command::new("curl")
12366 .args([
12367 "-sS",
12368 "-o",
12369 "/dev/null",
12370 "-w",
12371 "%{http_code}",
12372 "--max-time",
12373 "15",
12374 ])
12375 .arg("https://example.com/")
12376 .output()
12377 .unwrap();
12378 if String::from_utf8_lossy(&unsandboxed.stdout) != "200" {
12379 eprintln!(
12380 "SKIP — PROVES NOTHING: {test} needs https://example.com/ to answer 200 outside \
12381 the sandbox; it gave {unsandboxed:?}"
12382 );
12383 return;
12384 }
12385 let (_base, work, _outside) = work_and_outside();
12386 let policy = trust_store_policy(&["curl"], &["example.com"], &work);
12387 assert!(policy.shell_interpreter_runtime.is_empty());
12388 assert!(
12389 !policy.shell_trust_store.is_empty(),
12390 "this host has a trust store the candidate table names"
12391 );
12392 let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
12393 return;
12394 };
12395
12396 let fetched = execute(
12397 "curl",
12398 &[
12399 "-sS",
12400 "-o",
12401 "/dev/null",
12402 "-w",
12403 "%{http_code}",
12404 "--max-time",
12405 "30",
12406 "https://example.com/",
12407 ],
12408 &work,
12409 &policy,
12410 &enforcement,
12411 );
12412 enforcement.end_session();
12413 assert_eq!(fetched.exit_code, 0, "{}", fetched.stderr);
12414 assert_eq!(fetched.stdout, "200");
12415 }
12416
12417 #[test]
12419 fn a_scoped_command_reads_the_trust_store_only_with_network_allow() {
12420 let test = "a_scoped_command_reads_the_trust_store_only_with_network_allow";
12421 assert!(
12422 crate::trust_store::derive_trust_store(&[], &["example.com".into()], &[]).is_empty()
12423 );
12424 let Some(bundle) = host_ca_bundle() else {
12425 eprintln!("SKIP — PROVES NOTHING: {test} needs a CA bundle the candidate table names");
12426 return;
12427 };
12428 let bundle_arg = bundle.to_str().unwrap();
12429 let (_base, work, _outside) = work_and_outside();
12430
12431 let offline = trust_store_policy(&["cat"], &[], &work);
12432 assert!(offline.shell_trust_store.is_empty());
12433 let online = trust_store_policy(&["cat"], &["127.0.0.1"], &work);
12434 assert!(!online.shell_trust_store.is_empty());
12435 let Some(enforcement) = scoped_landlock_enforcement(test, &offline, 1) else {
12436 return;
12437 };
12438 for derived in &online.shell_trust_store {
12439 assert!(
12440 !enforcement
12441 .landlock_grants
12442 .iter()
12443 .any(|grant| grant.path == derived.path),
12444 "{derived:?} granted without network.allow"
12445 );
12446 }
12447 let denied = execute("cat", &[bundle_arg], &work, &offline, &enforcement);
12448 enforcement.end_session();
12449 assert_ne!(denied.exit_code, 0);
12450 assert!(
12451 denied.stderr.contains("Permission denied"),
12452 "{}",
12453 denied.stderr
12454 );
12455
12456 let Some(enforcement) = scoped_landlock_enforcement(test, &online, 1) else {
12457 return;
12458 };
12459 let read = execute("cat", &[bundle_arg], &work, &online, &enforcement);
12460 enforcement.end_session();
12461 assert_eq!(read.exit_code, 0, "{}", read.stderr);
12462 assert!(
12463 read.stdout.starts_with("-----BEGIN CERTIFICATE-----"),
12464 "{}",
12465 &read.stdout[..read.stdout.len().min(200)]
12466 );
12467 }
12468
12469 fn allow_only(shell_allow: &[&str]) -> CapabilityPolicy {
12473 CapabilityPolicy {
12474 shell_allow: shell_allow.iter().map(|name| name.to_string()).collect(),
12475 ..CapabilityPolicy::default()
12476 }
12477 }
12478
12479 fn sealed_landlock_enforcement(
12482 test: &str,
12483 policy: &CapabilityPolicy,
12484 ) -> Option<(ShellEnforcement, tempfile::TempDir)> {
12485 if crate::network_namespace::skip_without_egress_namespace(test) {
12486 return None;
12487 }
12488 let probe = HostProbe::probe();
12489 if probe.tier() != EnforcementTier::KernelSealed {
12490 eprintln!(
12491 "SKIP — PROVES NOTHING ABOUT SEALED ON THIS HOST: {test} needs a host that \
12492 reaches KernelSealed; detected {:?}",
12493 probe.tier()
12494 );
12495 return None;
12496 }
12497 let runtime = tempfile::tempdir().unwrap();
12498 let enforcement = ShellEnforcement::resolve_declared(
12499 policy,
12500 murmur_artifact::ContainmentClass::Sealed,
12501 probe,
12502 )
12503 .unwrap()
12504 .with_runtime_dir(test_runtime_dir(runtime.path()), None);
12505 assert_eq!(enforcement.tier, EnforcementTier::KernelSealed);
12506 Some((enforcement, runtime))
12507 }
12508
12509 fn mounts_private_proc(test: &str, enforcement: &ShellEnforcement) -> bool {
12512 match enforcement.installed_proc_view() {
12513 crate::proc_view::ProcView::Private => true,
12514 crate::proc_view::ProcView::Host(reason) => {
12515 eprintln!(
12516 "SKIP — PROVES NOTHING: {test} needs a host that mounts a private procfs; \
12517 {}",
12518 reason.describe()
12519 );
12520 false
12521 }
12522 }
12523 }
12524
12525 const OWN_PROC_SCRIPT: &str = "head -n 1 /proc/self/status; \
12528 head -n 1 /proc/thread-self/status; \
12529 head -n 1 /proc/stat; \
12530 n=0; while read -r line; do case $line in processor*) n=$((n+1));; esac; done \
12531 < /proc/cpuinfo; echo \"processors=$n\"; \
12532 head -n 1 /proc/meminfo; \
12533 while read -r _ point kind options _; do \
12534 if [ \"$point\" = /proc ]; then echo \"proc-mount: $kind $options\"; fi; \
12535 done < /proc/self/mounts";
12536
12537 fn assert_reads_own_proc(result: &crate::shell::ShellResult) {
12538 assert_eq!(result.exit_code, 0, "{}", result.stderr);
12539 assert!(
12540 !result.stderr.contains("Permission denied"),
12541 "{}",
12542 result.stderr
12543 );
12544 let lines: Vec<&str> = result.stdout.lines().collect();
12545 assert!(lines.len() >= 5, "{}", result.stdout);
12546 assert!(lines[0].starts_with("Name:"), "{}", result.stdout);
12547 assert!(lines[1].starts_with("Name:"), "{}", result.stdout);
12548 assert!(lines[2].starts_with("cpu "), "{}", result.stdout);
12549 let processors: u32 = lines[3]
12550 .strip_prefix("processors=")
12551 .and_then(|n| n.parse().ok())
12552 .unwrap_or_else(|| panic!("{}", result.stdout));
12553 assert!(processors > 0, "{}", result.stdout);
12554 assert!(lines[4].starts_with("MemTotal:"), "{}", result.stdout);
12555 }
12556
12557 #[test]
12560 fn a_scoped_command_reads_its_own_proc() {
12561 let test = "a_scoped_command_reads_its_own_proc";
12562 let (_base, work, _outside) = work_and_outside();
12563 let policy = allow_only(&["bash", "cat", "head"]);
12564 let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
12565 return;
12566 };
12567 if !mounts_private_proc(test, &enforcement) {
12568 return;
12569 }
12570 let result = run_bash_in(&work, OWN_PROC_SCRIPT, &policy, &enforcement);
12571 enforcement.end_session();
12572 assert_reads_own_proc(&result);
12573 }
12574
12575 struct HostSleeper(std::process::Child);
12577
12578 impl HostSleeper {
12579 fn start() -> Self {
12580 Self(
12581 std::process::Command::new("sleep")
12582 .arg("300")
12583 .spawn()
12584 .unwrap(),
12585 )
12586 }
12587
12588 fn pid(&self) -> u32 {
12589 self.0.id()
12590 }
12591 }
12592
12593 impl Drop for HostSleeper {
12594 fn drop(&mut self) {
12595 let _ = self.0.kill();
12596 let _ = self.0.wait();
12597 }
12598 }
12599
12600 fn host_process_script(pid: u32) -> String {
12602 format!("ls /proc; cat /proc/{pid}/cmdline; echo \"cat=$?\"")
12603 }
12604
12605 fn assert_sees_no_host_process(result: &crate::shell::ShellResult, host_pids: &[u32]) {
12606 assert_eq!(result.exit_code, 0, "{}", result.stderr);
12607 let entries: Vec<u32> = result
12608 .stdout
12609 .lines()
12610 .filter_map(|line| line.trim().parse().ok())
12611 .collect();
12612 assert!(!entries.is_empty(), "no process listed: {}", result.stdout);
12613 assert!(entries.len() <= 3, "{entries:?}");
12615 for pid in host_pids {
12616 assert!(!entries.contains(pid), "host pid {pid} listed: {entries:?}");
12617 }
12618 assert!(
12619 result.stderr.contains("No such file or directory"),
12620 "{}",
12621 result.stderr
12622 );
12623 assert!(result.stdout.contains("cat=1"), "{}", result.stdout);
12624 }
12625
12626 #[test]
12630 fn a_scoped_command_cannot_see_host_processes() {
12631 let test = "a_scoped_command_cannot_see_host_processes";
12632 let (_base, work, _outside) = work_and_outside();
12633 let policy = allow_only(&["bash", "ls", "cat"]);
12634 let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
12635 return;
12636 };
12637 if !mounts_private_proc(test, &enforcement) {
12638 return;
12639 }
12640 let sleeper = HostSleeper::start();
12641 let result = run_bash_in(
12642 &work,
12643 &host_process_script(sleeper.pid()),
12644 &policy,
12645 &enforcement,
12646 );
12647 enforcement.end_session();
12648 assert_sees_no_host_process(&result, &[sleeper.pid(), std::process::id()]);
12649 }
12650
12651 const NODE_OPENSSL_CONFIG: &str = "/etc/ssl/openssl.cnf";
12653
12654 #[test]
12657 fn a_scoped_node_counts_cpus_and_resolves_localhost() {
12658 let test = "a_scoped_node_counts_cpus_and_resolves_localhost";
12659 if resolve_exec_allowlist(&["node".to_string()]).is_empty() {
12660 eprintln!("SKIP — PROVES NOTHING: {test} needs node on PATH");
12661 return;
12662 }
12663 let (_base, work, _outside) = work_and_outside();
12664 let mut policy = allow_only(&["node"]);
12665 policy.resources.memory_bytes = 64 * 1024 * 1024 * 1024;
12667 if Path::new(NODE_OPENSSL_CONFIG).is_file() {
12670 policy.shell_interpreter_runtime = vec![murmur_artifact::InterpreterRuntimeGrant {
12671 binary: "node".to_string(),
12672 dirs: vec![murmur_artifact::InterpreterRuntimeDir {
12673 path: NODE_OPENSSL_CONFIG.to_string(),
12674 list_dir: false,
12675 }],
12676 }];
12677 }
12678 let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
12679 return;
12680 };
12681 if !mounts_private_proc(test, &enforcement) {
12682 return;
12683 }
12684 let result = execute(
12685 "node",
12686 &[
12687 "-e",
12688 "console.log('cpus=' + require('os').cpus().length); \
12689 require('dns').lookup('localhost', (error, address) => \
12690 console.log(error ? 'lookup=' + error.code : 'lookup=' + address));",
12691 ],
12692 &work,
12693 &policy,
12694 &enforcement,
12695 );
12696 enforcement.end_session();
12697 assert_eq!(result.exit_code, 0, "{}", result.stderr);
12698 let cpus: u32 = result
12699 .stdout
12700 .lines()
12701 .find_map(|line| line.strip_prefix("cpus="))
12702 .and_then(|n| n.parse().ok())
12703 .unwrap_or_else(|| panic!("{}", result.stdout));
12704 assert!(cpus > 0, "{}", result.stdout);
12705 let address = result
12706 .stdout
12707 .lines()
12708 .find_map(|line| line.strip_prefix("lookup="))
12709 .unwrap_or_else(|| panic!("{}", result.stdout));
12710 assert!(
12711 address == "127.0.0.1" || address == "::1",
12712 "{}",
12713 result.stdout
12714 );
12715 }
12716
12717 #[test]
12720 fn a_scoped_command_resolves_localhost_through_etc_hosts() {
12721 let test = "a_scoped_command_resolves_localhost_through_etc_hosts";
12722 let (_base, work, _outside) = work_and_outside();
12723 let policy = allow_only(&["bash", "cat", "getent"]);
12724 let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
12725 return;
12726 };
12727 let result = run_bash_in(
12728 &work,
12729 "getent hosts localhost; echo \"getent=$?\"; \
12730 cat /etc/hosts > /dev/null; echo \"hosts=$?\"; \
12731 cat /etc/nsswitch.conf > /dev/null; echo \"nsswitch=$?\"; \
12732 cat /etc/passwd > /dev/null; echo \"passwd=$?\"; \
12733 cat /etc/resolv.conf > /dev/null; echo \"resolv=$?\"",
12734 &policy,
12735 &enforcement,
12736 );
12737 enforcement.end_session();
12738 let stdout = &result.stdout;
12739 assert!(
12740 stdout
12741 .lines()
12742 .any(|line| line.starts_with("127.0.0.1") || line.starts_with("::1")),
12743 "{stdout}"
12744 );
12745 for read in ["getent=0", "hosts=0", "nsswitch=0"] {
12746 assert!(stdout.lines().any(|line| line == read), "{read}: {stdout}");
12747 }
12748 for denied in ["passwd=1", "resolv=1"] {
12749 assert!(
12750 stdout.lines().any(|line| line == denied),
12751 "{denied}: {stdout}"
12752 );
12753 }
12754 assert!(
12755 result.stderr.contains("/etc/passwd: Permission denied"),
12756 "{}",
12757 result.stderr
12758 );
12759 assert!(
12760 result
12761 .stderr
12762 .contains("/etc/resolv.conf: Permission denied"),
12763 "{}",
12764 result.stderr
12765 );
12766 }
12767
12768 #[test]
12771 fn a_command_under_its_relay_keeps_its_exit_status_and_signal() {
12772 let test = "a_command_under_its_relay_keeps_its_exit_status_and_signal";
12773 let (_base, work, _outside) = work_and_outside();
12774 let policy = allow_only(&["bash"]);
12775 let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
12776 return;
12777 };
12778 let exited = run_bash_in(&work, "exit 7", &policy, &enforcement);
12779 let killed = run_bash_in(&work, "kill -KILL $$", &policy, &enforcement);
12780 let parent = run_bash_in(&work, "echo \"ppid=$PPID pid=$$\"", &policy, &enforcement);
12781 enforcement.end_session();
12782 assert_eq!(exited.exit_code, 7);
12783 assert_eq!(killed.exit_code, 128 + libc::SIGKILL);
12784 assert!(
12785 parent.stdout.starts_with("ppid=0 pid="),
12786 "{}",
12787 parent.stdout
12788 );
12789 let pid: i32 = parent
12790 .stdout
12791 .trim()
12792 .rsplit('=')
12793 .next()
12794 .unwrap()
12795 .parse()
12796 .unwrap();
12797 assert!(pid > 1 && pid < 100, "a namespace pid: {}", parent.stdout);
12798 }
12799
12800 fn host_pid(namespace_pid: i32) -> i32 {
12803 for _ in 0..100 {
12804 if let Some(pid) =
12805 crate::network_namespace::host_pid_in_session(std::process::id(), namespace_pid)
12806 {
12807 return pid;
12808 }
12809 std::thread::sleep(std::time::Duration::from_millis(20));
12810 }
12811 panic!("no process of this test's sessions is pid {namespace_pid} in its namespace");
12812 }
12813
12814 #[test]
12817 fn a_backgrounded_process_survives_into_the_next_command_and_ends_with_the_session() {
12818 let test =
12819 "a_backgrounded_process_survives_into_the_next_command_and_ends_with_the_session";
12820 let (_base, work, _outside) = work_and_outside();
12821 let policy = allow_only(&["bash", "sleep"]);
12822 let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
12823 return;
12824 };
12825 let started = run_bash_in(
12826 &work,
12827 "sleep 300 >/dev/null 2>&1 & echo $! > sleeper.pid",
12828 &policy,
12829 &enforcement,
12830 );
12831 assert_eq!(started.exit_code, 0, "{}", started.stderr);
12832 let namespace_pid = pid_in(&work, "sleeper.pid");
12833 let sleeper = host_pid(namespace_pid);
12834 let holder = host_pid(1);
12835 assert_ne!(sleeper, namespace_pid, "the pid file holds a namespace pid");
12836 let seen = run_bash_in(
12837 &work,
12838 "kill -0 \"$(< sleeper.pid)\" && echo alive",
12839 &policy,
12840 &enforcement,
12841 );
12842 assert_eq!(seen.stdout.trim(), "alive", "{}", seen.stderr);
12843 assert!(process_exists(sleeper));
12844 enforcement.end_session();
12845 let wait = SESSION_END_KILL_WAIT + std::time::Duration::from_secs(3);
12846 assert!(
12847 process_gone_within(holder, wait),
12848 "holder {holder} outlived the session"
12849 );
12850 assert!(
12851 process_gone_within(sleeper, wait),
12852 "sleeper {sleeper} outlived the session"
12853 );
12854 }
12855
12856 #[test]
12859 fn a_dropped_session_ends_its_holder_and_its_processes() {
12860 let test = "a_dropped_session_ends_its_holder_and_its_processes";
12861 let (_base, work, _outside) = work_and_outside();
12862 let policy = allow_only(&["bash", "sleep"]);
12863 let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
12864 return;
12865 };
12866 let started = run_bash_in(
12867 &work,
12868 "sleep 300 >/dev/null 2>&1 & echo $! > sleeper.pid",
12869 &policy,
12870 &enforcement,
12871 );
12872 assert_eq!(started.exit_code, 0, "{}", started.stderr);
12873 let sleeper = host_pid(pid_in(&work, "sleeper.pid"));
12874 let holder = host_pid(1);
12875 drop(enforcement);
12876 assert!(
12877 process_gone_within(holder, SESSION_END_KILL_WAIT),
12878 "holder {holder} outlived its dropped session"
12879 );
12880 assert!(
12881 process_gone_within(sleeper, SESSION_END_KILL_WAIT),
12882 "sleeper {sleeper} outlived its dropped session"
12883 );
12884 }
12885
12886 #[test]
12889 fn killing_the_relay_ends_the_command() {
12890 use std::os::unix::process::{CommandExt, ExitStatusExt};
12891 let test = "killing_the_relay_ends_the_command";
12892 let (_base, work, _outside) = work_and_outside();
12893 let policy = allow_only(&["bash", "sleep"]);
12894 let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
12895 return;
12896 };
12897 let bash = resolve_exec_allowlist(&["bash".to_string()])
12898 .into_iter()
12899 .next()
12900 .unwrap();
12901 let mut command = std::process::Command::new(bash);
12902 command
12903 .args(["-c", "echo $$ > command.pid; exec sleep 300"])
12904 .current_dir(&work)
12905 .process_group(0);
12906 let _handle = prepare_enforcement(&mut command, &enforcement, &work).unwrap();
12907 let mut relay = command.spawn().unwrap();
12908 let pid_file = work.join("command.pid");
12909 for _ in 0..200 {
12910 if std::fs::read_to_string(&pid_file).is_ok_and(|text| text.ends_with('\n')) {
12911 break;
12912 }
12913 std::thread::sleep(std::time::Duration::from_millis(20));
12914 }
12915 let command_pid = host_pid(pid_in(&work, "command.pid"));
12916 assert_ne!(
12917 command_pid,
12918 relay.id() as i32,
12919 "the command is not its relay"
12920 );
12921 relay.kill().unwrap();
12922 let status = relay.wait().unwrap();
12923 assert_eq!(status.signal(), Some(libc::SIGKILL));
12924 assert!(
12925 process_gone_within(command_pid, std::time::Duration::from_secs(5)),
12926 "command {command_pid} outlived its relay"
12927 );
12928 enforcement.end_session();
12929 }
12930
12931 #[test]
12935 fn a_process_group_sigterm_reaches_the_command_past_its_relay() {
12936 use std::os::unix::process::{CommandExt, ExitStatusExt};
12937 let test = "a_process_group_sigterm_reaches_the_command_past_its_relay";
12938 let (_base, work, _outside) = work_and_outside();
12939 let policy = allow_only(&["bash", "sleep"]);
12940 let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
12941 return;
12942 };
12943 let bash = resolve_exec_allowlist(&["bash".to_string()])
12944 .into_iter()
12945 .next()
12946 .unwrap();
12947 let mut command = std::process::Command::new(bash);
12948 command
12949 .args(["-c", "echo $$ > command.pid; exec sleep 300"])
12950 .current_dir(&work)
12951 .process_group(0);
12952 let _handle = prepare_enforcement(&mut command, &enforcement, &work).unwrap();
12953 let mut relay = command.spawn().unwrap();
12954 let pid_file = work.join("command.pid");
12955 for _ in 0..200 {
12956 if std::fs::read_to_string(&pid_file).is_ok_and(|text| text.ends_with('\n')) {
12957 break;
12958 }
12959 std::thread::sleep(std::time::Duration::from_millis(20));
12960 }
12961 let command_pid = host_pid(pid_in(&work, "command.pid"));
12962 let sent_at = std::time::Instant::now();
12963 assert!(crate::formation_launch::signal_group(
12964 i64::from(relay.id()),
12965 libc::SIGTERM
12966 ));
12967 let status = relay.wait().unwrap();
12968 assert_eq!(status.signal(), Some(libc::SIGTERM), "{status:?}");
12969 assert!(
12970 sent_at.elapsed() < std::time::Duration::from_secs(5),
12971 "the command took {:?} to end",
12972 sent_at.elapsed()
12973 );
12974 assert!(
12975 process_gone_within(command_pid, std::time::Duration::from_secs(5)),
12976 "command {command_pid} outlived the group SIGTERM"
12977 );
12978 enforcement.end_session();
12979 }
12980
12981 #[test]
12984 fn a_sealed_command_reads_its_own_proc_and_sees_no_host_process() {
12985 let test = "a_sealed_command_reads_its_own_proc_and_sees_no_host_process";
12986 let (_base, work, _outside) = work_and_outside();
12987 let policy = allow_only(&["bash", "cat", "head", "ls"]);
12988 let Some((enforcement, _runtime)) = sealed_landlock_enforcement(test, &policy) else {
12989 return;
12990 };
12991 if !mounts_private_proc(test, &enforcement) {
12992 return;
12993 }
12994 let own = run_bash_in(&work, OWN_PROC_SCRIPT, &policy, &enforcement);
12995 let sleeper = HostSleeper::start();
12996 let host = run_bash_in(
12997 &work,
12998 &host_process_script(sleeper.pid()),
12999 &policy,
13000 &enforcement,
13001 );
13002 enforcement.end_session();
13003 assert_reads_own_proc(&own);
13004 assert_sees_no_host_process(&host, &[sleeper.pid(), std::process::id()]);
13005 }
13006
13007 #[test]
13010 fn a_host_proc_view_keeps_proc_denied_and_says_why() {
13011 let test = "a_host_proc_view_keeps_proc_denied_and_says_why";
13012 let (_base, work, _outside) = work_and_outside();
13013 let policy = allow_only(&["bash", "cat", "ls"]);
13014 let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
13015 return;
13016 };
13017 let refused =
13018 crate::proc_view::ProcView::Host(crate::proc_view::HostProcReason::MountRefused(
13019 "mounting proc over /proc failed with EPERM".to_string(),
13020 ));
13021 let enforcement = enforcement.with_proc_view(refused.clone());
13022 assert_eq!(enforcement.installed_proc_view(), refused);
13023 let result = run_bash_in(
13024 &work,
13025 "cat /proc/self/status; echo \"cat=$?\"; ls /proc/1 >/dev/null; echo \"ls=$?\"",
13026 &policy,
13027 &enforcement,
13028 );
13029 enforcement.end_session();
13030 assert!(result.stdout.contains("cat=1"), "{}", result.stdout);
13031 assert!(
13032 result
13033 .stderr
13034 .contains("/proc/self/status: Permission denied"),
13035 "{}",
13036 result.stderr
13037 );
13038 let report = crate::proc_view::process_view_report(&refused);
13039 assert!(!report.private);
13040 assert!(report
13041 .not_private_because
13042 .is_some_and(|reason| reason.contains("failed with EPERM")),);
13043 }
13044
13045 #[test]
13048 fn a_private_proc_mount_that_fails_at_spawn_fails_the_spawn() {
13049 let test = "a_private_proc_mount_that_fails_at_spawn_fails_the_spawn";
13050 let (_base, work, _outside) = work_and_outside();
13051 let policy = allow_only(&["bash"]);
13052 let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
13053 return;
13054 };
13055 if !mounts_private_proc(test, &enforcement) {
13056 return;
13057 }
13058 let _armed = ForcePrivateProcFailureGuard::new();
13059 let error = crate::shell::execute_shell(
13060 "bash",
13061 &["-c", "echo ran > ran.txt"],
13062 &[],
13063 &crate::workdir_fs::WorkdirRoot::open(&work).unwrap(),
13064 &policy,
13065 &enforcement,
13066 )
13067 .expect_err("a failed private /proc mount must fail the spawn");
13068 enforcement.end_session();
13069 let error = error.to_string();
13070 assert!(
13071 error.contains(crate::proc_view::PRIVATE_PROC_FAILURE_PREFIX),
13072 "{error}"
13073 );
13074 assert!(
13075 error.contains("mounting proc over /proc failed: EPERM"),
13076 "{error}"
13077 );
13078 assert!(!work.join("ran.txt").exists());
13079 }
13080
13081 #[test]
13084 fn scoped_name_resolution_grants_are_a_read_only_subset_of_sealed_etc() {
13085 for path in SCOPED_NAME_RESOLUTION_PATHS {
13086 assert!(
13087 crate::sealed::SEALED_ETC_PATHS
13088 .iter()
13089 .any(|entry| entry.path == *path && !entry.directory),
13090 "{path} is not a SEALED_ETC_PATHS file"
13091 );
13092 }
13093 let grants = resolve_scoped_name_resolution_landlock_grants(EnforcementTier::KernelFull);
13094 assert_eq!(
13095 grants
13096 .iter()
13097 .map(|grant| grant.path.clone())
13098 .collect::<Vec<_>>(),
13099 vec![
13100 PathBuf::from("/etc/hosts"),
13101 PathBuf::from("/etc/nsswitch.conf")
13102 ]
13103 );
13104 assert!(grants
13105 .iter()
13106 .all(|grant| !grant.list_dir && !grant.executable));
13107 for tier in [
13108 EnforcementTier::KernelSealed,
13109 EnforcementTier::KernelSeccompOnly,
13110 EnforcementTier::EnvironmentOnly,
13111 EnforcementTier::Unconfined,
13112 ] {
13113 assert!(resolve_scoped_name_resolution_landlock_grants(tier).is_empty());
13114 }
13115 }
13116
13117 #[test]
13120 fn a_creator_refused_the_pid_namespace_is_refused_with_e_cap_005() {
13121 let test = "a_creator_refused_the_pid_namespace_is_refused_with_e_cap_005";
13122 let (_base, work, _outside) = work_and_outside();
13123 let policy = allow_only(&["bash"]);
13124 let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
13125 return;
13126 };
13127 let _armed = crate::network_namespace::ForcePidNamespaceFailure::arm();
13128 let error = crate::shell::execute_shell(
13129 "bash",
13130 &["-c", "echo ran > ran.txt"],
13131 &[],
13132 &crate::workdir_fs::WorkdirRoot::open(&work).unwrap(),
13133 &policy,
13134 &enforcement,
13135 )
13136 .expect_err("a session without its PID namespace must not run");
13137 enforcement.end_session();
13138 let error = error.to_string();
13139 assert!(error.contains("E-CAP-005"), "{error}");
13140 assert!(
13141 error
13142 .contains("unshare(CLONE_NEWUSER | CLONE_NEWNET | CLONE_NEWPID) failed with EPERM"),
13143 "{error}"
13144 );
13145 assert!(error.contains("CLONE_NEWPID"), "{error}");
13146 assert!(!work.join("ran.txt").exists());
13147 }
13148
13149 #[test]
13152 fn the_trust_store_grant_reaches_nothing_beyond_the_trust_store() {
13153 let test = "the_trust_store_grant_reaches_nothing_beyond_the_trust_store";
13154 let (_base, work, _outside) = work_and_outside();
13155 let policy = trust_store_policy(&["cat", "ls", "bash"], &["127.0.0.1"], &work);
13156 for entry in &policy.shell_trust_store {
13157 for broad in ["/", "/etc", "/etc/ssl", "/etc/pki", "/etc/pki/tls"] {
13158 assert_ne!(entry.path, Path::new(broad));
13159 }
13160 assert!(
13161 !entry.path.components().any(|c| c.as_os_str() == "private"),
13162 "{entry:?}"
13163 );
13164 }
13165 assert!(resolve_trust_store_grants(&policy.shell_trust_store)
13166 .iter()
13167 .all(|grant| !grant.executable));
13168 if std::fs::metadata("/etc/ssl/certs").map_or(true, |m| !m.is_dir()) {
13169 eprintln!("SKIP — PROVES NOTHING: {test} needs /etc/ssl/certs on this host");
13170 return;
13171 }
13172 let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
13173 return;
13174 };
13175
13176 let listed = execute("ls", &["/etc/ssl/certs"], &work, &policy, &enforcement);
13177 assert_eq!(listed.exit_code, 0, "{}", listed.stderr);
13178 let above = execute("ls", &["/etc/ssl"], &work, &policy, &enforcement);
13179 assert_ne!(above.exit_code, 0);
13180 assert!(
13181 above.stderr.contains("Permission denied"),
13182 "{}",
13183 above.stderr
13184 );
13185 if std::fs::metadata("/etc/ssl/openssl.cnf").is_ok() {
13186 let config = execute(
13187 "cat",
13188 &["/etc/ssl/openssl.cnf"],
13189 &work,
13190 &policy,
13191 &enforcement,
13192 );
13193 assert_ne!(config.exit_code, 0);
13194 assert!(
13195 config.stderr.contains("Permission denied"),
13196 "{}",
13197 config.stderr
13198 );
13199 }
13200 let private = execute("ls", &["/etc/ssl/private"], &work, &policy, &enforcement);
13201 assert_ne!(private.exit_code, 0);
13202 let probe = Path::new("/etc/ssl/certs/murmur-probe");
13203 let written = execute(
13204 "bash",
13205 &["-c", "echo x > /etc/ssl/certs/murmur-probe"],
13206 &work,
13207 &policy,
13208 &enforcement,
13209 );
13210 enforcement.end_session();
13211 assert_ne!(written.exit_code, 0);
13212 assert!(std::fs::symlink_metadata(probe).is_err());
13213 }
13214
13215 #[test]
13218 fn a_scoped_command_cannot_write_the_host_facts_cache() {
13219 let test = "a_scoped_command_cannot_write_the_host_facts_cache";
13220 let home = home_tempdir();
13221 let workdir = home_tempdir();
13222 let cache = home.path().join(".murmur").join(
13223 crate::murmur_home::HomeEntryKind::HostFacts
13224 .file_name()
13225 .unwrap(),
13226 );
13227 crate::murmur_home::hold_private_dir(&home.path().join(".murmur")).unwrap();
13228 crate::murmur_home::hold_private_dir(&cache).unwrap();
13229 let policy = coreutils_policy(&[]);
13230 let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
13231 return;
13232 };
13233 let probe = cache.join("probe");
13234 let written = run_bash_in(
13235 workdir.path(),
13236 &format!("printf x > '{}'", probe.display()),
13237 &policy,
13238 &enforcement,
13239 );
13240 enforcement.end_session();
13241 assert_ne!(written.exit_code, 0);
13242 assert!(
13243 written.stderr.contains("Permission denied"),
13244 "{}",
13245 written.stderr
13246 );
13247 assert!(std::fs::symlink_metadata(&probe).is_err());
13248 }
13249
13250 #[test]
13251 fn resolve_trust_store_grants_reads_and_lists_but_never_executes() {
13252 let paths = [
13253 crate::trust_store::TrustStorePath {
13254 path: PathBuf::from("/etc/ssl/certs"),
13255 directory: true,
13256 },
13257 crate::trust_store::TrustStorePath {
13258 path: PathBuf::from("/etc/pki/tls/cacert.pem"),
13259 directory: false,
13260 },
13261 ];
13262 assert_eq!(
13263 resolve_trust_store_grants(&paths),
13264 vec![
13265 LandlockGrant {
13266 path: PathBuf::from("/etc/ssl/certs"),
13267 list_dir: true,
13268 executable: false,
13269 },
13270 LandlockGrant {
13271 path: PathBuf::from("/etc/pki/tls/cacert.pem"),
13272 list_dir: false,
13273 executable: false,
13274 },
13275 ]
13276 );
13277 assert!(resolve_trust_store_grants(&[]).is_empty());
13278 }
13279
13280 #[test]
13283 fn shell_enforcement_resolve_adds_the_trust_store() {
13284 use murmur_artifact::ContainmentClass;
13285 let store = vec![
13286 crate::trust_store::TrustStorePath {
13287 path: PathBuf::from("/murmur-test/certs"),
13288 directory: true,
13289 },
13290 crate::trust_store::TrustStorePath {
13291 path: PathBuf::from("/murmur-test/bundle.pem"),
13292 directory: false,
13293 },
13294 ];
13295 let expected = resolve_trust_store_grants(&store);
13296 let with_store = CapabilityPolicy {
13297 shell_trust_store: store.clone(),
13298 ..CapabilityPolicy::default()
13299 };
13300 let without = CapabilityPolicy::default();
13301 for (tier, declared) in [
13302 (EnforcementTier::KernelFull, ContainmentClass::Scoped),
13303 (EnforcementTier::KernelSealed, ContainmentClass::Sealed),
13304 ] {
13305 let decision =
13306 crate::containment::ContainmentDecision::decide(tier, declared, false, false);
13307 let granted = |policy: &CapabilityPolicy| {
13308 let enforcement =
13309 ShellEnforcement::resolve(policy, &decision, HostProbe::probe()).unwrap();
13310 assert_eq!(enforcement.tier, tier);
13311 enforcement.landlock_grants
13312 };
13313 let with_grants = granted(&with_store);
13314 let without_grants = granted(&without);
13315 for grant in &expected {
13316 assert_eq!(
13317 with_grants.iter().filter(|g| *g == grant).count(),
13318 1,
13319 "{tier:?}: {grant:?}"
13320 );
13321 }
13322 assert!(without_grants
13323 .iter()
13324 .all(|grant| !grant.path.starts_with("/murmur-test")));
13325 assert_eq!(with_grants.len(), without_grants.len() + expected.len());
13326 let sealed_etc = resolve_sealed_etc_landlock_grants(tier);
13327 assert_eq!(sealed_etc.is_empty(), tier != EnforcementTier::KernelSealed);
13328 for grant in &sealed_etc {
13329 assert!(with_grants.contains(grant), "{tier:?}: {grant:?}");
13330 }
13331 }
13332 }
13333
13334 #[test]
13338 fn a_scoped_python3_imports_its_stdlib_and_nothing_outside_it() {
13339 let test = "a_scoped_python3_imports_its_stdlib_and_nothing_outside_it";
13340 if find_on_path("python3").is_none() {
13341 println!("[SKIP] {test}: no python3 on PATH");
13342 return;
13343 }
13344 let (_base, work, outside) = work_and_outside();
13345 let policy = derived_interpreter_policy("python3", &work);
13346 let stdlib = assert_derives_stdlib_and_sqlite(&policy);
13347 let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
13348 return;
13349 };
13350
13351 let imported = execute(
13352 "python3",
13353 &["-c", "import json, sqlite3, venv; print('imported')"],
13354 &work,
13355 &policy,
13356 &enforcement,
13357 );
13358 assert_eq!(imported.exit_code, 0, "{}", imported.stderr);
13359 assert_eq!(imported.stdout.trim(), "imported");
13360
13361 let secret = outside.join("secret.txt");
13362 std::fs::write(&secret, "secret").unwrap();
13363 let probe = python_probe_outside(&secret, stdlib.parent().unwrap());
13364 let denied = execute("python3", &["-c", &probe], &work, &policy, &enforcement);
13365 assert_eq!(denied.exit_code, 0, "{}", denied.stderr);
13366 assert_eq!(
13367 denied.stdout.lines().collect::<Vec<_>>(),
13368 vec!["open PermissionError", "listdir PermissionError"]
13369 );
13370 }
13371
13372 #[test]
13375 fn a_sealed_python3_imports_its_stdlib_and_nothing_outside_it() {
13376 let test = "a_sealed_python3_imports_its_stdlib_and_nothing_outside_it";
13377 if crate::network_namespace::skip_without_egress_namespace(test) {
13378 return;
13379 }
13380 if find_on_path("python3").is_none() {
13381 println!("[SKIP] {test}: no python3 on PATH");
13382 return;
13383 }
13384 let probe = HostProbe::probe();
13385 if probe.tier() != EnforcementTier::KernelSealed {
13386 eprintln!(
13387 "SKIP — PROVES NOTHING ABOUT THE SEALED ROOT ON THIS HOST: {test} needs a host \
13388 that reaches KernelSealed; detected {:?}. No composed root is built in this run.",
13389 probe.tier()
13390 );
13391 return;
13392 }
13393 let (_base, work, outside) = work_and_outside();
13394 let policy = derived_interpreter_policy("python3", &work);
13395 assert_derives_stdlib_and_sqlite(&policy);
13396 let runtime = tempfile::tempdir().unwrap();
13397 let enforcement = ShellEnforcement::resolve_declared(
13398 &policy,
13399 murmur_artifact::ContainmentClass::Sealed,
13400 probe,
13401 )
13402 .expect("a sealed-capable host resolves sealed enforcement")
13403 .with_runtime_dir(test_runtime_dir(runtime.path()), None);
13404 assert_eq!(enforcement.tier, EnforcementTier::KernelSealed);
13405
13406 let imported = execute(
13407 "python3",
13408 &["-c", "import json, sqlite3, venv; print('imported')"],
13409 &work,
13410 &policy,
13411 &enforcement,
13412 );
13413 assert_eq!(imported.exit_code, 0, "{}", imported.stderr);
13414 assert_eq!(imported.stdout.trim(), "imported");
13415
13416 let secret = outside.join("secret.txt");
13417 std::fs::write(&secret, "secret").unwrap();
13418 let program = format!(
13419 "try:\n open({:?}).read(); print('allowed')\nexcept Exception as error:\n \
13420 print(type(error).__name__)\n",
13421 secret.to_string_lossy()
13422 );
13423 let denied = execute("python3", &["-c", &program], &work, &policy, &enforcement);
13424 assert_eq!(denied.exit_code, 0, "{}", denied.stderr);
13425 let outcome = denied.stdout.trim();
13426 assert!(
13427 outcome == "PermissionError" || outcome == "FileNotFoundError",
13428 "{outcome}"
13429 );
13430 }
13431
13432 #[test]
13435 fn a_scoped_perl_loads_posix_from_its_inc() {
13436 let test = "a_scoped_perl_loads_posix_from_its_inc";
13437 if find_on_path("perl").is_none() {
13438 println!("[SKIP] {test}: no perl on PATH");
13439 return;
13440 }
13441 let (_base, work, _outside) = work_and_outside();
13442 let policy = derived_interpreter_policy("perl", &work);
13443 assert!(
13444 !policy.shell_derived_interpreter_runtime.is_empty(),
13445 "perl was not recognised or reported nothing"
13446 );
13447 let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
13448 return;
13449 };
13450 let result = execute(
13451 "perl",
13452 &["-MPOSIX", "-e", "print qq{loaded\n}"],
13453 &work,
13454 &policy,
13455 &enforcement,
13456 );
13457 assert_eq!(result.exit_code, 0, "{}", result.stderr);
13458 assert_eq!(result.stdout.trim(), "loaded");
13459 }
13460
13461 #[test]
13466 fn host_path_landlock_rights_are_read_list_or_the_workdir_set_without_execute() {
13467 use landlock::AccessFs;
13468 assert_eq!(
13469 linux_enforce::host_path_access_rights(false),
13470 AccessFs::ReadFile | AccessFs::ReadDir
13471 );
13472 assert_eq!(
13473 linux_enforce::host_path_access_rights(true),
13474 linux_enforce::workdir_access_rights(false)
13475 );
13476 for read_write in [false, true] {
13477 assert!(!linux_enforce::host_path_access_rights(read_write).contains(AccessFs::Execute));
13478 }
13479 }
13480
13481 struct HostPathDirs {
13484 _base: tempfile::TempDir,
13485 ro: PathBuf,
13486 rw: PathBuf,
13487 outside: PathBuf,
13488 }
13489
13490 impl HostPathDirs {
13491 fn new() -> Self {
13492 let base = home_tempdir();
13493 let root = std::fs::canonicalize(base.path()).unwrap();
13494 let (ro, rw, outside) = (root.join("ro"), root.join("rw"), root.join("outside"));
13495 for dir in [&ro, &rw, &outside] {
13496 std::fs::create_dir(dir).unwrap();
13497 }
13498 std::fs::write(ro.join("in.txt"), "lent\n").unwrap();
13499 std::fs::write(outside.join("secret.txt"), "secret\n").unwrap();
13500 Self {
13501 _base: base,
13502 ro,
13503 rw,
13504 outside,
13505 }
13506 }
13507
13508 fn host_paths(&self) -> Vec<crate::host_paths::HostPath> {
13509 use crate::host_paths::{HostPath, HostPathAccess};
13510 vec![
13511 HostPath {
13512 declared: self.ro.display().to_string(),
13513 path: self.ro.clone(),
13514 access: HostPathAccess::Read,
13515 credential_locations: Vec::new(),
13516 },
13517 HostPath {
13518 declared: self.rw.display().to_string(),
13519 path: self.rw.clone(),
13520 access: HostPathAccess::ReadWrite,
13521 credential_locations: Vec::new(),
13522 },
13523 ]
13524 }
13525
13526 fn assert_reach(&self, enforcement: &ShellEnforcement, policy: &CapabilityPolicy) {
13529 let workdir = tempfile::tempdir().unwrap();
13530 let read = run_bash_in(
13531 workdir.path(),
13532 &format!(
13533 "read -r line < '{}/in.txt' && echo \"$line\"",
13534 self.ro.display()
13535 ),
13536 policy,
13537 enforcement,
13538 );
13539 assert_eq!(read.exit_code, 0, "{}", read.stderr);
13540 assert!(
13541 read.stdout.lines().any(|line| line == "lent"),
13542 "{}",
13543 read.stdout
13544 );
13545
13546 let write_ro = run_bash_in(
13547 workdir.path(),
13548 &format!("echo x > '{}/new'", self.ro.display()),
13549 policy,
13550 enforcement,
13551 );
13552 assert_ne!(write_ro.exit_code, 0);
13553 assert!(
13554 write_ro.stderr.contains("Permission denied")
13555 || write_ro.stderr.contains("Read-only file system"),
13556 "{}",
13557 write_ro.stderr
13558 );
13559 assert!(!self.ro.join("new").exists());
13560
13561 let write_rw = run_bash_in(
13562 workdir.path(),
13563 &format!("echo written > '{}/new'", self.rw.display()),
13564 policy,
13565 enforcement,
13566 );
13567 assert_eq!(write_rw.exit_code, 0, "{}", write_rw.stderr);
13568 assert_eq!(
13569 std::fs::read_to_string(self.rw.join("new")).unwrap(),
13570 "written\n"
13571 );
13572
13573 let outside = run_bash_in(
13574 workdir.path(),
13575 &format!("read -r line < '{}/secret.txt'", self.outside.display()),
13576 policy,
13577 enforcement,
13578 );
13579 assert_ne!(outside.exit_code, 0, "outside/secret.txt was read");
13580 }
13581 }
13582
13583 fn host_path_enforcement(
13586 test: &str,
13587 policy: &CapabilityPolicy,
13588 class: murmur_artifact::ContainmentClass,
13589 tier: EnforcementTier,
13590 ) -> Option<(ShellEnforcement, tempfile::TempDir)> {
13591 if crate::network_namespace::skip_without_egress_namespace(test) {
13592 return None;
13593 }
13594 let probe = HostProbe::probe();
13595 let reaches = match tier {
13596 EnforcementTier::KernelSealed => probe.tier() == EnforcementTier::KernelSealed,
13597 _ => matches!(
13598 probe.tier(),
13599 EnforcementTier::KernelFull | EnforcementTier::KernelSealed
13600 ),
13601 };
13602 if !reaches {
13603 eprintln!(
13604 "SKIP — PROVES NOTHING ON THIS HOST: {test} needs {tier:?}; detected {:?}",
13605 probe.tier()
13606 );
13607 return None;
13608 }
13609 let runtime = home_tempdir();
13610 let enforcement = ShellEnforcement::resolve_declared(policy, class, probe)
13611 .expect("the enforcement resolves")
13612 .with_runtime_dir(test_runtime_dir(runtime.path()), None);
13613 assert_eq!(enforcement.tier, tier);
13614 eprintln!("{test}: tier {tier:?}");
13615 Some((enforcement, runtime))
13616 }
13617
13618 fn resolve_on_tier(
13621 policy: &CapabilityPolicy,
13622 tier: EnforcementTier,
13623 class: murmur_artifact::ContainmentClass,
13624 ) -> Result<ShellEnforcement, String> {
13625 let decision = crate::containment::ContainmentDecision::decide(tier, class, false, false);
13626 let probe = HostProbe::from_parts(
13627 None,
13628 crate::namespace_probe::NamespaceReading {
13629 network: Err(crate::network_namespace::NamespaceAttemptError::Creator(
13630 "not probed".to_string(),
13631 )),
13632 mount: crate::namespace_probe::MountAttempt::NotAttempted,
13633 sealed_root: crate::namespace_probe::MountAttempt::NotAttempted,
13634 private_proc: crate::namespace_probe::MountAttempt::NotAttempted,
13635 userns_grant: crate::sealed::UsernsGrant::Withheld,
13636 attachment: crate::sealed::ProfileAttachment::NotReported,
13637 },
13638 );
13639 ShellEnforcement::resolve(policy, &decision, probe)
13640 }
13641
13642 #[test]
13643 fn host_paths_on_kernel_full_read_write_and_outside() {
13644 const TEST: &str = "host_paths_on_kernel_full_read_write_and_outside";
13645 let dirs = HostPathDirs::new();
13646 let policy = CapabilityPolicy {
13647 shell_allow: vec!["bash".to_string()],
13648 host_paths: dirs.host_paths(),
13649 ..CapabilityPolicy::default()
13650 };
13651 let Some((enforcement, _runtime)) = host_path_enforcement(
13652 TEST,
13653 &policy,
13654 murmur_artifact::ContainmentClass::Scoped,
13655 EnforcementTier::KernelFull,
13656 ) else {
13657 return;
13658 };
13659 dirs.assert_reach(&enforcement, &policy);
13660 }
13661
13662 #[test]
13663 fn host_paths_on_kernel_sealed_are_bound_at_their_own_path() {
13664 const TEST: &str = "host_paths_on_kernel_sealed_are_bound_at_their_own_path";
13665 let dirs = HostPathDirs::new();
13666 let policy = CapabilityPolicy {
13667 shell_allow: vec!["bash".to_string()],
13668 host_paths: dirs.host_paths(),
13669 ..CapabilityPolicy::default()
13670 };
13671 let Some((enforcement, _runtime)) = host_path_enforcement(
13672 TEST,
13673 &policy,
13674 murmur_artifact::ContainmentClass::Sealed,
13675 EnforcementTier::KernelSealed,
13676 ) else {
13677 return;
13678 };
13679 dirs.assert_reach(&enforcement, &policy);
13680 let workdir = tempfile::tempdir().unwrap();
13682 let absent = run_bash_in(
13683 workdir.path(),
13684 &format!(
13685 "[ -e '{}/secret.txt' ] && echo present || echo absent",
13686 dirs.outside.display()
13687 ),
13688 &policy,
13689 &enforcement,
13690 );
13691 assert!(
13692 absent.stdout.lines().any(|line| line == "absent"),
13693 "{}",
13694 absent.stdout
13695 );
13696 }
13697
13698 #[test]
13702 fn a_read_write_host_path_over_an_execute_root_is_refused_scoped_and_protected_sealed() {
13703 const TEST: &str =
13704 "a_read_write_host_path_over_an_execute_root_is_refused_scoped_and_protected_sealed";
13705 let base = home_tempdir();
13706 let tools = std::fs::canonicalize(base.path()).unwrap().join("tools");
13707 let bin = tools.join("bin");
13708 std::fs::create_dir_all(&bin).unwrap();
13709 std::fs::write(bin.join("tool"), "#!/bin/bash\necho tool ran\n").unwrap();
13710 let policy = CapabilityPolicy {
13711 shell_allow: vec!["bash".to_string()],
13712 shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
13713 binary: "bash".to_string(),
13714 dirs: vec![murmur_artifact::InterpreterRuntimeDir {
13715 path: bin.display().to_string(),
13716 list_dir: true,
13717 }],
13718 }],
13719 host_paths: vec![crate::host_paths::HostPath {
13720 declared: tools.display().to_string(),
13721 path: tools.clone(),
13722 access: crate::host_paths::HostPathAccess::ReadWrite,
13723 credential_locations: Vec::new(),
13724 }],
13725 ..CapabilityPolicy::default()
13726 };
13727 let error = resolve_on_tier(
13728 &policy,
13729 EnforcementTier::KernelFull,
13730 murmur_artifact::ContainmentClass::Scoped,
13731 )
13732 .expect_err("scoped refuses a read-write host path over an execute root");
13733 assert!(error.contains(&bin.display().to_string()), "{error}");
13734
13735 let Some((enforcement, _runtime)) = host_path_enforcement(
13736 TEST,
13737 &policy,
13738 murmur_artifact::ContainmentClass::Sealed,
13739 EnforcementTier::KernelSealed,
13740 ) else {
13741 return;
13742 };
13743 assert_eq!(enforcement.host_path_read_only_binds, vec![bin.clone()]);
13744 let workdir = tempfile::tempdir().unwrap();
13745 let rewrite = run_bash_in(
13746 workdir.path(),
13747 &format!("echo x > '{}/tool'", bin.display()),
13748 &policy,
13749 &enforcement,
13750 );
13751 assert_ne!(rewrite.exit_code, 0);
13752 assert!(
13753 rewrite.stderr.contains("Read-only file system"),
13754 "{}",
13755 rewrite.stderr
13756 );
13757 let add = run_bash_in(
13758 workdir.path(),
13759 &format!("echo x > '{}/new'", bin.display()),
13760 &policy,
13761 &enforcement,
13762 );
13763 assert_ne!(add.exit_code, 0);
13764 assert!(
13765 add.stderr.contains("Read-only file system"),
13766 "{}",
13767 add.stderr
13768 );
13769 assert_eq!(
13770 std::fs::read_to_string(bin.join("tool")).unwrap(),
13771 "#!/bin/bash\necho tool ran\n"
13772 );
13773 assert!(!bin.join("new").exists());
13774 let elsewhere = run_bash_in(
13775 workdir.path(),
13776 &format!("echo kept > '{}/notes'", tools.display()),
13777 &policy,
13778 &enforcement,
13779 );
13780 assert_eq!(elsewhere.exit_code, 0, "{}", elsewhere.stderr);
13781 assert_eq!(
13782 std::fs::read_to_string(tools.join("notes")).unwrap(),
13783 "kept\n"
13784 );
13785 }
13786
13787 #[test]
13789 fn a_vanished_host_path_fails_the_spawn_naming_it() {
13790 let base = home_tempdir();
13791 let gone = std::fs::canonicalize(base.path()).unwrap().join("gone");
13792 std::fs::create_dir(&gone).unwrap();
13793 let host_paths = vec![crate::host_paths::HostPath {
13794 declared: gone.display().to_string(),
13795 path: gone.clone(),
13796 access: crate::host_paths::HostPathAccess::Read,
13797 credential_locations: Vec::new(),
13798 }];
13799 std::fs::remove_dir(&gone).unwrap();
13800 let workdir = tempfile::tempdir().unwrap();
13801 for tier in [EnforcementTier::KernelFull, EnforcementTier::KernelSealed] {
13802 let enforcement = ShellEnforcement {
13803 tier,
13804 host_paths: host_paths.clone(),
13805 ..host_bounding_base().with_test_session_dirs()
13806 };
13807 let mut command = std::process::Command::new("bash");
13808 let Err(error) = prepare_enforcement(&mut command, &enforcement, workdir.path()) else {
13809 panic!("{tier:?}: a vanished host path must fail the spawn");
13810 };
13811 assert!(
13812 error.contains(&gone.display().to_string()),
13813 "{tier:?}: {error}"
13814 );
13815 }
13816 }
13817
13818 #[test]
13820 fn no_host_paths_add_nothing_to_the_enforcement() {
13821 let policy = CapabilityPolicy {
13822 shell_allow: vec!["bash".to_string()],
13823 ..CapabilityPolicy::default()
13824 };
13825 for tier in [EnforcementTier::KernelFull, EnforcementTier::KernelSealed] {
13826 let class = if tier == EnforcementTier::KernelSealed {
13827 murmur_artifact::ContainmentClass::Sealed
13828 } else {
13829 murmur_artifact::ContainmentClass::Scoped
13830 };
13831 let enforcement = resolve_on_tier(&policy, tier, class).unwrap();
13832 assert_eq!(enforcement.tier, tier);
13833 assert!(enforcement.host_paths.is_empty());
13834 assert!(enforcement.host_path_read_only_binds.is_empty());
13835 }
13836 }
13837}