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
1050pub(crate) fn 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_declared_need_grants(
1268 needs: &[crate::declared_needs::ResolvedProgramNeeds],
1269) -> Vec<LandlockGrant> {
1270 needs
1271 .iter()
1272 .flat_map(|program| program.granted.iter())
1273 .flat_map(|need| need.paths.iter())
1274 .map(|entry| LandlockGrant {
1275 path: entry.path.clone(),
1276 list_dir: entry.directory,
1277 executable: false,
1278 })
1279 .collect()
1280}
1281
1282pub(crate) fn resolve_sealed_need_mirrors(
1290 needs: &[crate::declared_needs::ResolvedProgramNeeds],
1291 bind_dirs: &[PathBuf],
1292) -> Vec<PathBuf> {
1293 let mut mirrors: Vec<PathBuf> = Vec::new();
1294 for path in needs
1295 .iter()
1296 .flat_map(|program| program.granted.iter())
1297 .flat_map(|need| need.paths.iter())
1298 .map(|entry| &entry.path)
1299 {
1300 let carried = crate::sealed::SEALED_RUNTIME_PATHS
1301 .iter()
1302 .chain(
1303 crate::sealed::SEALED_ETC_PATHS
1304 .iter()
1305 .map(|entry| &entry.path),
1306 )
1307 .any(|root| path.starts_with(root))
1308 || bind_dirs.iter().any(|dir| path.starts_with(dir));
1309 if path.is_absolute() && !carried && !mirrors.contains(path) {
1310 mirrors.push(path.clone());
1311 }
1312 }
1313 mirrors
1314}
1315
1316pub(crate) fn resolve_sealed_bind_dirs(
1335 exec_allow_paths: &[PathBuf],
1336 policy: &CapabilityPolicy,
1337) -> Vec<PathBuf> {
1338 let mut dirs: Vec<PathBuf> = Vec::new();
1339 let candidates = exec_allow_paths
1340 .iter()
1341 .filter_map(|binary| binary.parent().map(Path::to_path_buf))
1342 .chain(
1343 policy
1344 .shell_interpreter_runtime
1345 .iter()
1346 .flat_map(|grant| grant.dirs.iter().map(|dir| PathBuf::from(&dir.path))),
1347 )
1348 .chain(
1349 policy
1350 .shell_derived_interpreter_runtime
1351 .iter()
1352 .flat_map(|runtime| {
1353 runtime
1354 .entries()
1355 .filter(|(_, is_dir)| *is_dir)
1356 .map(|(path, _)| path.to_path_buf())
1357 .chain(
1358 runtime
1359 .libraries
1360 .iter()
1361 .filter_map(|library| library.parent().map(Path::to_path_buf)),
1362 )
1363 .collect::<Vec<_>>()
1364 }),
1365 );
1366
1367 for candidate in candidates {
1368 push_sealed_bind_dir(&mut dirs, candidate);
1369 }
1370 dirs
1371}
1372
1373pub(crate) fn host_path_execute_roots(
1379 policy: &CapabilityPolicy,
1380) -> Vec<crate::host_paths::ExecuteRoot> {
1381 let exec_allow_paths = resolve_policy_exec_allowlist(policy);
1382 execute_roots_from(&resolve_landlock_grants(&exec_allow_paths), policy)
1383}
1384
1385fn execute_roots_from(
1387 closure: &[PathBuf],
1388 policy: &CapabilityPolicy,
1389) -> Vec<crate::host_paths::ExecuteRoot> {
1390 let mut roots: Vec<crate::host_paths::ExecuteRoot> = Vec::new();
1391 let mut push = |dir: PathBuf, because: PathBuf| {
1392 let dir = std::fs::canonicalize(&dir).unwrap_or(dir);
1393 if dir.is_absolute() && !roots.iter().any(|root| root.dir == dir) {
1394 roots.push(crate::host_paths::ExecuteRoot { dir, because });
1395 }
1396 };
1397 for file in closure {
1398 if let Some(parent) = file.parent() {
1399 push(parent.to_path_buf(), file.clone());
1400 }
1401 }
1402 for grant in &policy.shell_interpreter_runtime {
1403 for dir in &grant.dirs {
1404 push(PathBuf::from(&dir.path), PathBuf::from(&dir.path));
1405 }
1406 }
1407 for dir in resolve_staged_runtime_dirs(policy) {
1408 push(dir.clone(), dir);
1409 }
1410 for dir in resolve_pinned_program_dirs(policy) {
1411 push(dir.clone(), dir);
1412 }
1413 roots
1414}
1415
1416pub(crate) fn resolve_policy_exec_allowlist(policy: &CapabilityPolicy) -> Vec<PathBuf> {
1421 let mut resolved: std::collections::BTreeSet<PathBuf> =
1422 resolve_exec_allowlist(&policy.shell_allow)
1423 .into_iter()
1424 .collect();
1425 resolved.extend(resolve_exec_allowlist_in(
1426 &policy.shell_allow,
1427 &crate::pinned_programs::pinned_bin_dirs(&policy.shell_pinned_programs),
1428 ));
1429 resolved.into_iter().collect()
1430}
1431
1432pub(crate) fn resolve_pinned_program_dirs(policy: &CapabilityPolicy) -> Vec<PathBuf> {
1439 crate::pinned_programs::pinned_trees(&policy.shell_pinned_programs)
1440 .into_iter()
1441 .filter(|tree| tree.is_absolute())
1442 .collect()
1443}
1444
1445pub(crate) fn resolve_pinned_program_landlock_grants(
1448 policy: &CapabilityPolicy,
1449) -> Vec<LandlockGrant> {
1450 resolve_pinned_program_dirs(policy)
1451 .into_iter()
1452 .map(|path| LandlockGrant {
1453 path,
1454 list_dir: true,
1455 executable: true,
1456 })
1457 .collect()
1458}
1459
1460pub(crate) fn host_path_mechanism(tier: EnforcementTier) -> crate::host_paths::HostPathMechanism {
1462 match tier {
1463 EnforcementTier::KernelSealed => crate::host_paths::HostPathMechanism::Bind,
1464 EnforcementTier::KernelFull => crate::host_paths::HostPathMechanism::Landlock,
1465 EnforcementTier::KernelSeccompOnly
1466 | EnforcementTier::EnvironmentOnly
1467 | EnforcementTier::Unconfined => crate::host_paths::HostPathMechanism::None,
1468 }
1469}
1470
1471fn push_sealed_bind_dir(dirs: &mut Vec<PathBuf>, candidate: PathBuf) {
1474 if !candidate.is_absolute() {
1475 return;
1476 }
1477 if crate::sealed::SEALED_RUNTIME_PATHS
1478 .iter()
1479 .any(|root| candidate.starts_with(root))
1480 {
1481 return;
1482 }
1483 if dirs.contains(&candidate) {
1484 return;
1485 }
1486 dirs.push(candidate);
1487}
1488
1489pub(crate) fn resolve_staged_runtime_dirs(policy: &CapabilityPolicy) -> Vec<PathBuf> {
1510 let mut dirs: Vec<PathBuf> = Vec::new();
1511 for grant in &policy.shell_staged_runtime {
1512 let path = PathBuf::from(&grant.source_path);
1513 if !path.is_absolute() || dirs.contains(&path) {
1514 continue;
1515 }
1516 dirs.push(path);
1517 }
1518 dirs
1519}
1520
1521pub(crate) fn resolve_staged_runtime_landlock_grants(
1544 policy: &CapabilityPolicy,
1545) -> Vec<LandlockGrant> {
1546 resolve_staged_runtime_dirs(policy)
1547 .into_iter()
1548 .map(|path| LandlockGrant {
1549 path,
1550 list_dir: true,
1551 executable: true,
1552 })
1553 .collect()
1554}
1555
1556pub(crate) fn resolve_sealed_runtime_landlock_grants(tier: EnforcementTier) -> Vec<LandlockGrant> {
1581 if tier != EnforcementTier::KernelSealed {
1582 return Vec::new();
1583 }
1584 crate::sealed::SEALED_RUNTIME_PATHS
1585 .iter()
1586 .map(|path| LandlockGrant {
1587 path: PathBuf::from(path),
1588 list_dir: true,
1589 executable: false,
1590 })
1591 .collect()
1592}
1593
1594pub(crate) fn resolve_sealed_etc_landlock_grants(tier: EnforcementTier) -> Vec<LandlockGrant> {
1632 if tier != EnforcementTier::KernelSealed {
1633 return Vec::new();
1634 }
1635 crate::sealed::SEALED_ETC_PATHS
1636 .iter()
1637 .map(|entry| LandlockGrant {
1638 path: PathBuf::from(entry.path),
1639 list_dir: entry.directory,
1640 executable: false,
1641 })
1642 .collect()
1643}
1644
1645pub(crate) const SCOPED_NAME_RESOLUTION_PATHS: &[&str] = &["/etc/hosts", "/etc/nsswitch.conf"];
1649
1650pub(crate) fn resolve_scoped_name_resolution_landlock_grants(
1660 tier: EnforcementTier,
1661) -> Vec<LandlockGrant> {
1662 if tier != EnforcementTier::KernelFull {
1663 return Vec::new();
1664 }
1665 SCOPED_NAME_RESOLUTION_PATHS
1666 .iter()
1667 .map(|path| LandlockGrant {
1668 path: PathBuf::from(path),
1669 list_dir: false,
1670 executable: false,
1671 })
1672 .collect()
1673}
1674
1675#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1706#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1707pub(crate) struct CapsuleDeviceGrant {
1708 pub(crate) path: &'static str,
1709 pub(crate) writable: bool,
1710}
1711
1712#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1751pub(crate) const CAPSULE_DEVICE_GRANTS: &[CapsuleDeviceGrant] = &[
1752 CapsuleDeviceGrant {
1753 path: "/dev/null",
1754 writable: true,
1755 },
1756 CapsuleDeviceGrant {
1757 path: "/dev/zero",
1758 writable: false,
1759 },
1760 CapsuleDeviceGrant {
1761 path: "/dev/urandom",
1762 writable: false,
1763 },
1764];
1765
1766#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1770pub(crate) fn network_ip_allowed(ip: IpAddr, network_allow_ips: &[IpAddr]) -> bool {
1771 network_allow_ips.contains(&ip)
1772}
1773
1774const LINUX_AF_UNIX: i32 = 1;
1783const LINUX_AF_INET: i32 = 2;
1784const LINUX_AF_INET6: i32 = 10;
1785const LINUX_AF_NETLINK: i32 = 16;
1786const LINUX_AF_PACKET: i32 = 17;
1787
1788#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1820pub(crate) fn denied_socket_domains(unix_sockets_allowed: bool) -> Vec<i32> {
1821 let mut denied = vec![LINUX_AF_NETLINK, LINUX_AF_PACKET];
1822 if !unix_sockets_allowed {
1823 denied.push(LINUX_AF_UNIX);
1824 }
1825 denied
1826}
1827
1828#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1846pub(crate) fn allowed_socket_domains(unix_sockets_allowed: bool) -> Vec<i32> {
1847 let mut allowed = vec![LINUX_AF_INET_DOMAIN, LINUX_AF_INET6_DOMAIN];
1848 if unix_sockets_allowed {
1849 allowed.push(LINUX_AF_UNIX);
1850 }
1851 allowed
1852}
1853
1854#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1860const LINUX_AF_INET_DOMAIN: i32 = LINUX_AF_INET;
1861#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1862const LINUX_AF_INET6_DOMAIN: i32 = LINUX_AF_INET6;
1863
1864#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1895pub(crate) const SECCOMP_SYSCALL_ALLOWLIST: &[&str] = &[
1896 "execve",
1900 "execveat",
1901 "clone",
1908 "clone3",
1909 "fork",
1910 "vfork",
1911 "exit",
1912 "exit_group",
1913 "wait4",
1914 "waitid",
1915 "kill",
1916 "tkill",
1917 "tgkill",
1918 "rt_sigaction",
1919 "rt_sigprocmask",
1920 "rt_sigreturn",
1921 "rt_sigsuspend",
1922 "rt_sigpending",
1923 "rt_sigtimedwait",
1924 "rt_sigqueueinfo",
1925 "rt_tgsigqueueinfo",
1926 "sigaltstack",
1927 "restart_syscall",
1930 "pause",
1931 "set_tid_address",
1932 "set_robust_list",
1933 "get_robust_list",
1934 "rseq",
1936 "prctl",
1937 "arch_prctl",
1938 "getpid",
1939 "getppid",
1940 "gettid",
1941 "getpgrp",
1942 "getpgid",
1943 "setpgid",
1944 "getsid",
1945 "setsid",
1946 "getpriority",
1947 "setpriority",
1948 "ioprio_get",
1949 "ioprio_set",
1950 "sched_yield",
1951 "sched_getaffinity",
1952 "sched_setaffinity",
1953 "sched_getparam",
1954 "sched_setparam",
1955 "sched_getscheduler",
1956 "sched_setscheduler",
1957 "sched_getattr",
1958 "sched_setattr",
1959 "sched_get_priority_max",
1960 "sched_get_priority_min",
1961 "sched_rr_get_interval",
1962 "getcpu",
1963 "capget",
1964 "capset",
1965 "prlimit64",
1966 "getrlimit",
1967 "setrlimit",
1968 "getrusage",
1969 "getitimer",
1970 "setitimer",
1971 "times",
1972 "uname",
1973 "sysinfo",
1974 "pidfd_open",
1976 "pidfd_send_signal",
1977 "mmap",
1979 "munmap",
1980 "mprotect",
1981 "mremap",
1982 "madvise",
1983 "brk",
1984 "mlock",
1985 "mlock2",
1986 "munlock",
1987 "mlockall",
1988 "munlockall",
1989 "membarrier",
1990 "msync",
1991 "mincore",
1992 "memfd_create",
1993 "pkey_alloc",
1994 "pkey_free",
1995 "pkey_mprotect",
1996 "shmget",
1997 "shmat",
1998 "shmdt",
1999 "shmctl",
2000 "open",
2002 "openat",
2003 "openat2",
2004 "creat",
2005 "close",
2006 "close_range",
2007 "read",
2008 "write",
2009 "pread64",
2010 "pwrite64",
2011 "readv",
2012 "writev",
2013 "preadv",
2014 "pwritev",
2015 "preadv2",
2016 "pwritev2",
2017 "lseek",
2018 "dup",
2019 "dup2",
2020 "dup3",
2021 "fcntl",
2022 "flock",
2023 "fsync",
2024 "fdatasync",
2025 "sync",
2026 "syncfs",
2027 "sync_file_range",
2028 "fadvise64",
2029 "readahead",
2030 "truncate",
2031 "ftruncate",
2032 "fallocate",
2033 "stat",
2034 "fstat",
2035 "lstat",
2036 "newfstatat",
2037 "statx",
2038 "access",
2039 "faccessat",
2040 "faccessat2",
2041 "getdents",
2042 "getdents64",
2043 "getcwd",
2044 "chdir",
2045 "fchdir",
2046 "mkdir",
2047 "mkdirat",
2048 "rmdir",
2049 "unlink",
2050 "unlinkat",
2051 "rename",
2052 "renameat",
2053 "renameat2",
2054 "link",
2055 "linkat",
2056 "symlink",
2057 "symlinkat",
2058 "readlink",
2059 "readlinkat",
2060 "chmod",
2061 "fchmod",
2062 "fchmodat",
2063 "fchmodat2",
2066 "chown",
2067 "fchown",
2068 "fchownat",
2069 "lchown",
2070 "umask",
2071 "utime",
2072 "utimes",
2073 "utimensat",
2074 "futimesat",
2075 "mknod",
2079 "mknodat",
2080 "sendfile",
2081 "copy_file_range",
2082 "splice",
2083 "tee",
2084 "vmsplice",
2085 "ioctl",
2086 "poll",
2087 "ppoll",
2088 "select",
2089 "pselect6",
2090 "epoll_create",
2091 "epoll_create1",
2092 "epoll_ctl",
2093 "epoll_wait",
2094 "epoll_pwait",
2095 "epoll_pwait2",
2096 "eventfd",
2097 "eventfd2",
2098 "pipe",
2099 "pipe2",
2100 "inotify_init",
2101 "inotify_init1",
2102 "inotify_add_watch",
2103 "inotify_rm_watch",
2104 "signalfd",
2105 "signalfd4",
2106 "timerfd_create",
2107 "timerfd_settime",
2108 "timerfd_gettime",
2109 "getxattr",
2110 "lgetxattr",
2111 "fgetxattr",
2112 "setxattr",
2113 "lsetxattr",
2114 "fsetxattr",
2115 "listxattr",
2116 "llistxattr",
2117 "flistxattr",
2118 "removexattr",
2119 "lremovexattr",
2120 "fremovexattr",
2121 "statfs",
2122 "fstatfs",
2123 "name_to_handle_at",
2126 "io_setup",
2130 "io_destroy",
2131 "io_submit",
2132 "io_cancel",
2133 "io_getevents",
2134 "connect",
2141 "sendto",
2142 "socketpair",
2143 "bind",
2144 "listen",
2145 "accept",
2146 "accept4",
2147 "getsockname",
2148 "getpeername",
2149 "getsockopt",
2150 "setsockopt",
2151 "shutdown",
2152 "sendmsg",
2153 "sendmmsg",
2154 "recvfrom",
2155 "recvmsg",
2156 "recvmmsg",
2157 "clock_gettime",
2159 "clock_getres",
2160 "clock_nanosleep",
2161 "nanosleep",
2162 "gettimeofday",
2163 "time",
2164 "timer_create",
2165 "timer_settime",
2166 "timer_gettime",
2167 "timer_getoverrun",
2168 "timer_delete",
2169 "alarm",
2170 "msgget",
2172 "msgsnd",
2173 "msgrcv",
2174 "msgctl",
2175 "semget",
2176 "semop",
2177 "semctl",
2178 "semtimedop",
2179 "mq_open",
2180 "mq_unlink",
2181 "mq_timedsend",
2182 "mq_timedreceive",
2183 "mq_notify",
2184 "mq_getsetattr",
2185 "getrandom",
2187 "futex",
2188 "futex_waitv",
2189 "getuid",
2190 "geteuid",
2191 "getgid",
2192 "getegid",
2193 "setuid",
2194 "setgid",
2195 "setreuid",
2196 "setregid",
2197 "setresuid",
2198 "setresgid",
2199 "getresuid",
2200 "getresgid",
2201 "getgroups",
2202 "setgroups",
2203 "setfsuid",
2204 "setfsgid",
2205];
2206
2207#[allow(dead_code)]
2236pub(crate) const SECCOMP_MUST_STAY_DENIED: &[&str] = &[
2237 "io_uring_setup",
2239 "io_uring_enter",
2240 "io_uring_register",
2241 "bpf",
2242 "userfaultfd",
2243 "perf_event_open",
2244 "process_vm_readv",
2245 "process_vm_writev",
2246 "open_by_handle_at",
2247 "keyctl",
2248 "add_key",
2249 "request_key",
2250 "ptrace",
2251 "mount",
2253 "umount2",
2254 "pivot_root",
2255 "chroot",
2256 "unshare",
2257 "setns",
2258 "init_module",
2259 "finit_module",
2260 "delete_module",
2261 "kexec_load",
2262 "kexec_file_load",
2263 "reboot",
2264 "swapon",
2265 "swapoff",
2266 "acct",
2267 "quotactl",
2268 "nfsservctl",
2269 "iopl",
2270 "ioperm",
2271 "vm86",
2272 "vm86old",
2273 "_sysctl",
2274 "sysfs",
2275 "uselib",
2276 "create_module",
2277 "get_kernel_syms",
2278 "query_module",
2279 "lookup_dcookie",
2280 "clock_adjtime",
2281 "clock_settime",
2282 "settimeofday",
2283 "stime",
2284 "adjtimex",
2285 "set_mempolicy",
2286 "get_mempolicy",
2287 "mbind",
2288 "migrate_pages",
2289 "move_pages",
2290 "fanotify_init",
2291 "fanotify_mark",
2292 "seccomp",
2294 "personality",
2295 "kcmp",
2296];
2297
2298#[allow(dead_code)]
2309pub(crate) const SECCOMP_FILTER_INSTRUCTION_BUDGET: usize = 512;
2310
2311#[allow(dead_code)]
2316pub(crate) const BPF_MAXINSNS_CEILING: usize = 4096;
2317
2318#[allow(dead_code)]
2324pub(crate) const SECCOMP_MAX_INSNS_PER_PATH: usize = 32768;
2325
2326#[derive(Debug, Clone)]
2328pub(crate) struct ShellEnforcement {
2329 pub(crate) tier: EnforcementTier,
2330 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2338 pub(crate) network_allow_ips: Vec<IpAddr>,
2339 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2347 pub(crate) network_allow_rules: Vec<crate::network_policy::NetworkAllowRule>,
2348 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2353 pub(crate) egress_tcp_ports: Vec<u16>,
2354 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2362 pub(crate) bind_observation: Option<crate::held_ports::HeldPorts>,
2363 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2370 pub(crate) unix_sockets_allowed: bool,
2371 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2381 pub(crate) workdir_exec: bool,
2382 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2390 pub(crate) landlock_abi: Option<u8>,
2391 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2427 pub(crate) landlock_grants: Vec<LandlockGrant>,
2428 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2436 pub(crate) sealed_bind_dirs: Vec<PathBuf>,
2437 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2442 pub(crate) sealed_need_mirrors: Vec<PathBuf>,
2443 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2454 pub(crate) staged_runtime_dirs: Vec<PathBuf>,
2455 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2460 pub(crate) pinned_program_dirs: Vec<PathBuf>,
2461 pub(crate) resource_limits: crate::resources::HostResourceLimits,
2467 pub(crate) declared_resources: murmur_artifact::ResourceCapabilities,
2471 pub(crate) cgroup_scope: Option<Arc<crate::cgroup::CgroupScope>>,
2480 pub(crate) workdir_guard: Option<Arc<crate::resources::WorkdirGuard>>,
2484 pub(crate) session_netns: Arc<crate::network_namespace::SessionNetns>,
2491 pub(crate) runtime_dir: Option<crate::runtime_dir::RuntimeDirRoots>,
2497 pub(crate) runtime_dir_owner: Option<crate::runtime_dir::SharedRuntimeDir>,
2500 pub(crate) temp_mapping: crate::temp_dir::TempMapping,
2505 pub(crate) proc_view: crate::proc_view::ProcView,
2509 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2516 pub(crate) host_paths: Vec<crate::host_paths::HostPath>,
2517 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2522 pub(crate) host_path_read_only_binds: Vec<PathBuf>,
2523 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2530 pub(crate) fd_hygiene_fallback: bool,
2531}
2532
2533#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2539#[derive(Debug, Clone)]
2540pub(crate) struct HarnessEnforcementGrants {
2541 pub(crate) granted_paths: Vec<crate::host_paths::HostPath>,
2542 pub(crate) exec_files: Vec<PathBuf>,
2545}
2546
2547impl ShellEnforcement {
2548 pub(crate) fn resolve(
2563 policy: &CapabilityPolicy,
2564 decision: &crate::containment::ContainmentDecision,
2565 probe: HostProbe,
2566 ) -> Result<Self, String> {
2567 let tier = decision.applied_tier();
2568 let network_allow_ips = resolve_network_allowlist_ips(&policy.network_allow)?;
2569 let network_allow_rules =
2570 crate::network_policy::parse_network_allow_rules(&policy.network_allow)
2571 .map_err(|error| error.to_string())?;
2572 let egress_tcp_ports = crate::egress_proxy::egress_listen_ports(&network_allow_rules);
2573 let bind_observation = (observes_binds_on(tier) && bind_notification_supported())
2574 .then(|| {
2575 crate::held_ports::HeldPorts::from_allowlist(
2576 &policy.network_allow,
2577 &egress_tcp_ports,
2578 )
2579 })
2580 .filter(|held| !held.is_empty());
2581 let exec_allow_paths = resolve_policy_exec_allowlist(policy);
2584 let closure = resolve_landlock_grants(&exec_allow_paths);
2590 let host_path_read_only_binds = crate::host_paths::check_execute_roots(
2595 &policy.host_paths,
2596 &execute_roots_from(&closure, policy),
2597 host_path_mechanism(tier),
2598 )
2599 .map_err(|refusal| refusal.to_string())?;
2600 let mut landlock_grants = LandlockGrant::non_listable_files(closure);
2601 landlock_grants.extend(resolve_interpreter_runtime_grants(
2602 &policy.shell_interpreter_runtime,
2603 ));
2604 landlock_grants.extend(resolve_derived_interpreter_grants(
2605 &policy.shell_derived_interpreter_runtime,
2606 ));
2607 landlock_grants.extend(resolve_trust_store_grants(&policy.shell_trust_store));
2611 landlock_grants.extend(resolve_declared_need_grants(&policy.shell_declared_needs));
2614 landlock_grants.extend(resolve_staged_runtime_landlock_grants(policy));
2618 landlock_grants.extend(resolve_pinned_program_landlock_grants(policy));
2622 landlock_grants.extend(resolve_sealed_runtime_landlock_grants(tier));
2628 landlock_grants.extend(resolve_sealed_etc_landlock_grants(tier));
2633 landlock_grants.extend(resolve_scoped_name_resolution_landlock_grants(tier));
2636 let sealed_bind_dirs = resolve_sealed_bind_dirs(&exec_allow_paths, policy);
2637 let sealed_need_mirrors =
2638 resolve_sealed_need_mirrors(&policy.shell_declared_needs, &sealed_bind_dirs);
2639 let staged_runtime_dirs = resolve_staged_runtime_dirs(policy);
2640 let pinned_program_dirs = resolve_pinned_program_dirs(policy);
2641 Ok(Self {
2642 tier,
2643 network_allow_ips,
2644 network_allow_rules,
2645 egress_tcp_ports,
2646 bind_observation,
2647 unix_sockets_allowed: policy.unix_sockets_allowed,
2648 workdir_exec: policy.workdir_exec_allowed,
2649 landlock_abi: ruleset_landlock_abi(probe.landlock_kernel_abi(), tier),
2650 landlock_grants,
2651 sealed_bind_dirs,
2652 sealed_need_mirrors,
2653 staged_runtime_dirs,
2654 pinned_program_dirs,
2655 resource_limits: policy.resources,
2656 declared_resources: policy.declared_resources,
2657 cgroup_scope: None,
2658 workdir_guard: None,
2659 session_netns: Arc::new(crate::network_namespace::SessionNetns::new()),
2660 runtime_dir: None,
2661 runtime_dir_owner: None,
2662 temp_mapping: crate::temp_dir::tier_temp_mapping(tier),
2663 proc_view: crate::proc_view::proc_view(tier, &probe.namespaces),
2664 host_paths: policy.host_paths.clone(),
2665 host_path_read_only_binds,
2666 fd_hygiene_fallback: false,
2667 })
2668 }
2669
2670 #[cfg(test)]
2673 pub(crate) fn resolve_declared(
2674 policy: &CapabilityPolicy,
2675 declared: murmur_artifact::ContainmentClass,
2676 probe: HostProbe,
2677 ) -> Result<Self, String> {
2678 let decision = crate::containment::ContainmentDecision::decide(
2679 probe.tier(),
2680 declared,
2681 policy.workdir_exec_allowed,
2682 false,
2683 );
2684 Self::resolve(policy, &decision, probe)
2685 }
2686
2687 pub(crate) fn with_runtime_dir(
2690 mut self,
2691 roots: crate::runtime_dir::RuntimeDirRoots,
2692 owner: Option<crate::runtime_dir::SharedRuntimeDir>,
2693 ) -> Self {
2694 self.runtime_dir = Some(roots);
2695 self.runtime_dir_owner = owner;
2696 self
2697 }
2698
2699 pub(crate) fn runtime_dir_path(&self) -> Option<&Path> {
2701 self.runtime_dir.as_ref().map(|roots| roots.runtime.path())
2702 }
2703
2704 pub(crate) fn bridge_netns(&self) -> Option<Arc<crate::network_namespace::SessionNetns>> {
2709 matches!(
2710 self.tier,
2711 EnforcementTier::KernelSealed
2712 | EnforcementTier::KernelFull
2713 | EnforcementTier::KernelSeccompOnly
2714 )
2715 .then(|| Arc::clone(&self.session_netns))
2716 }
2717
2718 pub(crate) fn records_root(&self) -> Option<&crate::workdir_fs::WorkdirRoot> {
2720 self.runtime_dir
2721 .as_ref()
2722 .and_then(|roots| roots.records.as_ref())
2723 }
2724
2725 pub(crate) fn records_dir_path(&self) -> Option<&Path> {
2728 self.records_root()
2729 .map(crate::workdir_fs::WorkdirRoot::path)
2730 }
2731
2732 pub(crate) fn temp_dir_path(&self) -> Option<&Path> {
2735 self.runtime_dir
2736 .as_ref()
2737 .and_then(|roots| roots.temp.as_ref())
2738 .map(crate::workdir_fs::WorkdirRoot::path)
2739 }
2740
2741 pub(crate) fn home_root(&self) -> Option<&crate::workdir_fs::WorkdirRoot> {
2744 self.runtime_dir
2745 .as_ref()
2746 .and_then(|roots| roots.home.as_ref())
2747 }
2748
2749 pub(crate) fn home_dir_path(&self) -> Option<&Path> {
2752 self.home_root().map(crate::workdir_fs::WorkdirRoot::path)
2753 }
2754
2755 pub(crate) fn with_temp_mapping(mut self, mapping: crate::temp_dir::TempMapping) -> Self {
2758 self.temp_mapping = mapping;
2759 self
2760 }
2761
2762 pub(crate) fn installed_temp_mapping(&self) -> crate::temp_dir::TempMapping {
2767 use crate::temp_dir::TempMapping;
2768 match (self.tier, self.temp_mapping) {
2769 (
2770 EnforcementTier::KernelFull | EnforcementTier::KernelSeccompOnly,
2771 TempMapping::MountNamespace,
2772 ) => TempMapping::MountNamespace,
2773 (
2774 EnforcementTier::KernelFull | EnforcementTier::KernelSeccompOnly,
2775 TempMapping::NotMapped(reason),
2776 ) => TempMapping::NotMapped(reason),
2777 (tier, _) => crate::temp_dir::tier_temp_mapping(tier),
2778 }
2779 }
2780
2781 #[cfg(all(test, target_os = "linux"))]
2784 pub(crate) fn with_proc_view(mut self, view: crate::proc_view::ProcView) -> Self {
2785 self.proc_view = view;
2786 self
2787 }
2788
2789 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2793 pub(crate) fn installed_proc_view(&self) -> crate::proc_view::ProcView {
2794 match (self.tier, &self.proc_view) {
2795 (
2796 EnforcementTier::KernelFull
2797 | EnforcementTier::KernelSealed
2798 | EnforcementTier::KernelSeccompOnly,
2799 view,
2800 ) => view.clone(),
2801 (tier, _) => crate::proc_view::tier_proc_view(tier),
2802 }
2803 }
2804
2805 pub(crate) fn tmpdir_value(&self) -> Option<String> {
2809 self.temp_dir_path()
2810 .map(|folder| self.installed_temp_mapping().tmpdir_value(folder))
2811 }
2812
2813 pub(crate) fn remove_runtime_dir(&self) {
2816 if let Some(owner) = &self.runtime_dir_owner {
2817 owner.remove();
2818 }
2819 }
2820
2821 pub(crate) fn with_host_bounding(
2830 mut self,
2831 cgroup_scope: Option<Arc<crate::cgroup::CgroupScope>>,
2832 workdir_guard: Option<Arc<crate::resources::WorkdirGuard>>,
2833 ) -> Self {
2834 self.cgroup_scope = cgroup_scope;
2835 self.workdir_guard = workdir_guard;
2836 self
2837 }
2838
2839 pub(crate) fn with_harness_grants(
2849 &self,
2850 grants: &HarnessEnforcementGrants,
2851 ) -> Result<Self, String> {
2852 let mut enforcement = self.clone();
2853 if matches!(
2854 self.tier,
2855 EnforcementTier::Unconfined | EnforcementTier::EnvironmentOnly
2856 ) {
2857 return Ok(enforcement);
2858 }
2859 enforcement
2862 .host_paths
2863 .extend(grants.granted_paths.iter().cloned());
2864 let exec_paths = &grants.exec_files;
2867 let closure = resolve_landlock_grants(exec_paths);
2868 for file in exec_paths {
2871 if let Some(dir) = file.parent() {
2872 push_sealed_bind_dir(&mut enforcement.sealed_bind_dirs, dir.to_path_buf());
2873 }
2874 }
2875 let harness_exec_roots: Vec<crate::host_paths::ExecuteRoot> = closure
2878 .iter()
2879 .filter_map(|file| {
2880 file.parent().map(|dir| crate::host_paths::ExecuteRoot {
2881 dir: dir.to_path_buf(),
2882 because: file.clone(),
2883 })
2884 })
2885 .collect();
2886 let extra_binds = crate::host_paths::check_execute_roots(
2887 &enforcement.host_paths,
2888 &harness_exec_roots,
2889 host_path_mechanism(self.tier),
2890 )
2891 .map_err(|refusal| refusal.to_string())?;
2892 enforcement.host_path_read_only_binds.extend(extra_binds);
2893 enforcement
2894 .landlock_grants
2895 .extend(LandlockGrant::non_listable_files(closure));
2896 Ok(enforcement)
2897 }
2898
2899 pub(crate) fn nproc_bound(&self) -> crate::resources::NprocBound {
2908 if self
2909 .cgroup_scope
2910 .as_ref()
2911 .is_some_and(|scope| scope.places_subprocesses())
2912 {
2913 crate::resources::NprocBound::CgroupPidsMax
2914 } else {
2915 crate::resources::NprocBound::Headroom {
2916 baseline: crate::resources::uid_task_count().unwrap_or(0),
2917 }
2918 }
2919 }
2920
2921 pub(crate) fn workdir_breach(&self) -> Option<crate::resources::WorkdirBreach> {
2923 self.workdir_guard.as_ref().and_then(|guard| guard.breach())
2924 }
2925
2926 pub(crate) fn check_workdir_budget(&self) -> Result<(), String> {
2932 match self.workdir_breach() {
2933 Some(breach) => Err(crate::resources::workdir_breach_message(breach)),
2934 None => Ok(()),
2935 }
2936 }
2937
2938 #[cfg_attr(not(test), allow(dead_code))]
2944 pub(crate) fn environment_only() -> Self {
2945 Self {
2946 tier: EnforcementTier::EnvironmentOnly,
2947 network_allow_ips: Vec::new(),
2948 network_allow_rules: Vec::new(),
2949 egress_tcp_ports: Vec::new(),
2950 bind_observation: None,
2951 unix_sockets_allowed: false,
2954 workdir_exec: false,
2957 landlock_abi: None,
2960 landlock_grants: Vec::new(),
2961 sealed_bind_dirs: Vec::new(),
2962 sealed_need_mirrors: Vec::new(),
2963 staged_runtime_dirs: Vec::new(),
2964 pinned_program_dirs: Vec::new(),
2965 resource_limits: crate::resources::HostResourceLimits::default(),
2968 declared_resources: murmur_artifact::ResourceCapabilities::default(),
2969 cgroup_scope: None,
2970 workdir_guard: None,
2971 session_netns: Arc::new(crate::network_namespace::SessionNetns::new()),
2972 runtime_dir: None,
2973 runtime_dir_owner: None,
2974 temp_mapping: crate::temp_dir::tier_temp_mapping(EnforcementTier::EnvironmentOnly),
2975 proc_view: crate::proc_view::tier_proc_view(EnforcementTier::EnvironmentOnly),
2976 host_paths: Vec::new(),
2977 host_path_read_only_binds: Vec::new(),
2978 fd_hygiene_fallback: false,
2979 }
2980 }
2981
2982 #[cfg(test)]
2986 pub(crate) fn with_test_session_dirs(self) -> Self {
2987 let base = tempfile::tempdir().unwrap().keep();
2988 self.with_runtime_dir(test_runtime_dir(&base), None)
2989 }
2990
2991 pub(crate) fn with_session_netns(
2996 mut self,
2997 session_netns: Arc<crate::network_namespace::SessionNetns>,
2998 ) -> Self {
2999 self.session_netns = session_netns;
3000 self
3001 }
3002
3003 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
3007 pub(crate) fn observed_held_ports(&self) -> Option<&crate::held_ports::HeldPorts> {
3008 self.bind_observation
3009 .as_ref()
3010 .filter(|_| observes_binds_on(self.tier))
3011 }
3012
3013 pub(crate) fn take_held_port_lines(&self) -> Vec<String> {
3017 self.session_netns.take_held_port_lines()
3018 }
3019
3020 pub(crate) fn end_session(&self) {
3028 self.kill_session_processes();
3029 self.session_netns.end();
3030 }
3031
3032 pub(crate) fn kill_session_processes(&self) {
3038 let Some(scope) = self.cgroup_scope.as_ref() else {
3039 return;
3040 };
3041 if let Err(error) = scope.kill_all() {
3042 crate::runtime_err!(
3043 "[capsule-runtime] warning: could not kill the processes in cgroup scope {}: \
3044 {error}",
3045 scope.path().display()
3046 );
3047 }
3048 if !scope.wait_until_empty(SESSION_END_KILL_WAIT) {
3049 crate::runtime_err!(
3050 "[capsule-runtime] warning: cgroup scope {} still held processes {} ms after \
3051 they were killed",
3052 scope.path().display(),
3053 SESSION_END_KILL_WAIT.as_millis()
3054 );
3055 }
3056 }
3057}
3058
3059fn observes_binds_on(tier: EnforcementTier) -> bool {
3065 matches!(
3066 tier,
3067 EnforcementTier::KernelFull | EnforcementTier::KernelSealed
3068 )
3069}
3070
3071#[cfg(target_os = "linux")]
3075fn bind_notification_supported() -> bool {
3076 libseccomp::check_api(6, libseccomp::ScmpVersion::from((2, 5, 0))).unwrap_or(false)
3077}
3078
3079#[cfg(not(target_os = "linux"))]
3080fn bind_notification_supported() -> bool {
3081 false
3082}
3083
3084pub(crate) const SESSION_END_KILL_WAIT: std::time::Duration = std::time::Duration::from_secs(2);
3086
3087pub(crate) struct EndSessionOnDrop(ShellEnforcement);
3093
3094impl EndSessionOnDrop {
3095 pub(crate) fn new(enforcement: ShellEnforcement) -> Self {
3096 Self(enforcement)
3097 }
3098}
3099
3100impl Drop for EndSessionOnDrop {
3101 fn drop(&mut self) {
3102 self.0.end_session();
3103 }
3104}
3105
3106const KERNEL_ENFORCEMENT_NOTICE: &str = "capabilities.shell.allow is non-empty and this host \
3125resolved to a Linux kernel-enforcement tier (Landlock/seccomp). Landlock now grants a narrow, \
3126derived read+execute scope outside the workdir (the allowlisted binaries, their loader, and their \
3127shared libraries — nothing writable), read-only access to the directories an allowlisted \
3128CPython or Perl interpreter reports on its own import path, read-only access to the host's TLS \
3129trust store when capabilities.network.allow is also non-empty, each host_paths entry in \
3130~/.murmur/config.yaml at its own access, never executable, read-only access to /etc/hosts and \
3131/etc/nsswitch.conf so localhost resolves, read and list access to the session's private /proc \
3132(commands run in the session's own PID namespace and its procfs lists only the session's processes \
3133and the kernel's world-readable files — no host process; where this host cannot mount one, /proc \
3134is the host's and every open beneath it is denied), and no other directory granted \
3135wholesale, plus a fixed device set \
3136every capsule gets unconditionally: /dev/null read+write (with the session's temporary and home \
3137folders and any read-write host_paths entry, the only writable paths outside the workdir), \
3138/dev/zero and /dev/urandom read-only, and no other device — /dev/random, /dev/full, /dev/tty and \
3139raw block devices stay denied. The workdir's own grant also \
3140withholds device-node and unix-socket creation; seccomp refuses socket(AF_UNIX) outright unless \
3141capabilities.network.unix_sockets is declared, and always refuses AF_NETLINK/AF_PACKET, so a \
3142capsule cannot reach a host daemon socket such as /var/run/docker.sock; the seccomp filter now \
3143defaults to deny and permits only a fixed syscall allowlist modelled on the OCI/Docker default \
3144profile, so io_uring, bpf, userfaultfd, perf_event_open, ptrace and similar are refused outright; \
3145and the forked shell child \
3146drops every Linux capability and sets no_new_privs before execve. Paths outside the workdir are \
3147denied, not absent: stat, access and readlink on an ungranted path still succeed.";
3148
3149const KERNEL_SEALED_ENFORCEMENT_NOTICE: &str = "capabilities.shell.allow is non-empty and this \
3153host resolved to the sealed kernel-enforcement tier: the subprocess tree runs in a private mount \
3154namespace pivoted onto a composed root (host runtime bind-mounted read-only, the session workdir, \
3155its temporary folder at /tmp, its home folder at $HOME and any read-write host_paths entry the only \
3156writable paths, each host_paths entry in ~/.murmur/config.yaml bound at its own path, a private \
3157/dev tmpfs carrying the OCI default device set), with Landlock \
3158and seccomp still installed inside it as defence in depth. Paths outside that root are absent \
3159rather than denied. Commands run in the session's own PID namespace, and /proc is a bind of the \
3160session's private procfs: it lists only the session's own processes and the kernel's \
3161world-readable files, readable and listable, and no host process appears in it or can be \
3162signalled. Where this host cannot mount a private procfs (mur doctor's private proc line), /proc \
3163is a bind of the host's instead, Landlock refuses every open beneath it, and stat(2) on \
3164/proc/<pid> still shows that a host process exists, its owner and its start time.";
3165
3166const SECCOMP_ONLY_WARNING: &str = "capabilities.shell.allow is non-empty and this Linux kernel \
3167lacks Landlock (kernel <5.13) — filesystem access outside the capsule workdir is not \
3168kernel-enforced at all, and the seccomp exec/network enforcement that would apply has not been \
3169verified on real Linux hardware. The fixed capsule device set the Full tier applies (/dev/null \
3170read+write, /dev/zero and /dev/urandom read-only, every other device denied) is a Landlock \
3171mechanism, so it does not apply here either: on this tier every device under /dev is reachable \
3172exactly as it would be without any sandbox. Seccomp also refuses socket(AF_UNIX) unless \
3173capabilities.network.unix_sockets is declared, and always refuses AF_NETLINK/AF_PACKET — that \
3174rule needs no Landlock and so applies identically on this tier, but it has not been verified \
3175either. The same is true of the default-deny syscall allowlist (modelled on the OCI/Docker default \
3176profile), which refuses io_uring, bpf, userfaultfd, perf_event_open, ptrace and similar on this \
3177tier too. The forked shell child still drops every Linux capability and \
3178sets no_new_privs before execve on this tier, independently of Landlock, but that has not been \
3179verified either. Treat shell subprocess isolation as experimental on this host.";
3180
3181const ENVIRONMENT_ONLY_WARNING: &str = "capabilities.shell.allow is non-empty but this \
3182platform has no kernel-level subprocess sandbox (Landlock/seccomp are Linux-only) — \
3183enforcement is environment-only (synthetic HOME + credential env-stripping). This is \
3184permanent on this platform.";
3185
3186pub(crate) fn tier_warning(
3200 tier: EnforcementTier,
3201 shell_allow_is_empty: bool,
3202) -> Option<(&'static str, &'static str)> {
3203 if shell_allow_is_empty {
3204 return None;
3205 }
3206 match tier {
3207 EnforcementTier::KernelSealed => Some((W_SEC_005, KERNEL_SEALED_ENFORCEMENT_NOTICE)),
3208 EnforcementTier::KernelFull => Some((W_SEC_005, KERNEL_ENFORCEMENT_NOTICE)),
3209 EnforcementTier::KernelSeccompOnly => Some((W_SEC_002, SECCOMP_ONLY_WARNING)),
3210 EnforcementTier::EnvironmentOnly => Some((W_SEC_001, ENVIRONMENT_ONLY_WARNING)),
3211 EnforcementTier::Unconfined => None,
3213 }
3214}
3215
3216const NO_AGGREGATE_BOUNDING_WARNING: &str = "this capsule can spawn subprocesses, but \
3217this platform has no cgroup v2 (Linux-only), so no aggregate bound exists across the subprocess \
3218tree: nothing caps its total memory, task count or CPU. Per-process rlimits from \
3219capabilities.resources still apply (hard, not soft), but RLIMIT_NPROC is a per-uid ceiling and \
3220does NOT stop a fork bomb of distinct, short-lived processes — only a cgroup's pids.max does. \
3221This is permanent on this platform, not a placeholder for a future slice.";
3222
3223pub(crate) fn aggregate_bounding_warning(
3232 is_linux: bool,
3233 requires_bounding: bool,
3234 has_scope: bool,
3235) -> Option<(&'static str, &'static str)> {
3236 if is_linux || !requires_bounding || has_scope {
3237 return None;
3238 }
3239 Some((W_SEC_010, NO_AGGREGATE_BOUNDING_WARNING))
3240}
3241
3242pub(crate) fn warn_for_missing_aggregate_bounding(
3244 workdir: &crate::workdir_fs::WorkdirRoot,
3245 requires_bounding: bool,
3246 has_scope: bool,
3247) {
3248 let is_linux = cfg!(target_os = "linux");
3249 if let Some((code, message)) =
3250 aggregate_bounding_warning(is_linux, requires_bounding, has_scope)
3251 {
3252 let link = security_warning_link(code);
3253 crate::runtime_err!("[capsule-runtime] warning[{code}]: {message} ({link})");
3254 crate::agent::append_bootstrap_log(
3255 workdir,
3256 &format!("[capability-policy] warning[{code}]: {message} ({link})"),
3257 );
3258 }
3259}
3260
3261pub(crate) fn warn_for_enforcement_tier(
3263 tier: EnforcementTier,
3264 workdir: &crate::workdir_fs::WorkdirRoot,
3265 policy: &CapabilityPolicy,
3266) {
3267 if let Some((code, message)) = tier_warning(tier, policy.shell_allow.is_empty()) {
3268 let link = security_warning_link(code);
3269 crate::runtime_err!("[capsule-runtime] warning[{code}]: {message} ({link})");
3270 crate::agent::append_bootstrap_log(
3271 workdir,
3272 &format!("[capability-policy] warning[{code}]: {message} ({link})"),
3273 );
3274 }
3275 if tier == EnforcementTier::EnvironmentOnly {
3276 crate::runtime::warn_if_bash_network_bypass(workdir, policy);
3277 }
3278}
3279
3280#[derive(Debug)]
3287pub(crate) enum SupervisorHandle {
3288 Noop,
3289 #[cfg(target_os = "linux")]
3290 Linux {
3291 diag_read: std::os::fd::OwnedFd,
3298 bind_listener: Option<std::os::fd::OwnedFd>,
3303 },
3304}
3305
3306impl SupervisorHandle {
3307 pub(crate) fn read_diagnostic(&self) -> Option<String> {
3320 match self {
3321 SupervisorHandle::Noop => None,
3322 #[cfg(target_os = "linux")]
3323 SupervisorHandle::Linux { diag_read, .. } => {
3324 use std::os::fd::AsRawFd;
3325 linux_enforce::read_diagnostic_pipe(diag_read.as_raw_fd())
3326 }
3327 }
3328 }
3329
3330 pub(crate) fn hand_off_bind_listener(&self, enforcement: &ShellEnforcement) {
3338 #[cfg(target_os = "linux")]
3339 if let SupervisorHandle::Linux {
3340 bind_listener: Some(socket),
3341 ..
3342 } = self
3343 {
3344 match linux_enforce::receive_bind_listener(socket) {
3345 Ok(listener) => enforcement.session_netns.observe_bind_listener(listener),
3346 Err(error) => crate::runtime_err!(
3347 "[capsule-runtime] warning: could not receive a command's bind listener: \
3348 {error}; a bind in that command fails with ENOSYS"
3349 ),
3350 }
3351 }
3352 #[cfg(not(target_os = "linux"))]
3353 let _ = (self, enforcement);
3354 }
3355}
3356
3357#[cfg(test)]
3361thread_local! {
3362 pub(crate) static FORCE_PREPARE_FAILURE: std::cell::Cell<bool> =
3363 const { std::cell::Cell::new(false) };
3364}
3365
3366#[cfg(test)]
3367fn forced_prepare_failure() -> Result<(), String> {
3368 if FORCE_PREPARE_FAILURE.with(|flag| flag.get()) {
3369 return Err("sandbox: kernel enforcement setup failed (forced by test seam)".to_string());
3370 }
3371 Ok(())
3372}
3373
3374#[cfg(all(test, target_os = "linux"))]
3383thread_local! {
3384 pub(crate) static FORCE_NO_NEW_PRIVS_FAILURE: std::cell::Cell<bool> =
3385 const { std::cell::Cell::new(false) };
3386 pub(crate) static FORCE_LANDLOCK_FAILURE: std::cell::Cell<bool> =
3387 const { std::cell::Cell::new(false) };
3388 pub(crate) static FORCE_SEALED_ROOT_FAILURE: std::cell::Cell<bool> =
3394 const { std::cell::Cell::new(false) };
3395 pub(crate) static FORCE_TMP_MAP_FAILURE: std::cell::Cell<bool> =
3398 const { std::cell::Cell::new(false) };
3399 pub(crate) static FORCE_PRIVATE_PROC_FAILURE: std::cell::Cell<bool> =
3402 const { std::cell::Cell::new(false) };
3403}
3404
3405#[cfg(all(test, target_os = "linux"))]
3407pub(crate) struct ForcePrivateProcFailureGuard;
3408
3409#[cfg(all(test, target_os = "linux"))]
3410impl ForcePrivateProcFailureGuard {
3411 pub(crate) fn new() -> Self {
3412 FORCE_PRIVATE_PROC_FAILURE.with(|flag| flag.set(true));
3413 Self
3414 }
3415}
3416
3417#[cfg(all(test, target_os = "linux"))]
3418impl Drop for ForcePrivateProcFailureGuard {
3419 fn drop(&mut self) {
3420 FORCE_PRIVATE_PROC_FAILURE.with(|flag| flag.set(false));
3421 }
3422}
3423
3424#[cfg(all(test, target_os = "linux"))]
3426pub(crate) struct ForceTmpMapFailureGuard;
3427
3428#[cfg(all(test, target_os = "linux"))]
3429impl ForceTmpMapFailureGuard {
3430 pub(crate) fn new() -> Self {
3431 FORCE_TMP_MAP_FAILURE.with(|flag| flag.set(true));
3432 Self
3433 }
3434}
3435
3436#[cfg(all(test, target_os = "linux"))]
3437impl Drop for ForceTmpMapFailureGuard {
3438 fn drop(&mut self) {
3439 FORCE_TMP_MAP_FAILURE.with(|flag| flag.set(false));
3440 }
3441}
3442
3443#[cfg(all(test, target_os = "linux"))]
3447pub(crate) struct ForceSealedRootFailureGuard;
3448
3449#[cfg(all(test, target_os = "linux"))]
3450impl ForceSealedRootFailureGuard {
3451 pub(crate) fn new() -> Self {
3452 FORCE_SEALED_ROOT_FAILURE.with(|flag| flag.set(true));
3453 Self
3454 }
3455}
3456
3457#[cfg(all(test, target_os = "linux"))]
3458impl Drop for ForceSealedRootFailureGuard {
3459 fn drop(&mut self) {
3460 FORCE_SEALED_ROOT_FAILURE.with(|flag| flag.set(false));
3461 }
3462}
3463
3464#[cfg(all(test, target_os = "linux"))]
3470pub(crate) fn sealed_test_enforcement() -> ShellEnforcement {
3471 let base = tempfile::tempdir().unwrap().keep();
3473 ShellEnforcement {
3474 tier: EnforcementTier::KernelSealed,
3475 ..ShellEnforcement::environment_only().with_test_session_dirs()
3476 }
3477 .with_runtime_dir(test_runtime_dir(&base), None)
3478}
3479
3480#[cfg(test)]
3483pub(crate) fn test_runtime_dir(base: &Path) -> crate::runtime_dir::RuntimeDirRoots {
3484 let open = |sub: &str| {
3485 let path = base.join(sub);
3486 std::fs::create_dir_all(&path).unwrap();
3487 crate::workdir_fs::WorkdirRoot::open(&path).unwrap()
3488 };
3489 crate::runtime_dir::RuntimeDirRoots::new(
3490 open(crate::runtime_dir::RUNTIME_SUBDIR),
3491 open(crate::runtime_dir::SEALED_ETC_SUBDIR),
3492 open(crate::runtime_dir::HARNESS_SUBDIR),
3493 )
3494 .with_records(open(crate::records_dir::RECORDS_DIR_NAME))
3495 .with_temp(open(&format!(
3496 "{}/{}",
3497 crate::records_dir::RECORDS_DIR_NAME,
3498 crate::temp_dir::SESSION_TEMP_DIR_NAME
3499 )))
3500 .with_home(open(&format!(
3501 "{}/{}",
3502 crate::records_dir::RECORDS_DIR_NAME,
3503 crate::records_dir::HOME_SUBDIR
3504 )))
3505}
3506
3507#[cfg(unix)]
3520pub(crate) fn attach_process_limits(
3521 command: &mut std::process::Command,
3522 enforcement: &ShellEnforcement,
3523) {
3524 use std::os::unix::process::CommandExt;
3525
3526 if enforcement.tier == EnforcementTier::Unconfined {
3527 attach_unconfined_limits(command, enforcement);
3528 return;
3529 }
3530
3531 let limits = enforcement.resource_limits;
3532 let nproc = enforcement.nproc_bound();
3533 let cgroup_scope = enforcement.cgroup_scope.clone();
3534
3535 #[allow(unsafe_code)]
3540 unsafe {
3541 command.pre_exec(move || {
3542 crate::resources::apply_hard_rlimits(&limits, nproc)?;
3543 if let Some(scope) = cgroup_scope.as_ref() {
3544 scope.join_current_process()?;
3545 }
3546 Ok(())
3547 });
3548 }
3549}
3550
3551#[cfg(unix)]
3558fn attach_unconfined_limits(command: &mut std::process::Command, enforcement: &ShellEnforcement) {
3559 use std::os::unix::process::CommandExt;
3560
3561 let declared = enforcement.declared_resources;
3562 let nproc = enforcement.nproc_bound();
3563 let cgroup_scope = enforcement.cgroup_scope.clone();
3564
3565 #[allow(unsafe_code)]
3570 unsafe {
3571 command.pre_exec(move || {
3572 crate::resources::apply_declared_rlimits(&declared, nproc)?;
3573 if let Some(scope) = cgroup_scope.as_ref() {
3574 scope.join_current_process()?;
3575 }
3576 #[cfg(target_os = "linux")]
3577 linux_enforce::mark_inherited_fds_cloexec()?;
3578 Ok(())
3579 });
3580 }
3581}
3582
3583#[derive(Debug, Clone, Default, PartialEq, Eq)]
3590pub(crate) struct WorkerGrantClosure {
3591 pub(crate) read_write: Vec<PathBuf>,
3595 pub(crate) runtime_dir: bool,
3597 pub(crate) host_paths: bool,
3600}
3601
3602impl ShellEnforcement {
3603 pub(crate) fn for_guest_worker(
3629 &self,
3630 closure: &WorkerGrantClosure,
3631 worker_image: &Path,
3632 ) -> ShellEnforcement {
3633 let mut worker = self.clone();
3634 worker.landlock_grants = LandlockGrant::non_listable_files(resolve_landlock_grants(&[
3635 worker_image.to_path_buf(),
3636 ]));
3637 worker.sealed_bind_dirs = Vec::new();
3638 worker.sealed_need_mirrors = Vec::new();
3639 worker.staged_runtime_dirs = Vec::new();
3640 worker.pinned_program_dirs = Vec::new();
3641 worker.host_path_read_only_binds = Vec::new();
3642 worker.fd_hygiene_fallback = true;
3643 worker.workdir_exec = false;
3644 worker.bind_observation = None;
3645 worker.cgroup_scope = None;
3646 worker.workdir_guard = None;
3647 worker.runtime_dir_owner = None;
3648 worker.resource_limits.memory_bytes = u64::MAX;
3649 worker.resource_limits.max_processes = u64::MAX;
3650 worker.resource_limits.cpu_seconds = None;
3651 worker.declared_resources.memory_bytes = None;
3652 worker.declared_resources.max_processes = None;
3653 worker.declared_resources.cpu_seconds = None;
3654 worker.runtime_dir = self.runtime_dir.clone().and_then(|mut roots| {
3655 roots.records = None;
3656 if self.tier == EnforcementTier::KernelSealed {
3657 Some(roots)
3658 } else if closure.runtime_dir {
3659 roots.temp = None;
3660 roots.home = None;
3661 Some(roots)
3662 } else {
3663 None
3664 }
3665 });
3666 let mut host_paths = if closure.host_paths {
3667 self.host_paths.clone()
3668 } else {
3669 Vec::new()
3670 };
3671 host_paths.extend(
3672 closure
3673 .read_write
3674 .iter()
3675 .map(|path| crate::host_paths::HostPath {
3676 declared: path.display().to_string(),
3677 path: path.clone(),
3678 access: crate::host_paths::HostPathAccess::ReadWrite,
3679 credential_locations: Vec::new(),
3680 }),
3681 );
3682 worker.host_paths = host_paths;
3683 worker
3684 }
3685}
3686
3687#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
3704pub(crate) const FD_HYGIENE_FIRST_FD: u32 = 3;
3705
3706#[cfg(not(target_os = "linux"))]
3713pub(crate) fn prepare_enforcement(
3714 command: &mut std::process::Command,
3715 enforcement: &ShellEnforcement,
3716 _accessible_workdir: &Path,
3717) -> Result<SupervisorHandle, String> {
3718 #[cfg(test)]
3719 forced_prepare_failure()?;
3720
3721 debug_assert!(
3722 matches!(
3723 enforcement.tier,
3724 EnforcementTier::EnvironmentOnly | EnforcementTier::Unconfined
3725 ),
3726 "non-Linux targets must always resolve to EnforcementTier::EnvironmentOnly or Unconfined"
3727 );
3728 attach_process_limits(command, enforcement);
3729 Ok(SupervisorHandle::Noop)
3730}
3731
3732#[cfg(target_os = "linux")]
3738#[allow(unsafe_code)]
3739pub(crate) fn prepare_enforcement(
3740 command: &mut std::process::Command,
3741 enforcement: &ShellEnforcement,
3742 accessible_workdir: &Path,
3743) -> Result<SupervisorHandle, String> {
3744 use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
3745 use std::os::unix::process::CommandExt;
3746
3747 #[cfg(test)]
3748 forced_prepare_failure()?;
3749
3750 if matches!(
3751 enforcement.tier,
3752 EnforcementTier::EnvironmentOnly | EnforcementTier::Unconfined
3753 ) {
3754 attach_process_limits(command, enforcement);
3758 return Ok(SupervisorHandle::Noop);
3759 }
3760
3761 let temp_dir_fd = enforcement
3765 .runtime_dir
3766 .as_ref()
3767 .and_then(|roots| roots.temp.as_ref())
3768 .map(|temp| temp.path_fd().map(Arc::new))
3769 .transpose()
3770 .map_err(|error| {
3771 format!("sandbox: failed to open the session's temporary folder: {error}")
3772 })?;
3773 let home_dir_fd = enforcement
3776 .home_root()
3777 .map(|home| home.path_fd().map(Arc::new))
3778 .transpose()
3779 .map_err(|error| format!("sandbox: failed to open the session's home folder: {error}"))?;
3780 let host_path_fds: Vec<HostPathFd> = if matches!(
3783 enforcement.tier,
3784 EnforcementTier::KernelFull | EnforcementTier::KernelSealed
3785 ) {
3786 enforcement
3787 .host_paths
3788 .iter()
3789 .map(|host_path| {
3790 linux_enforce::open_host_path(&host_path.path).map(|fd| HostPathFd {
3791 fd: Arc::new(fd),
3792 path: host_path.path.clone(),
3793 read_write: host_path.access == crate::host_paths::HostPathAccess::ReadWrite,
3794 })
3795 })
3796 .collect::<Result<_, _>>()?
3797 } else {
3798 Vec::new()
3799 };
3800
3801 let sealed_sources = if enforcement.tier == EnforcementTier::KernelSealed {
3807 Some(SealedSourceFds::open(
3808 enforcement,
3809 temp_dir_fd.as_ref(),
3810 home_dir_fd.as_ref(),
3811 )?)
3812 } else {
3813 None
3814 };
3815 let runtime_dir_fd = match &sealed_sources {
3817 Some(sources) => Some(Arc::clone(&sources.runtime_dir)),
3818 None if enforcement.tier == EnforcementTier::KernelFull => enforcement
3819 .runtime_dir
3820 .as_ref()
3821 .map(|roots| roots.runtime.path_fd().map(Arc::new))
3822 .transpose()
3823 .map_err(|error| {
3824 format!(
3825 "sandbox: failed to open the runtime directory for Landlock scoping: {error}"
3826 )
3827 })?,
3828 None => None,
3829 };
3830 let records_dir_fd = match &sealed_sources {
3833 Some(sources) => sources.records_dir.as_ref().map(|(fd, _)| Arc::clone(fd)),
3834 None if enforcement.tier == EnforcementTier::KernelFull => enforcement
3835 .runtime_dir
3836 .as_ref()
3837 .and_then(|roots| roots.records.as_ref())
3838 .map(|records| records.path_fd().map(Arc::new))
3839 .transpose()
3840 .map_err(|error| {
3841 format!(
3842 "sandbox: failed to open the records directory for Landlock scoping: {error}"
3843 )
3844 })?,
3845 None => None,
3846 };
3847
3848 let tmp_map_spec = match (&temp_dir_fd, enforcement.installed_temp_mapping()) {
3850 (Some(folder_fd), crate::temp_dir::TempMapping::MountNamespace) => Some(
3851 build_tmp_map_spec(enforcement, folder_fd, accessible_workdir)?,
3852 ),
3853 _ => None,
3854 };
3855
3856 let landlock_fds = if matches!(
3863 enforcement.tier,
3864 EnforcementTier::KernelFull | EnforcementTier::KernelSealed
3865 ) {
3866 Some(linux_enforce::open_landlock_fds(
3867 linux_enforce::LandlockFdRequest {
3868 accessible_workdir,
3869 landlock_grants: &enforcement.landlock_grants,
3870 device_grants: &landlock_device_grants(enforcement.tier),
3871 workdir_exec: enforcement.workdir_exec,
3872 abi: enforcement.landlock_abi,
3873 sealed: sealed_sources.as_ref(),
3874 temp_dir_fd,
3875 home_dir_fd,
3876 runtime_dir_fd,
3877 records_dir_fd,
3878 host_path_fds: host_path_fds
3879 .iter()
3880 .map(|host_path| (Arc::clone(&host_path.fd), host_path.read_write))
3881 .collect(),
3882 },
3883 )?)
3884 } else {
3885 None
3886 };
3887
3888 let sealed_spec = match &sealed_sources {
3894 Some(sources) => Some(build_sealed_root(
3895 accessible_workdir,
3896 sources,
3897 &[
3901 enforcement.pinned_program_dirs.as_slice(),
3902 enforcement.sealed_bind_dirs.as_slice(),
3903 enforcement.sealed_need_mirrors.as_slice(),
3904 ]
3905 .concat(),
3906 &enforcement.staged_runtime_dirs,
3907 &host_path_fds,
3908 &enforcement.host_path_read_only_binds,
3909 )?),
3910 None => None,
3911 };
3912
3913 let mut diag_fds = [0i32; 2];
3919 let rc = unsafe { libc::pipe2(diag_fds.as_mut_ptr(), libc::O_CLOEXEC) };
3922 if rc != 0 {
3923 return Err(format!(
3924 "sandbox: pipe2() for enforcement diagnostics failed: {}",
3925 std::io::Error::last_os_error()
3926 ));
3927 }
3928 let (diag_read, diag_write) = unsafe {
3931 (
3932 OwnedFd::from_raw_fd(diag_fds[0]),
3933 OwnedFd::from_raw_fd(diag_fds[1]),
3934 )
3935 };
3936
3937 let observed_held_ports = enforcement.observed_held_ports();
3944 let netns = enforcement.session_netns.establish(
3945 &enforcement.egress_tcp_ports,
3946 crate::egress_proxy::EgressPolicy::new(
3947 enforcement.network_allow_rules.clone(),
3948 enforcement.network_allow_ips.clone(),
3949 ),
3950 observed_held_ports,
3951 )?;
3952
3953 let (bind_listener_socket, bind_listener_handoff) = match observed_held_ports {
3957 Some(_) => {
3958 let (parent, handoff) = linux_enforce::bind_listener_socketpair()?;
3959 (Some(parent), Some(handoff))
3960 }
3961 None => (None, None),
3962 };
3963
3964 let tier = enforcement.tier;
3965 let private_proc =
3968 enforcement
3969 .installed_proc_view()
3970 .is_private()
3971 .then_some(linux_enforce::PrivateProc {
3972 mount_namespace_entered: tmp_map_spec.is_some(),
3973 });
3974 let unix_sockets_allowed = enforcement.unix_sockets_allowed;
3977 let fd_hygiene_fallback = enforcement.fd_hygiene_fallback;
3978 let resource_limits = enforcement.resource_limits;
3983 let nproc = enforcement.nproc_bound();
3984 let cgroup_scope = enforcement.cgroup_scope.clone();
3985
3986 unsafe {
4013 command.pre_exec(move || {
4014 if let Err(error) = crate::resources::apply_nproc_bound(&resource_limits, nproc) {
4019 linux_enforce::write_diagnostic(diag_write.as_raw_fd(), &error.to_string());
4020 return Err(error);
4021 }
4022 if let Some(scope) = cgroup_scope.as_ref() {
4023 if let Err(error) = scope.join_current_process() {
4024 linux_enforce::write_diagnostic(diag_write.as_raw_fd(), &error.to_string());
4025 return Err(error);
4026 }
4027 }
4028
4029 let (user_ns_fd, net_ns_fd) = netns.raw_fds();
4030 if let Err(error) = linux_enforce::child_install_enforcement(
4031 tier,
4032 linux_enforce::SessionNamespaces {
4033 user_ns_fd,
4034 net_ns_fd,
4035 pid_ns_fd: netns.pid_ns_fd(),
4036 },
4037 private_proc,
4038 tmp_map_spec.as_ref(),
4039 sealed_spec.as_ref(),
4040 landlock_fds.as_ref(),
4041 unix_sockets_allowed,
4042 bind_listener_handoff.as_ref(),
4043 fd_hygiene_fallback,
4044 ) {
4045 linux_enforce::write_diagnostic(diag_write.as_raw_fd(), &error.to_string());
4046 return Err(error);
4047 }
4048
4049 if let Err(error) = crate::resources::apply_per_process_rlimits(&resource_limits) {
4061 linux_enforce::write_diagnostic(diag_write.as_raw_fd(), &error.to_string());
4062 return Err(error);
4063 }
4064 Ok(())
4065 });
4066 }
4067
4068 Ok(SupervisorHandle::Linux {
4069 diag_read,
4070 bind_listener: bind_listener_socket,
4071 })
4072}
4073
4074#[cfg(target_os = "linux")]
4086fn landlock_device_grants(tier: EnforcementTier) -> Vec<CapsuleDeviceGrant> {
4087 match tier {
4088 EnforcementTier::KernelSealed => crate::sealed::SEALED_DEVICE_NODES
4089 .iter()
4090 .map(|device| CapsuleDeviceGrant {
4091 path: device.path,
4092 writable: device.writable,
4093 })
4094 .collect(),
4095 _ => CAPSULE_DEVICE_GRANTS.to_vec(),
4096 }
4097}
4098
4099#[cfg(target_os = "linux")]
4109fn build_tmp_map_spec(
4110 enforcement: &ShellEnforcement,
4111 folder_fd: &std::os::fd::OwnedFd,
4112 accessible_workdir: &Path,
4113) -> Result<crate::temp_dir::TmpMapSpec, String> {
4114 let folder = enforcement.temp_dir_path().ok_or_else(|| {
4115 format!(
4116 "{} this enforcement holds none",
4117 crate::temp_dir::TMP_MAP_FAILURE_PREFIX
4118 )
4119 })?;
4120 let identity = crate::workdir_fs::fd_identity(folder_fd).map_err(|errno| {
4121 format!(
4122 "{} fstat of the temporary folder failed: {errno}",
4123 crate::temp_dir::TMP_MAP_FAILURE_PREFIX
4124 )
4125 })?;
4126 let canonical_tmp = crate::temp_dir::canonical_tmp();
4127 let read_only = [
4128 enforcement.runtime_dir_path(),
4129 enforcement.records_dir_path(),
4130 ]
4131 .into_iter()
4132 .flatten()
4133 .chain(
4134 enforcement
4135 .landlock_grants
4136 .iter()
4137 .map(|grant| grant.path.as_path()),
4138 );
4139 let reexposed = crate::temp_dir::reexposed_paths(
4140 &canonical_tmp,
4141 std::iter::once(accessible_workdir).chain(enforcement.home_dir_path()),
4142 read_only,
4143 );
4144 crate::temp_dir::TmpMapSpec::new(
4145 folder,
4146 identity,
4147 &canonical_tmp,
4148 &reexposed,
4149 accessible_workdir,
4150 )
4151}
4152
4153#[cfg(target_os = "linux")]
4169fn ensure_sealed_identity_files(
4170 sealed_etc_root: &crate::workdir_fs::WorkdirRoot,
4171 home_root: &crate::workdir_fs::WorkdirRoot,
4172) -> Result<Vec<PathBuf>, String> {
4173 let (uid, gid) = home_root.owner().map_err(|error| {
4176 format!(
4177 "sealed: failed to stat the session's home folder for the capsule's uid/gid: {error}"
4178 )
4179 })?;
4180 let home = home_root.path().to_string_lossy().into_owned();
4183 let identity = crate::sealed::SealedAccountIdentity {
4184 uid,
4185 gid,
4186 home: &home,
4187 };
4188
4189 let mut written = Vec::new();
4190 for entry in crate::sealed::SEALED_ETC_PATHS {
4191 let Some(file) = entry.synthetic else {
4192 continue;
4193 };
4194 sealed_etc_root
4195 .overwrite_in_place(
4196 file.file_name(),
4197 file.render(&identity)?.as_bytes(),
4198 crate::workdir_fs::DEFAULT_FILE_MODE,
4199 )
4200 .map_err(|error| {
4201 format!(
4202 "sealed: failed to write the synthetic {}: {error}",
4203 entry.path
4204 )
4205 })?;
4206 written.push(crate::sealed::synthetic_etc_source(
4207 sealed_etc_root.path(),
4208 file,
4209 ));
4210 }
4211 Ok(written)
4212}
4213
4214#[cfg(target_os = "linux")]
4217pub(crate) struct HostPathFd {
4218 fd: Arc<std::os::fd::OwnedFd>,
4219 path: PathBuf,
4220 read_write: bool,
4221}
4222
4223#[cfg(target_os = "linux")]
4226pub(crate) struct SealedSourceFds {
4227 tmp_store: Arc<std::os::fd::OwnedFd>,
4230 tmp_store_path: PathBuf,
4232 home_dir: (Arc<std::os::fd::OwnedFd>, PathBuf),
4235 runtime_dir: Arc<std::os::fd::OwnedFd>,
4237 runtime_dir_path: PathBuf,
4240 records_dir: Option<(Arc<std::os::fd::OwnedFd>, PathBuf)>,
4244 etc_dir: PathBuf,
4246 identity_files: Vec<PathBuf>,
4248}
4249
4250#[cfg(target_os = "linux")]
4251impl SealedSourceFds {
4252 fn open(
4257 enforcement: &ShellEnforcement,
4258 temp_dir_fd: Option<&Arc<std::os::fd::OwnedFd>>,
4259 home_dir_fd: Option<&Arc<std::os::fd::OwnedFd>>,
4260 ) -> Result<Self, String> {
4261 let roots = enforcement.runtime_dir.as_ref().ok_or_else(|| {
4262 "sealed: a composed root needs the session's runtime directory, and this \
4263 enforcement holds none"
4264 .to_string()
4265 })?;
4266 let (Some(temp), Some(tmp_store)) = (roots.temp.as_ref(), temp_dir_fd) else {
4267 return Err(
4268 "sealed: a composed root binds the session's temporary folder at /tmp, \
4269 and this enforcement holds none"
4270 .to_string(),
4271 );
4272 };
4273 let tmp_store = Arc::clone(tmp_store);
4274 let (Some(home), Some(home_fd)) = (roots.home.as_ref(), home_dir_fd) else {
4275 return Err(crate::shell::NO_HOME_FOLDER_REFUSAL.to_string());
4276 };
4277 let home_dir = (Arc::clone(home_fd), home.path().to_path_buf());
4278 let identity_files = ensure_sealed_identity_files(&roots.sealed_etc, home)?;
4279 let runtime_dir = Arc::new(roots.runtime.path_fd().map_err(|error| {
4280 format!("sealed: failed to open the runtime directory for its bind: {error}")
4281 })?);
4282 let records_dir = roots
4283 .records
4284 .as_ref()
4285 .map(|records| {
4286 records
4287 .path_fd()
4288 .map(|fd| (Arc::new(fd), records.path().to_path_buf()))
4289 .map_err(|error| {
4290 format!(
4291 "sealed: failed to open the records directory for its bind: {error}"
4292 )
4293 })
4294 })
4295 .transpose()?;
4296 Ok(Self {
4297 tmp_store,
4298 tmp_store_path: temp.path().to_path_buf(),
4299 home_dir,
4300 runtime_dir,
4301 runtime_dir_path: roots.runtime.path().to_path_buf(),
4302 records_dir,
4303 etc_dir: roots.sealed_etc.path().to_path_buf(),
4304 identity_files,
4305 })
4306 }
4307
4308 fn identity_of(fd: &std::os::fd::OwnedFd) -> Result<(u64, u64), String> {
4310 crate::workdir_fs::fd_identity(fd)
4311 .map_err(|errno| format!("sealed: fstat of a bind source failed: {errno}"))
4312 }
4313}
4314
4315#[cfg(target_os = "linux")]
4331fn build_sealed_root(
4332 accessible_workdir: &Path,
4333 sources: &SealedSourceFds,
4334 extra_read_only: &[PathBuf],
4335 staged_runtime_read_only: &[PathBuf],
4336 host_paths: &[HostPathFd],
4337 host_path_read_only: &[PathBuf],
4338) -> Result<crate::sealed::SealedRootSpec, String> {
4339 use crate::sealed;
4340
4341 if !accessible_workdir.is_absolute() {
4345 return Err(format!(
4346 "sealed: capsule workdir {} is not absolute; a composed root reproduces the workdir at \
4347 its own absolute path",
4348 accessible_workdir.display()
4349 ));
4350 }
4351
4352 let host_path_binds = host_paths
4353 .iter()
4354 .map(|host_path| {
4355 SealedSourceFds::identity_of(&host_path.fd).map(|identity| sealed::HostPathBind {
4356 path: host_path.path.clone(),
4357 identity,
4358 read_only: !host_path.read_write,
4359 })
4360 })
4361 .collect::<Result<Vec<_>, _>>()?;
4362 let host_path_dirs: Vec<PathBuf> = host_paths
4363 .iter()
4364 .map(|host_path| host_path.path.clone())
4365 .collect();
4366 let base = sealed::choose_root_base(
4367 accessible_workdir,
4368 &host_path_dirs,
4369 sealed::SEALED_ROOT_BASE_CANDIDATES,
4370 |path| path.is_dir(),
4371 )
4372 .ok_or_else(|| {
4373 format!(
4374 "sealed: no usable base directory for the composed root; none of {:?} exists outside \
4375 the session workdir's own path and every host path's",
4376 sealed::SEALED_ROOT_BASE_CANDIDATES
4377 )
4378 })?;
4379
4380 let plan = sealed::plan_composed_root(
4381 accessible_workdir,
4382 &sealed::SealedBindSources {
4383 tmp_store: (
4384 &sources.tmp_store_path,
4385 SealedSourceFds::identity_of(&sources.tmp_store)?,
4386 ),
4387 etc_dir: &sources.etc_dir,
4388 runtime_dir: Some((
4389 &sources.runtime_dir_path,
4390 SealedSourceFds::identity_of(&sources.runtime_dir)?,
4391 )),
4392 records_dir: sources
4393 .records_dir
4394 .as_ref()
4395 .map(|(fd, path)| {
4396 SealedSourceFds::identity_of(fd).map(|identity| (path.as_path(), identity))
4397 })
4398 .transpose()?,
4399 home_dir: Some((
4400 &sources.home_dir.1,
4401 SealedSourceFds::identity_of(&sources.home_dir.0)?,
4402 )),
4403 host_paths: &host_path_binds,
4404 host_path_read_only,
4405 },
4406 &base,
4407 extra_read_only,
4408 staged_runtime_read_only,
4409 &sealed::RealHostLayout,
4410 );
4411 sealed::build_sealed_root_spec(&plan)
4412}
4413
4414#[cfg_attr(target_os = "linux", allow(unsafe_code))]
4428#[cfg(target_os = "linux")]
4429mod linux_enforce {
4430 use std::io;
4431 use std::os::fd::{AsFd, OwnedFd, RawFd};
4432 use std::path::Path;
4433
4434 use landlock::{make_bitflags, AccessFs, BitFlags};
4435
4436 use super::{EnforcementTier, LandlockGrant};
4437
4438 const MAX_DIAG_LEN: usize = 1024;
4442
4443 pub(super) struct OpenLandlockGrant {
4448 fd: OwnedFd,
4449 list_dir: bool,
4450 executable: bool,
4451 }
4452
4453 pub(super) struct OpenDeviceGrant {
4460 fd: OwnedFd,
4461 writable: bool,
4462 }
4463
4464 pub(super) struct LandlockChildFds {
4471 workdir_fd: OwnedFd,
4472 workdir_exec: bool,
4476 abi: Option<u8>,
4480 temp_dir_fd: Option<std::sync::Arc<OwnedFd>>,
4491 home_dir_fd: Option<std::sync::Arc<OwnedFd>>,
4496 sealed_identity_fds: Vec<OwnedFd>,
4509 runtime_dir_fd: Option<std::sync::Arc<OwnedFd>>,
4514 records_dir_fd: Option<std::sync::Arc<OwnedFd>>,
4519 host_path_fds: Vec<(std::sync::Arc<OwnedFd>, bool)>,
4522 grants: Vec<OpenLandlockGrant>,
4523 devices: Vec<OpenDeviceGrant>,
4524 }
4525
4526 impl LandlockChildFds {
4527 #[cfg(test)]
4530 pub(super) fn temp_dir_fd(&self) -> Option<&OwnedFd> {
4531 self.temp_dir_fd.as_deref()
4532 }
4533
4534 #[cfg(test)]
4536 pub(super) fn home_dir_fd(&self) -> Option<&OwnedFd> {
4537 self.home_dir_fd.as_deref()
4538 }
4539
4540 #[cfg(test)]
4542 pub(super) fn runtime_dir_fd(&self) -> Option<&OwnedFd> {
4543 self.runtime_dir_fd.as_deref()
4544 }
4545
4546 #[cfg(test)]
4548 pub(super) fn records_dir_fd(&self) -> Option<&OwnedFd> {
4549 self.records_dir_fd.as_deref()
4550 }
4551
4552 #[cfg(test)]
4555 pub(super) fn sealed_identity_fd_count(&self) -> usize {
4556 self.sealed_identity_fds.len()
4557 }
4558 }
4559
4560 pub(super) const WORKDIR_ACCESS_RIGHTS_NO_EXEC: BitFlags<AccessFs> = make_bitflags!(AccessFs::{
4619 WriteFile
4620 | ReadFile
4621 | ReadDir
4622 | RemoveDir
4623 | RemoveFile
4624 | MakeDir
4625 | MakeReg
4626 | MakeFifo
4627 | MakeSym
4628 | Refer
4629 | Truncate
4630 });
4631
4632 pub(super) fn workdir_access_rights(workdir_exec: bool) -> BitFlags<AccessFs> {
4640 if workdir_exec {
4641 WORKDIR_ACCESS_RIGHTS_NO_EXEC | AccessFs::Execute
4642 } else {
4643 WORKDIR_ACCESS_RIGHTS_NO_EXEC
4644 }
4645 }
4646
4647 pub(super) fn host_path_access_rights(read_write: bool) -> BitFlags<AccessFs> {
4652 if read_write {
4653 workdir_access_rights(false)
4654 } else {
4655 AccessFs::ReadFile | AccessFs::ReadDir
4656 }
4657 }
4658
4659 pub(super) fn kernel_landlock_abi() -> Option<u8> {
4666 const LANDLOCK_CREATE_RULESET_VERSION: u32 = 1;
4667 let version = unsafe {
4670 libc::syscall(
4671 libc::SYS_landlock_create_ruleset,
4672 std::ptr::null::<libc::c_void>(),
4673 0usize,
4674 LANDLOCK_CREATE_RULESET_VERSION,
4675 )
4676 };
4677 if version <= 0 {
4678 return None;
4679 }
4680 Some(u8::try_from(version).unwrap_or(u8::MAX))
4681 }
4682
4683 pub(super) fn probe_landlock_full_access() -> Option<bool> {
4700 use landlock::{
4701 Access, AccessFs, CompatLevel, Compatible, PathBeneath, PathFd, Ruleset, RulesetAttr,
4702 RulesetCreatedAttr, RulesetStatus, ABI,
4703 };
4704
4705 const PROBE_FULLY_ENFORCED: i32 = 0;
4708 const PROBE_PARTIAL: i32 = 1;
4709 const PROBE_FAILED: i32 = 2;
4710
4711 let abi = ABI::V1;
4715 let access_all = AccessFs::from_all(abi);
4716
4717 let root_fd = PathFd::new("/").ok()?;
4718
4719 let ruleset = Ruleset::default()
4720 .set_compatibility(CompatLevel::BestEffort)
4721 .handle_access(access_all)
4722 .ok()?
4723 .create()
4724 .ok()?
4725 .add_rule(PathBeneath::new(root_fd, access_all))
4726 .ok()?;
4727
4728 let pid = unsafe { libc::fork() };
4733 if pid < 0 {
4734 return None;
4735 }
4736 if pid == 0 {
4737 let code = match ruleset.restrict_self() {
4738 Ok(status) => {
4739 if matches!(status.ruleset, RulesetStatus::FullyEnforced) {
4740 PROBE_FULLY_ENFORCED
4741 } else {
4742 PROBE_PARTIAL
4743 }
4744 }
4745 Err(_) => PROBE_FAILED,
4746 };
4747 unsafe { libc::_exit(code) }
4750 }
4751
4752 let mut wait_status: libc::c_int = 0;
4753 let waited = unsafe { libc::waitpid(pid, &mut wait_status, 0) };
4755 if waited < 0 || !libc::WIFEXITED(wait_status) {
4756 return None;
4757 }
4758 match libc::WEXITSTATUS(wait_status) {
4759 PROBE_FULLY_ENFORCED => Some(true),
4760 PROBE_PARTIAL => Some(false),
4761 _ => None,
4764 }
4765 }
4766
4767 #[allow(clippy::too_many_arguments)]
4778 pub(super) fn child_install_enforcement(
4779 tier: EnforcementTier,
4780 namespaces: SessionNamespaces,
4781 private_proc: Option<PrivateProc>,
4782 tmp_map_spec: Option<&crate::temp_dir::TmpMapSpec>,
4783 sealed_spec: Option<&crate::sealed::SealedRootSpec>,
4784 landlock_fds: Option<&LandlockChildFds>,
4785 unix_sockets_allowed: bool,
4786 bind_listener: Option<&BindListenerHandoff>,
4787 fd_hygiene_fallback: bool,
4788 ) -> io::Result<()> {
4789 unsafe {
4811 crate::network_namespace::join_session_netns(
4812 namespaces.user_ns_fd,
4813 namespaces.net_ns_fd,
4814 )
4815 }
4816 .map_err(|error| {
4817 io::Error::other(format!(
4818 "sandbox: joining this session's network namespace failed: {error}"
4819 ))
4820 })?;
4821
4822 unsafe { enter_session_pid_namespace(namespaces.pid_ns_fd) }?;
4831
4832 if let Some(spec) = tmp_map_spec {
4838 #[cfg(test)]
4839 if super::FORCE_TMP_MAP_FAILURE.with(|flag| flag.get()) {
4840 return Err(io::Error::other(format!(
4841 "{} (forced by test seam)",
4842 spec.describe(crate::temp_dir::TmpMapFailure::forced(libc::EPERM))
4843 )));
4844 }
4845 unsafe { crate::temp_dir::map_private_tmp(spec) }
4850 .map_err(|failure| io::Error::other(spec.describe(failure)))?;
4851 }
4852
4853 let proc_fd = match private_proc {
4859 None => None,
4860 Some(private_proc) => {
4861 #[cfg(test)]
4862 if super::FORCE_PRIVATE_PROC_FAILURE.with(|flag| flag.get()) {
4863 return Err(io::Error::other(format!(
4864 "{} (forced by test seam)",
4865 crate::proc_view::describe_mount_failure(
4866 crate::proc_view::ProcMountStep::MountProc,
4867 libc::EPERM
4868 )
4869 )));
4870 }
4871 let fd = unsafe {
4874 crate::proc_view::mount_private_proc(!private_proc.mount_namespace_entered)
4875 }
4876 .map_err(|(step, errno)| {
4877 io::Error::other(crate::proc_view::describe_mount_failure(step, errno))
4878 })?;
4879 Some(unsafe { <OwnedFd as std::os::fd::FromRawFd>::from_raw_fd(fd) })
4881 }
4882 };
4883
4884 if tier == EnforcementTier::KernelSealed {
4895 if let Some(spec) = sealed_spec {
4896 #[cfg(test)]
4897 if super::FORCE_SEALED_ROOT_FAILURE.with(|flag| flag.get()) {
4898 return Err(io::Error::other(format!(
4899 "{} unshare(CLONE_NEWUSER|CLONE_NEWNS) failed (forced by test seam)",
4900 crate::sealed::SEALED_ROOT_FAILURE_PREFIX
4901 )));
4902 }
4903 crate::sealed::construct_composed_root(spec)
4904 .map_err(|failure| io::Error::other(spec.describe(failure)))?;
4905 }
4906 }
4907
4908 match mark_inherited_fds_cloexec() {
4923 Err(error)
4924 if fd_hygiene_fallback
4925 && matches!(error.raw_os_error(), Some(libc::ENOSYS | libc::EINVAL)) =>
4926 {
4927 mark_inherited_fds_cloexec_one_by_one()?;
4928 }
4929 result => result?,
4930 }
4931
4932 drop_all_capabilities()?;
4940
4941 install_seccomp_filter(unix_sockets_allowed, bind_listener)?;
4952
4953 if matches!(
4954 tier,
4955 EnforcementTier::KernelFull | EnforcementTier::KernelSealed
4956 ) {
4957 if let Some(fds) = landlock_fds {
4958 apply_landlock_scope(fds, proc_fd.as_ref()).map_err(io::Error::other)?;
4959 }
4960 }
4961
4962 Ok(())
4963 }
4964
4965 #[derive(Debug, Clone, Copy)]
4968 pub(super) struct SessionNamespaces {
4969 pub(super) user_ns_fd: RawFd,
4970 pub(super) net_ns_fd: RawFd,
4971 pub(super) pid_ns_fd: RawFd,
4972 }
4973
4974 #[derive(Debug, Clone, Copy)]
4976 pub(super) struct PrivateProc {
4977 pub(super) mount_namespace_entered: bool,
4979 }
4980
4981 const RELAY_GONE_EXIT: i32 = 125;
4984
4985 unsafe fn enter_session_pid_namespace(pid_ns_fd: RawFd) -> io::Result<()> {
4998 unsafe {
5001 if libc::setns(pid_ns_fd, libc::CLONE_NEWPID) != 0 {
5002 return Err(io::Error::other(format!(
5003 "sandbox: joining this session's PID namespace failed: {}",
5004 io::Error::last_os_error()
5005 )));
5006 }
5007 let mut default: libc::sigaction = std::mem::zeroed();
5010 default.sa_sigaction = libc::SIG_DFL;
5011 libc::sigaction(libc::SIGCHLD, &default, std::ptr::null_mut());
5012 let command = libc::fork();
5013 if command < 0 {
5014 return Err(io::Error::other(format!(
5015 "sandbox: forking the command into this session's PID namespace failed: {}",
5016 io::Error::last_os_error()
5017 )));
5018 }
5019 if command > 0 {
5020 run_relay(command);
5021 }
5022 if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL as libc::c_ulong) != 0 {
5023 return Err(io::Error::other(format!(
5024 "sandbox: prctl(PR_SET_PDEATHSIG) in the command failed: {}",
5025 io::Error::last_os_error()
5026 )));
5027 }
5028 if libc::getppid() != 0 {
5029 libc::_exit(RELAY_GONE_EXIT);
5030 }
5031 }
5032 Ok(())
5033 }
5034
5035 unsafe fn run_relay(command: libc::pid_t) -> ! {
5051 unsafe {
5053 let mut all: libc::sigset_t = std::mem::zeroed();
5054 libc::sigfillset(&mut all);
5055 libc::sigprocmask(libc::SIG_SETMASK, &all, std::ptr::null_mut());
5056 crate::cgroup::close_range(0, libc::c_uint::MAX);
5057 let mut status: libc::c_int = 0;
5058 loop {
5059 let waited = libc::waitpid(command, &mut status, 0);
5060 if waited == command {
5061 break;
5062 }
5063 if waited < 0 && *libc::__errno_location() == libc::EINTR {
5064 continue;
5065 }
5066 libc::_exit(1);
5067 }
5068 if libc::WIFEXITED(status) {
5069 libc::_exit(libc::WEXITSTATUS(status));
5070 }
5071 if libc::WIFSIGNALED(status) {
5072 let signal = libc::WTERMSIG(status);
5073 let mut default: libc::sigaction = std::mem::zeroed();
5074 default.sa_sigaction = libc::SIG_DFL;
5075 libc::sigaction(signal, &default, std::ptr::null_mut());
5076 let mut only: libc::sigset_t = std::mem::zeroed();
5077 libc::sigemptyset(&mut only);
5078 libc::sigaddset(&mut only, signal);
5079 libc::sigprocmask(libc::SIG_UNBLOCK, &only, std::ptr::null_mut());
5080 libc::kill(libc::getpid(), signal);
5081 libc::_exit(128 + signal);
5082 }
5083 libc::_exit(1)
5084 }
5085 }
5086
5087 pub(super) fn mark_inherited_fds_cloexec_one_by_one() -> io::Result<()> {
5092 unsafe {
5095 let mut limit: libc::rlimit = std::mem::zeroed();
5096 if libc::getrlimit(libc::RLIMIT_NOFILE, &mut limit) != 0 {
5097 return Err(io::Error::last_os_error());
5098 }
5099 let end = libc::c_int::try_from(limit.rlim_cur).unwrap_or(libc::c_int::MAX);
5100 let mut fd = super::FD_HYGIENE_FIRST_FD as libc::c_int;
5101 while fd < end {
5102 let flags = libc::fcntl(fd, libc::F_GETFD);
5103 if flags >= 0
5104 && flags & libc::FD_CLOEXEC == 0
5105 && libc::fcntl(fd, libc::F_SETFD, flags | libc::FD_CLOEXEC) != 0
5106 {
5107 return Err(io::Error::last_os_error());
5108 }
5109 fd += 1;
5110 }
5111 }
5112 Ok(())
5113 }
5114
5115 pub(super) fn mark_inherited_fds_cloexec() -> io::Result<()> {
5140 let rc = unsafe {
5146 libc::syscall(
5147 libc::SYS_close_range,
5148 super::FD_HYGIENE_FIRST_FD,
5149 libc::c_uint::MAX,
5150 libc::CLOSE_RANGE_CLOEXEC,
5151 )
5152 };
5153 if rc != 0 {
5154 return Err(io::Error::last_os_error());
5155 }
5156 Ok(())
5157 }
5158
5159 const LINUX_CAPABILITY_VERSION_3: u32 = 0x2008_0522;
5164
5165 const CAP_LAST_CAP_FALLBACK: u32 = 63;
5170
5171 #[repr(C)]
5179 #[allow(dead_code)]
5180 struct CapUserHeader {
5181 version: u32,
5182 pid: libc::c_int,
5183 }
5184
5185 #[repr(C)]
5190 #[derive(Clone, Copy)]
5191 #[allow(dead_code)]
5192 struct CapUserData {
5193 effective: u32,
5194 permitted: u32,
5195 inheritable: u32,
5196 }
5197
5198 fn drop_all_capabilities() -> io::Result<()> {
5234 drop_capability_bounding_set()?;
5235 clear_capability_sets()?;
5236 set_no_new_privs()
5237 }
5238
5239 #[allow(unsafe_code)]
5249 fn drop_capability_bounding_set() -> io::Result<()> {
5250 let zero: libc::c_ulong = 0;
5251
5252 for cap in 0..=kernel_last_cap() {
5253 let cap_arg = libc::c_ulong::from(cap);
5254 let rc = unsafe { libc::prctl(libc::PR_CAPBSET_DROP, cap_arg, zero, zero, zero) };
5257 if rc == 0 {
5258 continue;
5259 }
5260
5261 let error = io::Error::last_os_error();
5262 match error.raw_os_error() {
5263 Some(libc::EINVAL) | Some(libc::EPERM) => continue,
5264 _ => return Err(error),
5265 }
5266 }
5267
5268 Ok(())
5269 }
5270
5271 #[allow(unsafe_code)]
5275 fn clear_capability_sets() -> io::Result<()> {
5276 let header = CapUserHeader {
5277 version: LINUX_CAPABILITY_VERSION_3,
5278 pid: 0,
5281 };
5282 let data = [CapUserData {
5283 effective: 0,
5284 permitted: 0,
5285 inheritable: 0,
5286 }; 2];
5287
5288 let rc = unsafe {
5292 libc::syscall(
5293 libc::SYS_capset,
5294 &header as *const CapUserHeader,
5295 data.as_ptr(),
5296 )
5297 };
5298 if rc != 0 {
5299 return Err(io::Error::last_os_error());
5300 }
5301
5302 Ok(())
5303 }
5304
5305 #[allow(unsafe_code)]
5312 fn set_no_new_privs() -> io::Result<()> {
5313 #[cfg(test)]
5314 if super::FORCE_NO_NEW_PRIVS_FAILURE.with(|flag| flag.get()) {
5315 return Err(io::Error::other(
5316 "sandbox: no_new_privs (prctl PR_SET_NO_NEW_PRIVS, 1) failed (forced by test seam)",
5317 ));
5318 }
5319 let enable: libc::c_ulong = 1;
5323 let zero: libc::c_ulong = 0;
5324
5325 let rc = unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, enable, zero, zero, zero) };
5328 if rc != 0 {
5329 return Err(io::Error::last_os_error());
5330 }
5331
5332 Ok(())
5333 }
5334
5335 #[allow(unsafe_code)]
5341 fn kernel_last_cap() -> u32 {
5342 const PATH: &[u8] = b"/proc/sys/kernel/cap_last_cap\0";
5343
5344 let fd = unsafe {
5347 libc::open(
5348 PATH.as_ptr() as *const libc::c_char,
5349 libc::O_RDONLY | libc::O_CLOEXEC,
5350 )
5351 };
5352 if fd < 0 {
5353 return CAP_LAST_CAP_FALLBACK;
5354 }
5355
5356 let mut buf = [0u8; 16];
5357 let read = unsafe { libc::read(fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) };
5360 unsafe { libc::close(fd) };
5362
5363 if read <= 0 {
5364 return CAP_LAST_CAP_FALLBACK;
5365 }
5366
5367 let parsed = std::str::from_utf8(&buf[..read as usize])
5368 .ok()
5369 .and_then(|text| text.trim().parse::<u32>().ok());
5370
5371 match parsed {
5372 Some(last) if last <= CAP_LAST_CAP_FALLBACK => last,
5373 _ => CAP_LAST_CAP_FALLBACK,
5374 }
5375 }
5376
5377 const LANDLOCK_SYSCALLS: [(&str, i32); 3] = [
5386 ("landlock_create_ruleset", 444),
5387 ("landlock_add_rule", 445),
5388 ("landlock_restrict_self", 446),
5389 ];
5390
5391 fn build_seccomp_filter(
5417 unix_sockets_allowed: bool,
5418 observe_binds: bool,
5419 ) -> io::Result<libseccomp::ScmpFilterContext> {
5420 let mut filter =
5427 libseccomp::ScmpFilterContext::new(libseccomp::ScmpAction::Errno(libc::EPERM))
5428 .map_err(to_io_err)?;
5429
5430 let _ = filter.set_ctl_log(true);
5449
5450 let socket_syscall = libseccomp::ScmpSyscall::from_name("socket").map_err(to_io_err)?;
5470 for domain in super::denied_socket_domains(unix_sockets_allowed) {
5471 filter
5472 .add_rule_conditional(
5473 libseccomp::ScmpAction::Errno(libc::EACCES),
5474 socket_syscall,
5475 &[libseccomp::ScmpArgCompare::new(
5476 0,
5477 libseccomp::ScmpCompareOp::Equal,
5478 domain as u64,
5479 )],
5480 )
5481 .map_err(to_io_err)?;
5482 }
5483
5484 for domain in super::allowed_socket_domains(unix_sockets_allowed) {
5498 filter
5499 .add_rule_conditional(
5500 libseccomp::ScmpAction::Allow,
5501 socket_syscall,
5502 &[libseccomp::ScmpArgCompare::new(
5503 0,
5504 libseccomp::ScmpCompareOp::Equal,
5505 domain as u64,
5506 )],
5507 )
5508 .map_err(to_io_err)?;
5509 }
5510
5511 for name in super::SECCOMP_SYSCALL_ALLOWLIST {
5527 if observe_binds && *name == "bind" {
5528 continue;
5529 }
5530 if let Ok(syscall) = libseccomp::ScmpSyscall::from_name(name) {
5531 let _ = filter.add_rule(libseccomp::ScmpAction::Allow, syscall);
5532 }
5533 }
5534
5535 if observe_binds {
5540 let bind = libseccomp::ScmpSyscall::from_name("bind").map_err(to_io_err)?;
5541 filter
5542 .add_rule(libseccomp::ScmpAction::Notify, bind)
5543 .map_err(to_io_err)?;
5544 }
5545
5546 for (name, fallback_nr) in LANDLOCK_SYSCALLS {
5559 let syscall = libseccomp::ScmpSyscall::from_name(name)
5560 .unwrap_or_else(|_| libseccomp::ScmpSyscall::from(fallback_nr));
5561 filter
5562 .add_rule(libseccomp::ScmpAction::Allow, syscall)
5563 .map_err(to_io_err)?;
5564 }
5565
5566 Ok(filter)
5567 }
5568
5569 fn install_seccomp_filter(
5579 unix_sockets_allowed: bool,
5580 bind_listener: Option<&BindListenerHandoff>,
5581 ) -> io::Result<()> {
5582 let filter = build_seccomp_filter(unix_sockets_allowed, bind_listener.is_some())?;
5586 filter.load().map_err(to_io_err)?;
5587 let Some(handoff) = bind_listener else {
5588 return Ok(());
5589 };
5590 let listener = filter.get_notify_fd().map_err(|error| {
5591 io::Error::other(format!(
5592 "sandbox: the seccomp filter observing bind left no listener: {error}"
5593 ))
5594 })?;
5595 let sent = handoff.send(listener);
5596 unsafe { libc::close(listener) };
5599 sent.map_err(|error| {
5600 io::Error::other(format!(
5601 "sandbox: sending the seccomp listener observing bind failed: {error}"
5602 ))
5603 })
5604 }
5605
5606 pub(super) struct BindListenerHandoff {
5609 socket: OwnedFd,
5610 }
5611
5612 impl BindListenerHandoff {
5613 fn send(&self, listener: RawFd) -> io::Result<()> {
5616 use std::os::fd::AsRawFd;
5617 unsafe { crate::network_namespace::send_fds(self.socket.as_raw_fd(), &[listener]) }
5620 .map_err(|()| io::Error::last_os_error())
5621 }
5622 }
5623
5624 pub(super) fn bind_listener_socketpair() -> Result<(OwnedFd, BindListenerHandoff), String> {
5627 use std::os::fd::FromRawFd;
5628 let mut fds = [0 as RawFd; 2];
5629 let rc = unsafe {
5631 libc::socketpair(
5632 libc::AF_UNIX,
5633 libc::SOCK_SEQPACKET | libc::SOCK_CLOEXEC,
5634 0,
5635 fds.as_mut_ptr(),
5636 )
5637 };
5638 if rc != 0 {
5639 return Err(format!(
5640 "sandbox: socketpair() for the bind listener failed: {}",
5641 io::Error::last_os_error()
5642 ));
5643 }
5644 let (parent, child) =
5646 unsafe { (OwnedFd::from_raw_fd(fds[0]), OwnedFd::from_raw_fd(fds[1])) };
5647 Ok((parent, BindListenerHandoff { socket: child }))
5648 }
5649
5650 pub(super) fn receive_bind_listener(socket: &OwnedFd) -> io::Result<OwnedFd> {
5653 use std::os::fd::{AsRawFd, FromRawFd};
5654 let mut payload = [0u8; 1];
5655 let mut iov = libc::iovec {
5656 iov_base: payload.as_mut_ptr().cast(),
5657 iov_len: payload.len(),
5658 };
5659 #[repr(C, align(8))]
5661 struct Control([u8; 64]);
5662 let mut cmsg = Control([0; 64]);
5663 unsafe {
5667 let mut message: libc::msghdr = std::mem::zeroed();
5668 message.msg_iov = &mut iov;
5669 message.msg_iovlen = 1;
5670 message.msg_control = cmsg.0.as_mut_ptr().cast();
5671 message.msg_controllen = cmsg.0.len() as _;
5672 let received = libc::recvmsg(
5673 socket.as_raw_fd(),
5674 &mut message,
5675 libc::MSG_DONTWAIT | libc::MSG_CMSG_CLOEXEC,
5676 );
5677 if received < 0 {
5678 return Err(io::Error::last_os_error());
5679 }
5680 let header = libc::CMSG_FIRSTHDR(&message);
5681 if header.is_null()
5682 || (*header).cmsg_level != libc::SOL_SOCKET
5683 || (*header).cmsg_type != libc::SCM_RIGHTS
5684 {
5685 return Err(io::Error::other(
5686 "the message carried no SCM_RIGHTS descriptor",
5687 ));
5688 }
5689 let fd = std::ptr::read_unaligned(libc::CMSG_DATA(header).cast::<RawFd>());
5690 Ok(OwnedFd::from_raw_fd(fd))
5691 }
5692 }
5693
5694 fn to_io_err<E: std::fmt::Display>(error: E) -> io::Error {
5695 io::Error::other(error.to_string())
5696 }
5697
5698 #[cfg(test)]
5702 const SOCK_FILTER_BYTES: usize = 8;
5703
5704 #[cfg(test)]
5712 fn export_to_memfd<F>(export: F) -> io::Result<Vec<u8>>
5713 where
5714 F: FnOnce(&OwnedFd) -> Result<(), libseccomp::error::SeccompError>,
5715 {
5716 use std::io::{Read, Seek, SeekFrom};
5717 use std::os::fd::FromRawFd;
5718
5719 let raw = unsafe { libc::memfd_create(c"murmur-seccomp-export".as_ptr(), 0) };
5723 if raw < 0 {
5724 return Err(io::Error::last_os_error());
5725 }
5726 let memfd = unsafe { OwnedFd::from_raw_fd(raw) };
5729
5730 export(&memfd).map_err(to_io_err)?;
5731
5732 let mut file = std::fs::File::from(memfd);
5733 file.seek(SeekFrom::Start(0))?;
5734 let mut exported = Vec::new();
5735 file.read_to_end(&mut exported)?;
5736 Ok(exported)
5737 }
5738
5739 #[cfg(test)]
5751 fn seccomp_filter_instruction_count(unix_sockets_allowed: bool) -> io::Result<usize> {
5752 let filter = build_seccomp_filter(unix_sockets_allowed, false)?;
5753 let exported = export_to_memfd(|fd| filter.export_bpf(fd))?;
5754 Ok(exported.len() / SOCK_FILTER_BYTES)
5755 }
5756
5757 #[cfg(test)]
5764 fn seccomp_filter_pfc(unix_sockets_allowed: bool, observe_binds: bool) -> io::Result<String> {
5765 let filter = build_seccomp_filter(unix_sockets_allowed, observe_binds)?;
5766 let exported = export_to_memfd(|fd| filter.export_pfc(fd))?;
5767 String::from_utf8(exported).map_err(to_io_err)
5768 }
5769
5770 fn apply_landlock_scope(
5838 fds: &LandlockChildFds,
5839 proc_fd: Option<&OwnedFd>,
5840 ) -> Result<(), String> {
5841 #[cfg(test)]
5842 if super::FORCE_LANDLOCK_FAILURE.with(|flag| flag.get()) {
5843 return Err("landlock: ruleset construction failed (forced by test seam)".to_string());
5844 }
5845
5846 use landlock::{
5847 Access, AccessFs, CompatLevel, Compatible, PathBeneath, Ruleset, RulesetAttr,
5848 RulesetCreatedAttr, ABI,
5849 };
5850
5851 let abi = fds.abi.map_or(ABI::V1, |abi| ABI::from(i32::from(abi)));
5852 let handled = AccessFs::from_all(abi);
5853 let read_execute = AccessFs::Execute | AccessFs::ReadFile;
5857 let read_execute_list = read_execute | AccessFs::ReadDir;
5859 let read_list = AccessFs::ReadFile | AccessFs::ReadDir;
5863 let read_only: BitFlags<AccessFs> = AccessFs::ReadFile.into();
5867 let device_read: BitFlags<AccessFs> = AccessFs::ReadFile.into();
5872 let device_read_write = device_read | AccessFs::WriteFile | AccessFs::Truncate;
5873
5874 let writable_rights = workdir_access_rights(fds.workdir_exec) & handled;
5881
5882 let mut ruleset = Ruleset::default()
5886 .set_compatibility(CompatLevel::HardRequirement)
5887 .handle_access(handled)
5888 .map_err(|error| format!("landlock: handle_access failed: {error}"))?
5889 .create()
5890 .map_err(|error| format!("landlock: ruleset create failed: {error}"))?
5891 .add_rule(PathBeneath::new(fds.workdir_fd.as_fd(), writable_rights))
5892 .map_err(|error| format!("landlock: add_rule failed: {error}"))?;
5893
5894 if let Some(proc_fd) = proc_fd {
5897 ruleset = ruleset
5898 .add_rule(PathBeneath::new(proc_fd.as_fd(), read_list & handled))
5899 .map_err(|error| {
5900 format!("landlock: add_rule for the session's private /proc failed: {error}")
5901 })?;
5902 }
5903
5904 if let Some(temp_dir_fd) = &fds.temp_dir_fd {
5907 ruleset = ruleset
5908 .add_rule(PathBeneath::new(temp_dir_fd.as_fd(), writable_rights))
5909 .map_err(|error| {
5910 format!("landlock: add_rule for the session's temporary folder failed: {error}")
5911 })?;
5912 }
5913
5914 if let Some(home_dir_fd) = &fds.home_dir_fd {
5917 ruleset = ruleset
5918 .add_rule(PathBeneath::new(home_dir_fd.as_fd(), writable_rights))
5919 .map_err(|error| {
5920 format!("landlock: add_rule for the session's home folder failed: {error}")
5921 })?;
5922 }
5923
5924 for (host_path_fd, read_write) in &fds.host_path_fds {
5928 ruleset = ruleset
5929 .add_rule(PathBeneath::new(
5930 host_path_fd.as_fd(),
5931 host_path_access_rights(*read_write) & handled,
5932 ))
5933 .map_err(|error| format!("landlock: add_rule for a host path failed: {error}"))?;
5934 }
5935
5936 if let Some(runtime_dir_fd) = &fds.runtime_dir_fd {
5938 ruleset = ruleset
5939 .add_rule(PathBeneath::new(
5940 runtime_dir_fd.as_fd(),
5941 read_list & handled,
5942 ))
5943 .map_err(|error| {
5944 format!("landlock: add_rule for the runtime directory failed: {error}")
5945 })?;
5946 }
5947
5948 if let Some(records_dir_fd) = &fds.records_dir_fd {
5951 ruleset = ruleset
5952 .add_rule(PathBeneath::new(
5953 records_dir_fd.as_fd(),
5954 read_list & handled,
5955 ))
5956 .map_err(|error| {
5957 format!("landlock: add_rule for the records directory failed: {error}")
5958 })?;
5959 }
5960
5961 for identity_fd in &fds.sealed_identity_fds {
5965 ruleset = ruleset
5966 .add_rule(PathBeneath::new(identity_fd.as_fd(), read_only & handled))
5967 .map_err(|error| {
5968 format!("landlock: add_rule for a synthetic /etc file failed: {error}")
5969 })?;
5970 }
5971
5972 for grant in &fds.grants {
5973 let access = match (grant.executable, grant.list_dir) {
5977 (true, true) => read_execute_list,
5978 (true, false) => read_execute,
5979 (false, true) => read_list,
5980 (false, false) => read_only,
5981 };
5982 ruleset = ruleset
5983 .add_rule(PathBeneath::new(grant.fd.as_fd(), access & handled))
5984 .map_err(|error| format!("landlock: add_rule for grant failed: {error}"))?;
5985 }
5986
5987 for device in &fds.devices {
5988 let access = if device.writable {
5992 device_read_write
5993 } else {
5994 device_read
5995 };
5996 ruleset = ruleset
5997 .add_rule(PathBeneath::new(device.fd.as_fd(), access & handled))
5998 .map_err(|error| format!("landlock: add_rule for device failed: {error}"))?;
5999 }
6000
6001 ruleset
6002 .restrict_self()
6003 .map_err(|error| format!("landlock: restrict_self failed: {error}"))?;
6004
6005 Ok(())
6006 }
6007
6008 pub(super) struct LandlockFdRequest<'a> {
6010 pub(super) accessible_workdir: &'a Path,
6013 pub(super) landlock_grants: &'a [LandlockGrant],
6016 pub(super) device_grants: &'a [super::CapsuleDeviceGrant],
6024 pub(super) workdir_exec: bool,
6026 pub(super) abi: Option<u8>,
6028 pub(super) sealed: Option<&'a super::SealedSourceFds>,
6030 pub(super) temp_dir_fd: Option<std::sync::Arc<OwnedFd>>,
6035 pub(super) home_dir_fd: Option<std::sync::Arc<OwnedFd>>,
6037 pub(super) runtime_dir_fd: Option<std::sync::Arc<OwnedFd>>,
6039 pub(super) records_dir_fd: Option<std::sync::Arc<OwnedFd>>,
6041 pub(super) host_path_fds: Vec<(std::sync::Arc<OwnedFd>, bool)>,
6044 }
6045
6046 impl<'a> LandlockFdRequest<'a> {
6047 #[cfg(test)]
6050 pub(super) fn workdir_only(accessible_workdir: &'a Path) -> Self {
6051 Self {
6052 accessible_workdir,
6053 landlock_grants: &[],
6054 device_grants: &[],
6055 workdir_exec: false,
6056 abi: None,
6057 sealed: None,
6058 temp_dir_fd: None,
6059 home_dir_fd: None,
6060 runtime_dir_fd: None,
6061 records_dir_fd: None,
6062 host_path_fds: Vec::new(),
6063 }
6064 }
6065 }
6066
6067 pub(super) fn open_landlock_fds(
6081 request: LandlockFdRequest<'_>,
6082 ) -> Result<LandlockChildFds, String> {
6083 let LandlockFdRequest {
6084 accessible_workdir,
6085 landlock_grants,
6086 device_grants,
6087 workdir_exec,
6088 abi,
6089 sealed,
6090 temp_dir_fd,
6091 home_dir_fd,
6092 runtime_dir_fd,
6093 records_dir_fd,
6094 host_path_fds,
6095 } = request;
6096 let workdir_fd = open_o_path(accessible_workdir)
6097 .map(OwnedFd::from)
6098 .map_err(|error| {
6099 format!(
6100 "sandbox: failed to open workdir {} for Landlock scoping: {error}",
6101 accessible_workdir.display()
6102 )
6103 })?;
6104
6105 let mut sealed_identity_fds = Vec::new();
6110 for path in sealed
6111 .map(|sources| sources.identity_files.as_slice())
6112 .unwrap_or_default()
6113 {
6114 let file = open_o_path(path).map_err(|error| {
6115 format!(
6116 "sandbox: failed to open the synthetic /etc file {} for Landlock scoping: \
6117 {error}",
6118 path.display()
6119 )
6120 })?;
6121 sealed_identity_fds.push(OwnedFd::from(file));
6122 }
6123
6124 let mut grants = Vec::with_capacity(landlock_grants.len());
6125 for grant in landlock_grants {
6126 match open_o_path(&grant.path) {
6127 Ok(file) => grants.push(OpenLandlockGrant {
6128 list_dir: grant.list_dir
6138 && file.metadata().map(|meta| meta.is_dir()).unwrap_or(false),
6139 fd: OwnedFd::from(file),
6140 executable: grant.executable,
6141 }),
6142 Err(_) => continue,
6143 }
6144 }
6145
6146 let mut devices = Vec::with_capacity(device_grants.len());
6147 for device in device_grants {
6148 match open_o_path(Path::new(device.path)) {
6149 Ok(file) => devices.push(OpenDeviceGrant {
6150 fd: OwnedFd::from(file),
6151 writable: device.writable,
6152 }),
6153 Err(_) => continue,
6154 }
6155 }
6156
6157 Ok(LandlockChildFds {
6158 workdir_fd,
6159 workdir_exec,
6160 abi,
6161 temp_dir_fd,
6162 home_dir_fd,
6163 sealed_identity_fds,
6164 runtime_dir_fd,
6165 records_dir_fd,
6166 host_path_fds,
6167 grants,
6168 devices,
6169 })
6170 }
6171
6172 fn open_o_path(path: &Path) -> io::Result<std::fs::File> {
6181 use std::os::unix::fs::OpenOptionsExt;
6182 std::fs::OpenOptions::new()
6183 .read(true)
6184 .custom_flags(libc::O_PATH | libc::O_CLOEXEC)
6185 .open(path)
6186 }
6187
6188 pub(super) fn open_host_path(path: &Path) -> Result<OwnedFd, String> {
6192 use std::os::unix::fs::OpenOptionsExt;
6193 std::fs::OpenOptions::new()
6194 .read(true)
6195 .custom_flags(libc::O_PATH | libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_DIRECTORY)
6196 .open(path)
6197 .map(OwnedFd::from)
6198 .map_err(|error| {
6199 format!(
6200 "sandbox: failed to open host path {} (host_paths in ~/.murmur/config.yaml): \
6201 {error}",
6202 path.display()
6203 )
6204 })
6205 }
6206
6207 #[allow(unsafe_code)]
6213 pub(super) fn write_diagnostic(fd: RawFd, message: &str) {
6214 let bytes = message.as_bytes();
6215 let len = bytes.len().min(MAX_DIAG_LEN);
6216 let mut written = 0;
6217 while written < len {
6218 let n = unsafe {
6221 libc::write(
6222 fd,
6223 bytes[written..len].as_ptr() as *const libc::c_void,
6224 len - written,
6225 )
6226 };
6227 if n < 0 {
6228 if io::Error::last_os_error().raw_os_error() == Some(libc::EINTR) {
6229 continue;
6230 }
6231 break;
6232 }
6233 if n == 0 {
6234 break;
6235 }
6236 written += n as usize;
6237 }
6238 }
6239
6240 #[allow(unsafe_code)]
6246 pub(super) fn read_diagnostic_pipe(fd: RawFd) -> Option<String> {
6247 let mut buf = Vec::new();
6248 let mut chunk = [0u8; 256];
6249 loop {
6250 let n = unsafe { libc::read(fd, chunk.as_mut_ptr() as *mut libc::c_void, chunk.len()) };
6253 if n < 0 {
6254 if io::Error::last_os_error().raw_os_error() == Some(libc::EINTR) {
6255 continue;
6256 }
6257 break;
6258 }
6259 if n == 0 {
6260 break;
6261 }
6262 buf.extend_from_slice(&chunk[..n as usize]);
6263 if buf.len() >= MAX_DIAG_LEN {
6264 break;
6265 }
6266 }
6267 if buf.is_empty() {
6268 None
6269 } else {
6270 Some(String::from_utf8_lossy(&buf).into_owned())
6271 }
6272 }
6273
6274 #[cfg(test)]
6285 #[allow(clippy::print_stdout, clippy::print_stderr)]
6286 mod seccomp_budget {
6287 use std::io;
6288 use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
6289
6290 use super::{
6293 build_seccomp_filter, export_to_memfd, seccomp_filter_pfc, to_io_err,
6294 LANDLOCK_SYSCALLS, SOCK_FILTER_BYTES,
6295 };
6296 use crate::sandbox::{
6297 allowed_socket_domains, denied_socket_domains, detect_enforcement_tier,
6298 EnforcementTier, BPF_MAXINSNS_CEILING, SECCOMP_FILTER_INSTRUCTION_BUDGET,
6299 SECCOMP_MAX_INSNS_PER_PATH, SECCOMP_SYSCALL_ALLOWLIST,
6300 };
6301
6302 const KERNEL_PER_FILTER_OVERHEAD_INSNS: usize = 4;
6313
6314 const PROBE_ERRNO_UNKNOWN: i32 = 254;
6318
6319 fn probe_filter(rules: usize) -> io::Result<(libseccomp::ScmpFilterContext, usize)> {
6334 let mut filter = libseccomp::ScmpFilterContext::new(libseccomp::ScmpAction::Allow)
6335 .map_err(to_io_err)?;
6336 filter.set_api_sysrawrc(true).map_err(to_io_err)?;
6337
6338 let syscall = libseccomp::ScmpSyscall::from_name("ptrace").map_err(to_io_err)?;
6339 for value in 0..rules {
6340 filter
6341 .add_rule_conditional(
6342 libseccomp::ScmpAction::Errno(libc::EPERM),
6343 syscall,
6344 &[libseccomp::ScmpArgCompare::new(
6345 0,
6346 libseccomp::ScmpCompareOp::Equal,
6347 value as u64,
6348 )],
6349 )
6350 .map_err(to_io_err)?;
6351 }
6352
6353 let exported = export_to_memfd(|fd| filter.export_bpf(fd))?;
6354 Ok((filter, exported.len() / SOCK_FILTER_BYTES))
6355 }
6356
6357 fn first_rule_count_above(instructions: usize) -> io::Result<usize> {
6362 let mut under = 1;
6363 let mut over = 8;
6364 while probe_filter(over)?.1 <= instructions {
6365 under = over;
6366 over *= 2;
6367 assert!(
6368 over <= 1 << 20,
6369 "no probe filter of up to {over} rules exceeded {instructions} instructions — \
6370 the probe is not growing the program the way this search assumes"
6371 );
6372 }
6373 while over - under > 1 {
6374 let mid = under + (over - under) / 2;
6375 if probe_filter(mid)?.1 <= instructions {
6376 under = mid;
6377 } else {
6378 over = mid;
6379 }
6380 }
6381 Ok(over)
6382 }
6383
6384 fn refusal_errno(error: &libseccomp::error::SeccompError) -> i32 {
6389 use libseccomp::error::SeccompErrno;
6390
6391 if let Some(raw) = error.sysrawrc() {
6392 return -raw;
6393 }
6394 match error.errno() {
6395 Some(SeccompErrno::EACCES) => libc::EACCES,
6396 Some(SeccompErrno::ECANCELED) => libc::ECANCELED,
6397 Some(SeccompErrno::EINVAL) => libc::EINVAL,
6398 Some(SeccompErrno::ENOMEM) => libc::ENOMEM,
6399 _ => PROBE_ERRNO_UNKNOWN,
6400 }
6401 }
6402
6403 fn load_in_child(filter: &libseccomp::ScmpFilterContext) -> Result<(), i32> {
6409 let pid = unsafe { libc::fork() };
6416 assert!(pid >= 0, "fork failed: {}", io::Error::last_os_error());
6417
6418 if pid == 0 {
6419 let code = match filter.load() {
6420 Ok(()) => 0,
6421 Err(error) => refusal_errno(&error),
6422 };
6423 unsafe { libc::_exit(code) }
6426 }
6427
6428 let mut status: libc::c_int = 0;
6429 let waited = unsafe { libc::waitpid(pid, &mut status, 0) };
6431 assert!(waited == pid, "waitpid: {}", io::Error::last_os_error());
6432 assert!(
6433 libc::WIFEXITED(status),
6434 "the load probe child did not exit normally (status {status})"
6435 );
6436 match libc::WEXITSTATUS(status) {
6437 0 => Ok(()),
6438 errno => Err(errno),
6439 }
6440 }
6441
6442 fn resolvable_allowlist_syscalls() -> io::Result<usize> {
6447 use std::collections::HashSet;
6448
6449 let mut scratch =
6450 libseccomp::ScmpFilterContext::new(libseccomp::ScmpAction::Errno(libc::EPERM))
6451 .map_err(to_io_err)?;
6452 let mut accepted = HashSet::new();
6453 for name in SECCOMP_SYSCALL_ALLOWLIST {
6454 if let Ok(syscall) = libseccomp::ScmpSyscall::from_name(name) {
6455 if scratch
6456 .add_rule(libseccomp::ScmpAction::Allow, syscall)
6457 .is_ok()
6458 {
6459 accepted.insert(i32::from(syscall));
6460 }
6461 }
6462 }
6463 Ok(accepted.len())
6464 }
6465
6466 fn skip_without_seccomp(what: &str) -> bool {
6469 let tier = detect_enforcement_tier();
6470 if tier == EnforcementTier::EnvironmentOnly {
6471 eprintln!(
6472 "SKIP — PROVES NOTHING ON THIS HOST: measuring {what} requires a host that \
6473 can load a seccomp filter at all, which is every tier above \
6474 EnforcementTier::EnvironmentOnly; detected {tier:?}"
6475 );
6476 return true;
6477 }
6478 false
6479 }
6480
6481 #[test]
6488 fn seccomp_filter_instruction_count() {
6489 for unix_sockets_allowed in [false, true] {
6490 let count = super::seccomp_filter_instruction_count(unix_sockets_allowed)
6491 .expect("export the built seccomp filter");
6492
6493 eprintln!(
6494 "seccomp filter instruction count: unix_sockets_allowed={unix_sockets_allowed} \
6495 -> {count} instructions; budget {SECCOMP_FILTER_INSTRUCTION_BUDGET}; kernel \
6496 per-filter ceiling {BPF_MAXINSNS_CEILING} ({:.1}x headroom); kernel \
6497 cumulative ceiling {SECCOMP_MAX_INSNS_PER_PATH} ({:.1}x headroom)",
6498 BPF_MAXINSNS_CEILING as f64 / count as f64,
6499 SECCOMP_MAX_INSNS_PER_PATH as f64 / count as f64,
6500 );
6501
6502 assert!(
6503 count > 0,
6504 "the exported filter is empty, so nothing was measured — \
6505 `export_bpf` wrote no `struct sock_filter` records at all"
6506 );
6507 assert!(
6508 count <= SECCOMP_FILTER_INSTRUCTION_BUDGET,
6509 "the child's seccomp filter compiles to {count} instructions with \
6510 unix_sockets_allowed={unix_sockets_allowed}, over this project's budget of \
6511 {SECCOMP_FILTER_INSTRUCTION_BUDGET} (the kernel refuses a single filter over \
6512 {BPF_MAXINSNS_CEILING} with EINVAL). Re-measure, decide whether the rule set \
6513 still earns its size, and raise SECCOMP_FILTER_INSTRUCTION_BUDGET \
6514 deliberately — updating \
6515 docs/content/reference/seccomp-filter-instruction-budget.md with the new \
6516 numbers — rather than letting the filter grow into a spawn failure"
6517 );
6518 }
6519 }
6520
6521 #[test]
6530 fn seccomp_filter_rule_shape() {
6531 let allowlist_syscalls =
6532 resolvable_allowlist_syscalls().expect("count the resolvable allowlist syscalls");
6533 assert!(
6534 allowlist_syscalls > 0,
6535 "no SECCOMP_SYSCALL_ALLOWLIST name resolved on this host, so this test would pass \
6536 for the wrong reason"
6537 );
6538
6539 for unix_sockets_allowed in [false, true] {
6540 let filter = build_seccomp_filter(unix_sockets_allowed, false)
6541 .expect("build the seccomp filter");
6542 assert_eq!(
6543 filter.get_act_default().expect("read the default action"),
6544 libseccomp::ScmpAction::Errno(libc::EPERM),
6545 "the filter's default action is no longer a deny with EPERM, which is what \
6546 makes every syscall outside the allowlist unreachable"
6547 );
6548
6549 let pfc = seccomp_filter_pfc(unix_sockets_allowed, false)
6550 .expect("export the filter's pfc");
6551 let denied = denied_socket_domains(unix_sockets_allowed);
6552 let allowed = allowed_socket_domains(unix_sockets_allowed);
6553
6554 let syscall_blocks = pfc.matches("# filter for syscall ").count();
6557 assert_eq!(
6558 syscall_blocks,
6559 1 + allowlist_syscalls + LANDLOCK_SYSCALLS.len(),
6560 "the built filter carries rules for {syscall_blocks} syscalls, not the \
6561 socket rule plus {allowlist_syscalls} allowlist syscalls plus {} Landlock \
6562 syscalls this code adds",
6563 LANDLOCK_SYSCALLS.len()
6564 );
6565
6566 let deny_actions = pfc
6567 .matches(&format!("action ERRNO({});", libc::EACCES))
6568 .count();
6569 assert_eq!(
6570 deny_actions,
6571 denied.len(),
6572 "the built filter carries {deny_actions} EACCES rules, not one per denied \
6573 socket domain ({denied:?}) — an unconditional Allow on `socket` would look \
6574 exactly like this, and would have discarded every domain denial with it"
6575 );
6576
6577 let allow_actions = pfc.matches("action ALLOW;").count();
6580 assert_eq!(
6581 allow_actions,
6582 allowed.len() + allowlist_syscalls + LANDLOCK_SYSCALLS.len(),
6583 "the built filter carries {allow_actions} Allow rules, not one per allowed \
6584 socket domain ({allowed:?}) plus one per allowlist syscall plus one per \
6585 Landlock syscall"
6586 );
6587
6588 for (name, _) in LANDLOCK_SYSCALLS {
6589 assert!(
6590 pfc.contains(&format!("\"{name}\"")),
6591 "{name} has no rule in the built filter, so `apply_landlock_scope` would \
6592 be refused by the filter installed just before it"
6593 );
6594 }
6595
6596 eprintln!(
6597 "seccomp filter rule shape: unix_sockets_allowed={unix_sockets_allowed} -> \
6598 default ERRNO(EPERM); {} socket EACCES rules; {} socket ALLOW rules; \
6599 {allowlist_syscalls} allowlist syscalls; {} Landlock syscalls",
6600 denied.len(),
6601 allowed.len(),
6602 LANDLOCK_SYSCALLS.len(),
6603 );
6604 }
6605 }
6606
6607 fn bind_block(pfc: &str) -> &str {
6610 let start = pfc
6611 .find("# filter for syscall \"bind\"")
6612 .unwrap_or_else(|| panic!("no bind block in:\n{pfc}"));
6613 let rest = &pfc[start + 1..];
6614 let end = rest
6615 .find("# filter for syscall ")
6616 .map_or(pfc.len(), |at| start + 1 + at);
6617 &pfc[start..end]
6618 }
6619
6620 fn returns_of(filter: &libseccomp::ScmpFilterContext, action: u32) -> usize {
6622 let exported = export_to_memfd(|fd| filter.export_bpf(fd)).expect("export the bpf");
6623 let (insns, _) = exported.as_chunks::<SOCK_FILTER_BYTES>();
6624 insns
6625 .iter()
6626 .filter(|insn| {
6627 let code = u16::from_ne_bytes([insn[0], insn[1]]);
6628 let k = u32::from_ne_bytes([insn[4], insn[5], insn[6], insn[7]]);
6629 code == (libc::BPF_RET | libc::BPF_K) as u16 && k == action
6630 })
6631 .count()
6632 }
6633
6634 #[test]
6638 fn seccomp_observed_filter_notifies_on_bind_instead_of_allowing_it() {
6639 const SECCOMP_RET_USER_NOTIF: u32 = 0x7fc0_0000;
6640 for unix_sockets_allowed in [false, true] {
6641 let plain = seccomp_filter_pfc(unix_sockets_allowed, false).expect("plain pfc");
6642 let observed =
6643 seccomp_filter_pfc(unix_sockets_allowed, true).expect("observed pfc");
6644 eprintln!(
6645 "unix_sockets_allowed={unix_sockets_allowed}\nplain bind block:\n{}\
6646 observed bind block:\n{}",
6647 bind_block(&plain),
6648 bind_block(&observed)
6649 );
6650
6651 let notify = format!("action {SECCOMP_RET_USER_NOTIF:#x};");
6653 assert!(bind_block(&plain).contains("action ALLOW;"));
6654 assert!(!plain.contains(¬ify), "{plain}");
6655 assert!(bind_block(&observed).contains(¬ify));
6656 assert!(!bind_block(&observed).contains("ALLOW"));
6657 assert_eq!(observed.matches(¬ify).count(), 1, "{observed}");
6658 assert_eq!(
6659 observed.matches("action ALLOW;").count() + 1,
6660 plain.matches("action ALLOW;").count()
6661 );
6662 assert_eq!(
6663 observed.matches("# filter for syscall ").count(),
6664 plain.matches("# filter for syscall ").count()
6665 );
6666
6667 let plain = build_seccomp_filter(unix_sockets_allowed, false).unwrap();
6668 let observed = build_seccomp_filter(unix_sockets_allowed, true).unwrap();
6669 assert_eq!(returns_of(&plain, SECCOMP_RET_USER_NOTIF), 0);
6670 assert!(returns_of(&observed, SECCOMP_RET_USER_NOTIF) >= 1);
6671 }
6672 }
6673
6674 fn second_listener_errno(first: &libseccomp::ScmpFilterContext) -> i32 {
6678 let allow = [libc::sock_filter {
6679 code: (libc::BPF_RET | libc::BPF_K) as u16,
6680 jt: 0,
6681 jf: 0,
6682 k: libc::SECCOMP_RET_ALLOW,
6683 }];
6684 let program = libc::sock_fprog {
6685 len: allow.len() as u16,
6686 filter: allow.as_ptr().cast_mut(),
6687 };
6688 let pid = unsafe { libc::fork() };
6691 assert!(pid >= 0, "fork failed: {}", io::Error::last_os_error());
6692 if pid == 0 {
6693 let code = match first.load() {
6694 Err(_) => PROBE_ERRNO_UNKNOWN,
6695 Ok(()) => unsafe {
6698 if libc::syscall(
6699 libc::SYS_seccomp,
6700 libc::SECCOMP_SET_MODE_FILTER,
6701 libc::SECCOMP_FILTER_FLAG_NEW_LISTENER,
6702 std::ptr::addr_of!(program),
6703 ) >= 0
6704 {
6705 0
6706 } else {
6707 *libc::__errno_location()
6708 }
6709 },
6710 };
6711 unsafe { libc::_exit(code) }
6713 }
6714 let mut status: libc::c_int = 0;
6715 let waited = unsafe { libc::waitpid(pid, &mut status, 0) };
6717 assert!(waited == pid, "waitpid: {}", io::Error::last_os_error());
6718 assert!(libc::WIFEXITED(status), "status {status}");
6719 libc::WEXITSTATUS(status)
6720 }
6721
6722 #[test]
6728 fn a_second_listener_under_the_observed_filter_is_refused() {
6729 if skip_without_seccomp("a second seccomp listener under the observed filter") {
6730 return;
6731 }
6732 let observed = build_seccomp_filter(false, true).expect("build the observed filter");
6733 let mut permissive =
6734 libseccomp::ScmpFilterContext::new(libseccomp::ScmpAction::Allow).unwrap();
6735 permissive
6736 .add_rule(
6737 libseccomp::ScmpAction::Notify,
6738 libseccomp::ScmpSyscall::from_name("bind").unwrap(),
6739 )
6740 .unwrap();
6741
6742 let shipped = second_listener_errno(&observed);
6743 let kernel = second_listener_errno(&permissive);
6744 eprintln!(
6745 "second listener under the observed filter: errno {shipped} ({}); under a \
6746 filter permitting seccomp(2): errno {kernel} ({})",
6747 io::Error::from_raw_os_error(shipped),
6748 io::Error::from_raw_os_error(kernel)
6749 );
6750 assert_eq!(shipped, libc::EPERM);
6751 assert_eq!(kernel, libc::EBUSY);
6752 }
6753
6754 #[test]
6761 fn seccomp_per_filter_ceiling() {
6762 if skip_without_seccomp("the kernel's per-filter instruction ceiling") {
6763 return;
6764 }
6765
6766 let over_rules = first_rule_count_above(BPF_MAXINSNS_CEILING)
6767 .expect("bracket the per-filter ceiling");
6768 let (under_filter, under_count) =
6769 probe_filter(over_rules - 1).expect("build the under-ceiling probe filter");
6770 let (over_filter, over_count) =
6771 probe_filter(over_rules).expect("build the over-ceiling probe filter");
6772
6773 assert!(
6774 under_count <= BPF_MAXINSNS_CEILING && over_count > BPF_MAXINSNS_CEILING,
6775 "the two probe filters ({under_count} and {over_count} instructions) do not \
6776 straddle BPF_MAXINSNS_CEILING ({BPF_MAXINSNS_CEILING}), so this test would prove \
6777 nothing about where the ceiling is"
6778 );
6779
6780 let under_outcome = load_in_child(&under_filter);
6781 let over_outcome = load_in_child(&over_filter);
6782
6783 eprintln!(
6784 "seccomp per-filter ceiling: largest filter that loaded = {under_count} \
6785 instructions; smallest that did not = {over_count} instructions, refused with \
6786 errno {} (EINVAL is {}); recorded BPF_MAXINSNS_CEILING = {BPF_MAXINSNS_CEILING}",
6787 over_outcome.err().unwrap_or(0),
6788 libc::EINVAL,
6789 );
6790
6791 assert_eq!(
6792 under_outcome,
6793 Ok(()),
6794 "a {under_count}-instruction filter was refused, below the \
6795 {BPF_MAXINSNS_CEILING}-instruction ceiling this test expects the kernel to hold"
6796 );
6797 assert_eq!(
6798 over_outcome,
6799 Err(libc::EINVAL),
6800 "a {over_count}-instruction filter was not refused with EINVAL, so \
6801 BPF_MAXINSNS_CEILING ({BPF_MAXINSNS_CEILING}) is not where this kernel puts its \
6802 per-filter limit"
6803 );
6804 }
6805
6806 #[test]
6814 fn seccomp_cumulative_ceiling() {
6815 if skip_without_seccomp("the kernel's cumulative filter-chain ceiling") {
6816 return;
6817 }
6818
6819 let (filter, count) = probe_filter(120).expect("build the cumulative probe filter");
6822 let per_filter_cost = count + KERNEL_PER_FILTER_OVERHEAD_INSNS;
6823 let max_loads = SECCOMP_MAX_INSNS_PER_PATH / per_filter_cost + 16;
6826
6827 let mut fds = [0 as libc::c_int; 2];
6828 let rc = unsafe { libc::pipe2(fds.as_mut_ptr(), libc::O_CLOEXEC) };
6830 assert!(rc == 0, "pipe2: {}", io::Error::last_os_error());
6831 let (read_fd, write_fd) =
6833 unsafe { (OwnedFd::from_raw_fd(fds[0]), OwnedFd::from_raw_fd(fds[1])) };
6834
6835 let pid = unsafe { libc::fork() };
6839 assert!(pid >= 0, "fork failed: {}", io::Error::last_os_error());
6840
6841 if pid == 0 {
6842 let mut loads: u32 = 0;
6843 let mut refusal: i32 = 0;
6844 while loads < max_loads as u32 {
6845 match filter.load() {
6846 Ok(()) => loads += 1,
6847 Err(error) => {
6848 refusal = refusal_errno(&error);
6849 break;
6850 }
6851 }
6852 }
6853
6854 let mut report = [0u8; 8];
6855 report[..4].copy_from_slice(&loads.to_ne_bytes());
6856 report[4..].copy_from_slice(&refusal.to_ne_bytes());
6857 unsafe { libc::write(write_fd.as_raw_fd(), report.as_ptr().cast(), report.len()) };
6860 unsafe { libc::_exit(0) }
6862 }
6863
6864 drop(write_fd);
6865 let mut report = [0u8; 8];
6866 std::io::Read::read_exact(&mut std::fs::File::from(read_fd), &mut report)
6867 .expect("read the cumulative probe child's report");
6868 let loads = u32::from_ne_bytes(report[..4].try_into().unwrap()) as usize;
6869 let refusal = i32::from_ne_bytes(report[4..].try_into().unwrap());
6870
6871 let mut status: libc::c_int = 0;
6872 let waited = unsafe { libc::waitpid(pid, &mut status, 0) };
6874 assert!(waited == pid, "waitpid: {}", io::Error::last_os_error());
6875
6876 let stacked = loads * count;
6877 let cumulative_at_refusal = count + loads * per_filter_cost;
6878 let loads_if_counted_classic = SECCOMP_MAX_INSNS_PER_PATH / per_filter_cost;
6879 let shipped = super::seccomp_filter_instruction_count(false)
6880 .expect("measure the shipped filter for comparison");
6881
6882 eprintln!(
6883 "seccomp cumulative ceiling: {loads} loads of a {count}-instruction filter \
6884 succeeded ({stacked} instructions stacked, {cumulative_at_refusal} counted the \
6885 way the kernel counts once the {KERNEL_PER_FILTER_OVERHEAD_INSNS}-instruction \
6886 per-filter overhead is added); load {} was refused with errno {refusal} (ENOMEM \
6887 is {}), against the recorded SECCOMP_MAX_INSNS_PER_PATH of \
6888 {SECCOMP_MAX_INSNS_PER_PATH} — which the exported classic length puts at at most \
6889 {loads_if_counted_classic} loads. The runtime installs ONE filter of {shipped} \
6890 instructions, so reaching this ceiling would take {} of them in a single task.",
6891 loads + 1,
6892 libc::ENOMEM,
6893 SECCOMP_MAX_INSNS_PER_PATH / shipped,
6894 );
6895
6896 assert!(
6897 loads >= 2,
6898 "the kernel refused the filter chain after only {loads} load(s), so this is not \
6899 a cumulative limit and the ENOMEM it returns is not the one being ruled out"
6900 );
6901 assert_eq!(
6902 refusal,
6903 libc::ENOMEM,
6904 "stacking filters past SECCOMP_MAX_INSNS_PER_PATH was refused with errno \
6905 {refusal}, not ENOMEM ({}) — the rule-out in \
6906 docs/content/reference/seccomp-filter-instruction-budget.md rests on ENOMEM \
6907 being what this ceiling returns",
6908 libc::ENOMEM
6909 );
6910 assert!(
6911 loads <= loads_if_counted_classic,
6912 "the kernel took {loads} loads of a {count}-instruction filter — more than the \
6913 {loads_if_counted_classic} that fit under SECCOMP_MAX_INSNS_PER_PATH \
6914 ({SECCOMP_MAX_INSNS_PER_PATH}) even before the conversion to eBPF lengthens \
6915 each one, so this kernel's ceiling is higher than the recorded value"
6916 );
6917 assert!(
6922 loads * 2 > loads_if_counted_classic,
6923 "the kernel refused after {loads} loads of a {count}-instruction filter, far \
6924 short of the {loads_if_counted_classic} that SECCOMP_MAX_INSNS_PER_PATH \
6925 ({SECCOMP_MAX_INSNS_PER_PATH}) implies — the ceiling being measured is not the \
6926 one recorded"
6927 );
6928 assert!(
6929 shipped * 2 < SECCOMP_MAX_INSNS_PER_PATH,
6930 "the shipped filter ({shipped} instructions) is within a factor of two of the \
6931 cumulative ceiling, which would make the rule-out this test supports untrue"
6932 );
6933 }
6934 }
6935}
6936
6937#[cfg(test)]
6942pub(crate) fn build_elf64(interp: Option<&str>, needed: &[&str], runpath: Option<&str>) -> Vec<u8> {
6943 let has_dynamic = !needed.is_empty() || runpath.is_some();
6944
6945 let mut phnum: u16 = 1; if interp.is_some() {
6947 phnum += 1;
6948 }
6949 if has_dynamic {
6950 phnum += 1;
6951 }
6952
6953 let ehsize = 64usize;
6954 let phentsize = 56usize;
6955 let phoff = ehsize;
6956 let body_start = phoff + phentsize * phnum as usize;
6957
6958 let mut strtab: Vec<u8> = vec![0];
6960 let mut needed_offsets = Vec::new();
6961 for name in needed {
6962 needed_offsets.push(strtab.len() as u64);
6963 strtab.extend_from_slice(name.as_bytes());
6964 strtab.push(0);
6965 }
6966 let runpath_offset = runpath.map(|r| {
6967 let off = strtab.len() as u64;
6968 strtab.extend_from_slice(r.as_bytes());
6969 strtab.push(0);
6970 off
6971 });
6972
6973 let mut body: Vec<u8> = Vec::new();
6974
6975 let interp_offset = body_start + body.len();
6976 if let Some(i) = interp {
6977 body.extend_from_slice(i.as_bytes());
6978 body.push(0);
6979 }
6980
6981 while !(body_start + body.len()).is_multiple_of(8) {
6983 body.push(0);
6984 }
6985 let dyn_offset = body_start + body.len();
6986 let num_dyn = needed.len() + runpath.map_or(0, |_| 1) + 3; let dyn_size = num_dyn * 16;
6988 let strtab_offset = dyn_offset + dyn_size;
6989
6990 if has_dynamic {
6991 let mut push_dyn = |tag: u64, val: u64| {
6992 body.extend_from_slice(&tag.to_le_bytes());
6993 body.extend_from_slice(&val.to_le_bytes());
6994 };
6995 for &o in &needed_offsets {
6996 push_dyn(1, o); }
6998 if let Some(ro) = runpath_offset {
6999 push_dyn(29, ro); }
7001 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);
7005 body.extend_from_slice(&strtab);
7006 }
7007
7008 let total_len = body_start + body.len();
7009 let mut out = vec![0u8; total_len];
7010 out[0..4].copy_from_slice(b"\x7fELF");
7011 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 {
7024 phdrs.push((
7025 3,
7026 interp_offset as u64,
7027 interp_offset as u64,
7028 i.len() as u64 + 1,
7029 ));
7030 }
7031 if has_dynamic {
7032 phdrs.push((2, dyn_offset as u64, dyn_offset as u64, dyn_size as u64));
7033 }
7034 for (idx, (p_type, p_offset, p_vaddr, p_filesz)) in phdrs.iter().enumerate() {
7035 let base = phoff + idx * phentsize;
7036 out[base..base + 4].copy_from_slice(&p_type.to_le_bytes());
7037 out[base + 8..base + 16].copy_from_slice(&p_offset.to_le_bytes());
7038 out[base + 16..base + 24].copy_from_slice(&p_vaddr.to_le_bytes());
7039 out[base + 32..base + 40].copy_from_slice(&p_filesz.to_le_bytes());
7040 }
7041
7042 out[body_start..total_len].copy_from_slice(&body);
7043 out
7044}
7045
7046#[cfg(test)]
7047mod tests {
7048 use super::*;
7049 use murmur_artifact::security_warnings::W_SEC_003;
7050
7051 #[test]
7058 fn fd_hygiene_range_starts_above_stdio() {
7059 assert_eq!(
7060 FD_HYGIENE_FIRST_FD, 3,
7061 "fd hygiene must start at 3 — 0/1/2 are the stdio pipes every spawn needs to \
7062 survive execve"
7063 );
7064 }
7065
7066 #[cfg(target_os = "linux")]
7077 #[allow(unsafe_code)]
7078 #[test]
7079 fn close_range_syscall_reports_failure_as_minus_one_and_errno() {
7080 let rc = unsafe {
7084 libc::syscall(
7085 libc::SYS_close_range,
7086 FD_HYGIENE_FIRST_FD,
7087 libc::c_uint::MAX,
7088 libc::c_uint::MAX,
7089 )
7090 };
7091
7092 assert_eq!(rc, -1, "an invalid flag word must not be accepted");
7093 assert_eq!(
7094 std::io::Error::last_os_error().raw_os_error(),
7095 Some(libc::EINVAL),
7096 "the two statements the production path runs on failure must carry the kernel's errno"
7097 );
7098 }
7099
7100 fn sealed_capable() -> crate::namespace_probe::NamespaceReading {
7103 crate::namespace_probe::NamespaceReading {
7104 network: Ok(()),
7105 mount: crate::namespace_probe::MountAttempt::Created,
7106 sealed_root: crate::namespace_probe::MountAttempt::Created,
7107 private_proc: crate::namespace_probe::MountAttempt::NotAttempted,
7108 userns_grant: crate::sealed::UsernsGrant::Withheld,
7109 attachment: crate::sealed::ProfileAttachment::Unconfined,
7110 profile_gap: crate::sealed::ProfileGap::Undetermined,
7111 }
7112 }
7113
7114 fn sealed_incapable() -> crate::namespace_probe::NamespaceReading {
7116 crate::namespace_probe::NamespaceReading {
7117 network: Err(crate::network_namespace::NamespaceAttemptError::Creator(
7118 "the namespace creator did not run".to_string(),
7119 )),
7120 mount: crate::namespace_probe::MountAttempt::NotAttempted,
7121 sealed_root: crate::namespace_probe::MountAttempt::NotAttempted,
7122 private_proc: crate::namespace_probe::MountAttempt::NotAttempted,
7123 userns_grant: crate::sealed::UsernsGrant::Withheld,
7124 attachment: crate::sealed::ProfileAttachment::NotReported,
7125 profile_gap: crate::sealed::ProfileGap::Undetermined,
7126 }
7127 }
7128
7129 #[test]
7133 fn doctor_reads_the_decision_from_the_probe_it_is_handed() {
7134 if !cfg!(target_os = "linux") {
7135 return;
7136 }
7137 let sealed_host = HostProbe::from_parts(Some(true), sealed_capable());
7138 assert_eq!(sealed_host.tier(), EnforcementTier::KernelSealed);
7139 assert_eq!(
7140 crate::containment::applied_mechanism(
7141 &sealed_host,
7142 murmur_artifact::ContainmentClass::Scoped
7143 ),
7144 "landlock+seccomp"
7145 );
7146 assert_eq!(
7147 crate::containment::applied_mechanism(
7148 &sealed_host,
7149 murmur_artifact::ContainmentClass::Sealed
7150 ),
7151 "mountns+pivot_root+landlock+seccomp"
7152 );
7153 }
7154
7155 fn unprobed() -> HostProbe {
7158 HostProbe::from_parts(None, sealed_incapable())
7159 }
7160
7161 #[test]
7165 fn an_unprobed_host_resolves_to_seccomp_only() {
7166 let tier = unprobed().tier();
7167 if cfg!(target_os = "linux") {
7168 assert_eq!(tier, EnforcementTier::KernelSeccompOnly);
7169 } else {
7170 assert_eq!(tier, EnforcementTier::EnvironmentOnly);
7171 }
7172 assert_ne!(tier, EnforcementTier::KernelSealed);
7173 assert_ne!(tier, EnforcementTier::KernelFull);
7174 }
7175
7176 #[test]
7179 fn an_unprobed_host_names_a_blocker() {
7180 assert!(unprobed().sealed_shortfall().is_some());
7181 }
7182
7183 #[test]
7186 fn an_unprobed_host_achieves_less_than_sealed() {
7187 let achieved = crate::containment::achieved_class_for_tier(unprobed().tier());
7188 assert!(achieved < murmur_artifact::ContainmentClass::Sealed);
7189 }
7190
7191 #[test]
7194 fn an_unprobed_host_refuses_a_declared_sealed_floor() {
7195 let probe = unprobed();
7196 let achieved = crate::containment::achieved_class_for_tier(probe.tier());
7197 let error = crate::containment::check_containment_floor(
7198 murmur_artifact::ContainmentClass::Sealed,
7199 achieved,
7200 probe.sealed_shortfall().as_ref(),
7201 false,
7202 None,
7203 )
7204 .expect_err("an unprobed host cannot back a declared sealed floor");
7205 assert!(matches!(
7206 error,
7207 crate::errors::RuntimeError::ContainmentFloorUnmet { .. }
7208 ));
7209 }
7210
7211 #[test]
7215 fn a_probe_that_created_every_namespace_backs_sealed_while_the_setting_reads_one() {
7216 for attachment in [
7217 crate::sealed::ProfileAttachment::Unconfined,
7218 crate::sealed::ProfileAttachment::Other {
7219 profile: "docker-default".to_string(),
7220 },
7221 ] {
7222 let probe = HostProbe::from_parts(
7223 Some(true),
7224 crate::namespace_probe::NamespaceReading {
7225 attachment,
7226 ..sealed_capable()
7227 },
7228 );
7229 if !cfg!(target_os = "linux") {
7230 assert_eq!(probe.tier(), EnforcementTier::EnvironmentOnly);
7231 continue;
7232 }
7233 assert_eq!(probe.tier(), EnforcementTier::KernelSealed);
7234 assert_eq!(probe.sealed_shortfall(), None);
7235 assert!(probe.mount_namespace_usable());
7236 assert_eq!(probe.egress_namespace_failure(), None);
7237 assert_eq!(
7238 crate::temp_dir::temp_mapping(
7239 EnforcementTier::KernelFull,
7240 probe.mount_namespace_usable(),
7241 Path::new("/home/u/w"),
7242 ),
7243 crate::temp_dir::TempMapping::MountNamespace
7244 );
7245 }
7246 }
7247
7248 #[test]
7252 fn every_decision_is_identical_across_every_userns_grant() {
7253 use crate::namespace_probe::{
7254 MountAttempt, MountAttemptError, MountStep, NamespaceReading,
7255 };
7256 let root_refused = NamespaceReading {
7257 sealed_root: MountAttempt::Failed(MountAttemptError::Step {
7258 step: MountStep::PivotRoot,
7259 errno: libc::EPERM,
7260 }),
7261 ..sealed_capable()
7262 };
7263 for base in [sealed_capable(), root_refused, sealed_incapable()] {
7264 let decide = |grant: crate::sealed::UsernsGrant| {
7265 let probe = HostProbe::from_parts(
7266 Some(true),
7267 NamespaceReading {
7268 userns_grant: grant,
7269 ..base.clone()
7270 },
7271 );
7272 (
7273 probe.tier(),
7274 probe.sealed_shortfall().map(|shortfall| shortfall.blocker),
7275 probe.mount_namespace_usable(),
7276 probe
7277 .egress_namespace_failure()
7278 .map(|failure| failure.blocker),
7279 )
7280 };
7281 let expected = decide(crate::sealed::UsernsGrant::Withheld);
7282 for grant in crate::sealed::UsernsGrant::ALL {
7283 assert_eq!(decide(*grant), expected, "{grant:?} over {base:?}");
7284 }
7285 }
7286 }
7287
7288 #[test]
7292 fn a_live_reading_answers_every_question_from_its_own_attempts() {
7293 let probe = HostProbe::probe();
7294 let namespaces = probe.namespaces().clone();
7295 let linux = cfg!(target_os = "linux");
7296 assert_eq!(
7297 probe.tier() == EnforcementTier::KernelSealed,
7298 probe.sealed_shortfall().is_none(),
7299 "{namespaces:?}"
7300 );
7301 assert_eq!(
7302 probe.mount_namespace_usable(),
7303 linux
7304 && namespaces.network.is_ok()
7305 && namespaces.mount == crate::namespace_probe::MountAttempt::Created
7306 );
7307 assert_eq!(
7308 probe.egress_namespace_failure().is_some(),
7309 linux && namespaces.network.is_err()
7310 );
7311 }
7312
7313 #[test]
7314 fn tier_from_probe_non_linux_is_always_environment_only() {
7315 for landlock in [None, Some(true), Some(false)] {
7316 for namespaces in [sealed_capable(), sealed_incapable()] {
7317 assert_eq!(
7318 tier_from_probe(false, landlock, &namespaces),
7319 EnforcementTier::EnvironmentOnly
7320 );
7321 }
7322 }
7323 }
7324
7325 #[test]
7326 fn tier_from_probe_linux_fully_enforced_is_kernel_full() {
7327 assert_eq!(
7328 tier_from_probe(true, Some(true), &sealed_incapable()),
7329 EnforcementTier::KernelFull
7330 );
7331 }
7332
7333 #[test]
7334 fn tier_from_probe_linux_partially_enforced_is_seccomp_only() {
7335 assert_eq!(
7336 tier_from_probe(true, Some(false), &sealed_incapable()),
7337 EnforcementTier::KernelSeccompOnly
7338 );
7339 }
7340
7341 #[test]
7342 fn tier_from_probe_linux_no_probe_result_is_seccomp_only() {
7343 assert_eq!(
7344 tier_from_probe(true, None, &sealed_incapable()),
7345 EnforcementTier::KernelSeccompOnly
7346 );
7347 }
7348
7349 #[test]
7353 fn tier_from_probe_reaches_sealed_only_when_every_precondition_holds() {
7354 use crate::namespace_probe::{
7355 MountAttempt, MountAttemptError, MountStep, NamespaceReading,
7356 };
7357
7358 assert_eq!(
7359 tier_from_probe(true, Some(true), &sealed_capable()),
7360 EnforcementTier::KernelSealed
7361 );
7362
7363 let network_refused = NamespaceReading {
7365 network: Err(crate::network_namespace::NamespaceAttemptError::Step {
7366 step: crate::network_namespace::CreationStep::IdentityMaps,
7367 errno: libc::EPERM,
7368 }),
7369 mount: MountAttempt::NotAttempted,
7370 sealed_root: MountAttempt::NotAttempted,
7371 ..sealed_capable()
7372 };
7373 assert_eq!(
7374 tier_from_probe(true, Some(true), &network_refused),
7375 EnforcementTier::KernelFull
7376 );
7377
7378 for step in [
7380 MountStep::JoinSession,
7381 MountStep::UnshareUserMount,
7382 MountStep::IdentityMaps,
7383 MountStep::MakePrivateNested,
7384 MountStep::Tmpfs,
7385 MountStep::PivotPrepare,
7386 MountStep::PivotRoot,
7387 MountStep::DetachOldRoot,
7388 ] {
7389 let reading = NamespaceReading {
7390 sealed_root: MountAttempt::Failed(MountAttemptError::Step {
7391 step,
7392 errno: libc::EPERM,
7393 }),
7394 ..sealed_capable()
7395 };
7396 assert_eq!(
7397 tier_from_probe(true, Some(true), &reading),
7398 EnforcementTier::KernelFull,
7399 "a sealed root refused at {step:?} must fall back to KernelFull"
7400 );
7401 }
7402
7403 let tmp_refused = NamespaceReading {
7405 mount: MountAttempt::Failed(MountAttemptError::Step {
7406 step: MountStep::MakePrivate,
7407 errno: libc::EACCES,
7408 }),
7409 ..sealed_capable()
7410 };
7411 assert_eq!(
7412 tier_from_probe(true, Some(true), &tmp_refused),
7413 EnforcementTier::KernelSealed
7414 );
7415
7416 assert_eq!(
7419 tier_from_probe(true, Some(false), &sealed_capable()),
7420 EnforcementTier::KernelSeccompOnly
7421 );
7422 assert_eq!(
7423 tier_from_probe(true, None, &sealed_capable()),
7424 EnforcementTier::KernelSeccompOnly
7425 );
7426
7427 assert_eq!(
7429 tier_from_probe(false, Some(true), &sealed_capable()),
7430 EnforcementTier::EnvironmentOnly
7431 );
7432 }
7433
7434 #[test]
7438 fn the_tmp_mapping_follows_the_mount_attempt() {
7439 use crate::namespace_probe::{
7440 MountAttempt, MountAttemptError, MountStep, NamespaceReading,
7441 };
7442 use crate::temp_dir::{temp_mapping, NotMappedReason, TempMapping};
7443 if !cfg!(target_os = "linux") {
7444 return;
7445 }
7446 let workdir = Path::new("/home/u/w");
7447 let refused = |step, errno| MountAttempt::Failed(MountAttemptError::Step { step, errno });
7448
7449 let mount_refused = HostProbe::from_parts(
7450 Some(true),
7451 NamespaceReading {
7452 mount: refused(MountStep::MakePrivate, libc::EACCES),
7453 sealed_root: refused(MountStep::MakePrivateNested, libc::EACCES),
7454 ..sealed_capable()
7455 },
7456 );
7457 assert_eq!(mount_refused.tier(), EnforcementTier::KernelFull);
7458 assert_eq!(
7459 temp_mapping(
7460 mount_refused.tier(),
7461 mount_refused.mount_namespace_usable(),
7462 workdir
7463 ),
7464 TempMapping::NotMapped(NotMappedReason::NoMountNamespace)
7465 );
7466
7467 let root_refused = HostProbe::from_parts(
7468 Some(true),
7469 NamespaceReading {
7470 sealed_root: refused(MountStep::PivotRoot, libc::EPERM),
7471 ..sealed_capable()
7472 },
7473 );
7474 assert_eq!(root_refused.tier(), EnforcementTier::KernelFull);
7475 assert_eq!(
7476 temp_mapping(
7477 root_refused.tier(),
7478 root_refused.mount_namespace_usable(),
7479 workdir
7480 ),
7481 TempMapping::MountNamespace
7482 );
7483
7484 let network_refused = HostProbe::from_parts(
7485 Some(true),
7486 NamespaceReading {
7487 network: Err(crate::network_namespace::NamespaceAttemptError::Step {
7488 step: crate::network_namespace::CreationStep::Unshare,
7489 errno: libc::EPERM,
7490 }),
7491 mount: MountAttempt::NotAttempted,
7492 sealed_root: MountAttempt::NotAttempted,
7493 ..sealed_capable()
7494 },
7495 );
7496 assert!(!network_refused.mount_namespace_usable());
7497 assert!(matches!(
7498 temp_mapping(
7499 network_refused.tier(),
7500 network_refused.mount_namespace_usable(),
7501 workdir
7502 ),
7503 TempMapping::NotMapped(_)
7504 ));
7505 assert!(network_refused.egress_namespace_failure().is_some());
7506 }
7507
7508 #[test]
7510 fn ruleset_landlock_abi_is_the_kernel_abi_capped_at_the_target_on_landlock_tiers() {
7511 for tier in [EnforcementTier::KernelFull, EnforcementTier::KernelSealed] {
7512 for (kernel, built) in [
7513 (Some(1), Some(1)),
7514 (Some(2), Some(2)),
7515 (Some(3), Some(3)),
7516 (Some(8), Some(3)),
7517 (Some(u8::MAX), Some(3)),
7518 ] {
7519 assert_eq!(
7520 ruleset_landlock_abi(kernel, tier),
7521 built,
7522 "kernel ABI {kernel:?} on {tier:?}"
7523 );
7524 }
7525 }
7526 }
7527
7528 #[test]
7530 fn ruleset_landlock_abi_is_none_without_a_kernel_abi_or_a_landlock_tier() {
7531 for tier in [EnforcementTier::KernelFull, EnforcementTier::KernelSealed] {
7532 assert_eq!(ruleset_landlock_abi(None, tier), None);
7533 assert_eq!(ruleset_landlock_abi(Some(0), tier), None);
7534 }
7535 for tier in [
7536 EnforcementTier::KernelSeccompOnly,
7537 EnforcementTier::EnvironmentOnly,
7538 ] {
7539 for kernel in [None, Some(0), Some(1), Some(3), Some(8)] {
7540 assert_eq!(
7541 ruleset_landlock_abi(kernel, tier),
7542 None,
7543 "kernel ABI {kernel:?} on {tier:?}"
7544 );
7545 }
7546 }
7547 }
7548
7549 #[test]
7552 fn ruleset_landlock_abi_reaches_shell_enforcement_from_the_probe() {
7553 let negative = unprobed();
7554 for abi in [None, Some(1), Some(8)] {
7555 let enforcement = ShellEnforcement::resolve_declared(
7556 &CapabilityPolicy::default(),
7557 murmur_artifact::ContainmentClass::Advisory,
7558 negative.clone().with_landlock_kernel_abi(abi),
7559 )
7560 .unwrap()
7561 .with_test_session_dirs();
7562 assert_eq!(
7563 enforcement.landlock_abi,
7564 ruleset_landlock_abi(abi, enforcement.tier),
7565 "kernel ABI {abi:?}"
7566 );
7567 assert_eq!(enforcement.landlock_abi, None);
7569 }
7570 if cfg!(target_os = "linux") {
7571 let full = HostProbe::from_parts(Some(true), sealed_incapable());
7572 let enforcement = ShellEnforcement::resolve_declared(
7573 &CapabilityPolicy::default(),
7574 murmur_artifact::ContainmentClass::Scoped,
7575 full.with_landlock_kernel_abi(Some(8)),
7576 )
7577 .unwrap()
7578 .with_test_session_dirs();
7579 assert_eq!(enforcement.tier, EnforcementTier::KernelFull);
7580 assert_eq!(enforcement.landlock_abi, Some(LANDLOCK_TARGET_ABI));
7581 }
7582 }
7583
7584 #[test]
7586 fn landlock_doctor_lines_name_the_kernel_and_ruleset_abi() {
7587 if !cfg!(target_os = "linux") {
7588 for abi in [None, Some(1), Some(8)] {
7589 assert_eq!(
7590 landlock_doctor_lines(abi),
7591 vec!["kernel ABI: n/a (Landlock is a Linux mechanism)".to_string()]
7592 );
7593 }
7594 return;
7595 }
7596
7597 let none = landlock_doctor_lines(None).join("\n");
7598 assert!(none.contains("no Landlock on this host"), "{none}");
7599 assert_eq!(landlock_doctor_lines(Some(0)), landlock_doctor_lines(None));
7600
7601 let v1 = landlock_doctor_lines(Some(1)).join("\n");
7602 assert!(v1.contains("kernel ABI: 1"), "{v1}");
7603 assert!(v1.contains("ruleset ABI: 1"), "{v1}");
7604 assert!(v1.contains("EXDEV"), "{v1}");
7605 assert!(v1.contains("Invalid cross-device link"), "{v1}");
7606 assert!(v1.contains("Linux 5.19 (Landlock ABI v2)"), "{v1}");
7607 assert!(v1.contains("uv"), "{v1}");
7608
7609 let v2 = landlock_doctor_lines(Some(2)).join("\n");
7610 assert!(v2.contains("kernel ABI: 2"), "{v2}");
7611 assert!(v2.contains("ruleset ABI: 2"), "{v2}");
7612 assert!(!v2.contains("EXDEV"), "{v2}");
7613 assert!(
7614 v2.contains("not mediated below Landlock ABI v3 (Linux 6.2)"),
7615 "{v2}"
7616 );
7617
7618 for kernel in [3, 8] {
7619 let lines = landlock_doctor_lines(Some(kernel));
7620 assert_eq!(
7621 lines,
7622 vec![
7623 format!("kernel ABI: {kernel}"),
7624 "ruleset ABI: 3 (the kernel's, capped at 3)".to_string(),
7625 ]
7626 );
7627 }
7628 }
7629
7630 #[test]
7633 fn applied_tier_is_unconfined_on_every_host_for_the_unconfined_stance() {
7634 use murmur_artifact::ContainmentClass;
7635 for host_tier in [
7636 EnforcementTier::KernelSealed,
7637 EnforcementTier::KernelFull,
7638 EnforcementTier::KernelSeccompOnly,
7639 EnforcementTier::EnvironmentOnly,
7640 ] {
7641 assert_eq!(
7642 applied_tier(host_tier, ContainmentClass::Unconfined),
7643 EnforcementTier::Unconfined,
7644 "{host_tier:?}"
7645 );
7646 }
7647 assert_eq!(tier_warning(EnforcementTier::Unconfined, false), None);
7648 assert_eq!(
7649 ruleset_landlock_abi(Some(4), EnforcementTier::Unconfined),
7650 None
7651 );
7652 }
7653
7654 #[test]
7658 fn applied_tier_never_installs_a_composed_root_for_a_weaker_declaration() {
7659 use murmur_artifact::ContainmentClass;
7660
7661 assert_eq!(
7662 applied_tier(EnforcementTier::KernelSealed, ContainmentClass::Sealed),
7663 EnforcementTier::KernelSealed
7664 );
7665 for declared in [ContainmentClass::Advisory, ContainmentClass::Scoped] {
7666 assert_eq!(
7667 applied_tier(EnforcementTier::KernelSealed, declared),
7668 EnforcementTier::KernelFull,
7669 "declaring {declared} must not opt a capsule into the composed root"
7670 );
7671 }
7672
7673 for host_tier in [
7676 EnforcementTier::KernelFull,
7677 EnforcementTier::KernelSeccompOnly,
7678 EnforcementTier::EnvironmentOnly,
7679 ] {
7680 for declared in [
7681 ContainmentClass::Advisory,
7682 ContainmentClass::Scoped,
7683 ContainmentClass::Sealed,
7684 ] {
7685 assert_eq!(applied_tier(host_tier, declared), host_tier);
7686 }
7687 }
7688 }
7689
7690 pub(super) fn granted_needs(
7693 paths: &[(PathBuf, bool)],
7694 ) -> Vec<crate::declared_needs::ResolvedProgramNeeds> {
7695 vec![crate::declared_needs::ResolvedProgramNeeds {
7696 artifact: "local/needs-tool".to_string(),
7697 program: "cat".to_string(),
7698 granted: paths
7699 .iter()
7700 .map(|(path, directory)| crate::declared_needs::ResolvedNeed {
7701 need: murmur_artifact::DeclaredNeed {
7702 form: murmur_artifact::NeedForm::Etc("fixture".to_string()),
7703 packages: Default::default(),
7704 },
7705 paths: vec![crate::declared_needs::NeedPath {
7706 path: path.clone(),
7707 directory: *directory,
7708 }],
7709 })
7710 .collect(),
7711 missing: Vec::new(),
7712 }]
7713 }
7714
7715 #[test]
7719 fn sealed_need_mirrors_are_each_need_at_its_own_path() {
7720 let needs = granted_needs(&[
7721 (PathBuf::from("/etc/fonts/fonts.conf"), false),
7722 (PathBuf::from("/usr/share/fonts"), true),
7723 (PathBuf::from("/lib/x86_64-linux-gnu/libm.so.6"), false),
7724 (PathBuf::from("/etc/ssl/certs"), true),
7725 (PathBuf::from("/opt/fixture/share"), true),
7726 (PathBuf::from("/opt/fixture/share"), true),
7727 (PathBuf::from("/var/cache/fontconfig"), true),
7728 ]);
7729 assert_eq!(
7730 resolve_sealed_need_mirrors(&needs, &[]),
7731 [
7732 PathBuf::from("/etc/fonts/fonts.conf"),
7733 PathBuf::from("/opt/fixture/share"),
7734 PathBuf::from("/var/cache/fontconfig"),
7735 ]
7736 );
7737 assert!(!resolve_sealed_need_mirrors(&needs, &[]).contains(&PathBuf::from("/etc")));
7738 assert_eq!(
7739 resolve_sealed_need_mirrors(&needs, &[PathBuf::from("/opt/fixture")]),
7740 [
7741 PathBuf::from("/etc/fonts/fonts.conf"),
7742 PathBuf::from("/var/cache/fontconfig"),
7743 ]
7744 );
7745 }
7746
7747 #[test]
7749 fn resolve_declared_need_grants_reads_and_lists_but_never_executes() {
7750 let needs = granted_needs(&[
7751 (PathBuf::from("/opt/fixture/lib/libfix.so.1"), false),
7752 (PathBuf::from("/opt/fixture/share"), true),
7753 ]);
7754 assert_eq!(
7755 resolve_declared_need_grants(&needs),
7756 [
7757 LandlockGrant {
7758 path: PathBuf::from("/opt/fixture/lib/libfix.so.1"),
7759 list_dir: false,
7760 executable: false,
7761 },
7762 LandlockGrant {
7763 path: PathBuf::from("/opt/fixture/share"),
7764 list_dir: true,
7765 executable: false,
7766 },
7767 ]
7768 );
7769 let mut policy = CapabilityPolicy {
7770 shell_allow: vec!["cat".to_string()],
7771 ..CapabilityPolicy::default()
7772 };
7773 policy.shell_declared_needs = needs;
7774 policy.shell_interpreter_runtime = vec![murmur_artifact::InterpreterRuntimeGrant {
7776 binary: "cat".to_string(),
7777 dirs: vec![murmur_artifact::InterpreterRuntimeDir {
7778 path: "/opt/own-grant".to_string(),
7779 list_dir: false,
7780 }],
7781 }];
7782 let enforcement = ShellEnforcement::resolve_declared(
7783 &policy,
7784 murmur_artifact::ContainmentClass::Scoped,
7785 HostProbe::probe(),
7786 )
7787 .unwrap();
7788 for path in ["/opt/fixture/lib/libfix.so.1", "/opt/fixture/share"] {
7789 let grant = enforcement
7790 .landlock_grants
7791 .iter()
7792 .find(|grant| grant.path == Path::new(path))
7793 .unwrap_or_else(|| panic!("{path} is granted"));
7794 assert!(!grant.executable, "{grant:?}");
7795 }
7796 assert!(enforcement
7797 .landlock_grants
7798 .iter()
7799 .any(|grant| grant.path == Path::new("/opt/own-grant") && grant.executable));
7800 assert_eq!(
7801 enforcement.sealed_need_mirrors,
7802 [
7803 PathBuf::from("/opt/fixture/lib/libfix.so.1"),
7804 PathBuf::from("/opt/fixture/share"),
7805 ]
7806 );
7807 }
7808
7809 #[test]
7814 fn pinned_programs_grant_list_and_execute_on_the_entry_and_bind_it_only_when_pinned() {
7815 use crate::pinned_programs::{PinnedSource, ResolvedPinnedProgram};
7816 let temp = tempfile::tempdir().unwrap();
7817 let tree = std::fs::canonicalize(temp.path())
7818 .unwrap()
7819 .join("programs/abc");
7820 std::fs::create_dir_all(tree.join("files/fixture")).unwrap();
7821 std::fs::create_dir_all(tree.join("bin")).unwrap();
7822 let file = tree.join("files/fixture/murmur-pinned-fixture-prog");
7823 std::fs::copy("/bin/true", &file).unwrap();
7824 std::os::unix::fs::symlink(
7825 "../files/fixture/murmur-pinned-fixture-prog",
7826 tree.join("bin/murmur-pinned-fixture-prog"),
7827 )
7828 .unwrap();
7829 let chosen = |source: PinnedSource| CapabilityPolicy {
7830 shell_allow: vec!["murmur-pinned-fixture-prog".to_string()],
7831 shell_pinned_programs: vec![ResolvedPinnedProgram {
7832 artifact: "local/pinned-tool".to_string(),
7833 program: "murmur-pinned-fixture-prog".to_string(),
7834 platform: "linux-x86_64".to_string(),
7835 source,
7836 }],
7837 ..CapabilityPolicy::default()
7838 };
7839 let resolve = |policy: &CapabilityPolicy| {
7840 ShellEnforcement::resolve_declared(
7841 policy,
7842 murmur_artifact::ContainmentClass::Scoped,
7843 HostProbe::probe(),
7844 )
7845 .unwrap()
7846 };
7847
7848 let pinned = chosen(PinnedSource::Pinned {
7849 path: file.clone(),
7850 bin_dir: tree.join("bin"),
7851 tree: tree.clone(),
7852 url: "http://127.0.0.1:1/f.zip".to_string(),
7853 sha256: "abc".to_string(),
7854 });
7855 assert_eq!(resolve_policy_exec_allowlist(&pinned), vec![file.clone()]);
7856 assert_eq!(
7857 resolve_pinned_program_landlock_grants(&pinned),
7858 vec![LandlockGrant {
7859 path: tree.clone(),
7860 list_dir: true,
7861 executable: true,
7862 }]
7863 );
7864 let enforcement = resolve(&pinned);
7865 assert!(enforcement.landlock_grants.contains(&LandlockGrant {
7866 path: tree.clone(),
7867 list_dir: true,
7868 executable: true,
7869 }));
7870 assert!(
7871 enforcement
7872 .landlock_grants
7873 .iter()
7874 .any(|grant| grant.path == file && grant.executable),
7875 "the pinned file seeds the DT_NEEDED closure like any shell.allow binary"
7876 );
7877 assert_eq!(enforcement.pinned_program_dirs, vec![tree.clone()]);
7878 assert!(host_path_execute_roots(&pinned)
7879 .iter()
7880 .any(|root| root.dir == tree));
7881
7882 for source in [
7883 PinnedSource::Host {
7884 path: PathBuf::from("/bin/true"),
7885 },
7886 PinnedSource::Missing {
7887 reason: "the store holds no copy".to_string(),
7888 },
7889 ] {
7890 let policy = chosen(source);
7891 assert!(resolve_policy_exec_allowlist(&policy).is_empty());
7892 let enforcement = resolve(&policy);
7893 assert!(enforcement.pinned_program_dirs.is_empty());
7894 assert!(!enforcement
7895 .landlock_grants
7896 .iter()
7897 .any(|grant| grant.path.starts_with(&tree)));
7898 assert!(!host_path_execute_roots(&policy)
7899 .iter()
7900 .any(|root| root.dir.starts_with(&tree)));
7901 }
7902 }
7903
7904 #[test]
7905 fn sealed_bind_dirs_are_whole_directories_outside_the_fixed_runtime_paths() {
7906 use murmur_artifact::{InterpreterRuntimeDir, InterpreterRuntimeGrant};
7907
7908 let policy = CapabilityPolicy {
7909 shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
7910 binary: "python3".to_string(),
7911 dirs: vec![
7912 InterpreterRuntimeDir {
7914 path: "/usr/lib/python3.11".to_string(),
7915 list_dir: true,
7916 },
7917 InterpreterRuntimeDir {
7918 path: "/opt/py/lib".to_string(),
7919 list_dir: true,
7920 },
7921 InterpreterRuntimeDir {
7923 path: "/opt/py/lib".to_string(),
7924 list_dir: false,
7925 },
7926 InterpreterRuntimeDir {
7927 path: "relative/lib".to_string(),
7928 list_dir: false,
7929 },
7930 ],
7931 }],
7932 ..CapabilityPolicy::default()
7933 };
7934 let exec_allow = vec![
7935 PathBuf::from("/usr/bin/python3"),
7936 PathBuf::from("/opt/toolchain/bin/cc"),
7937 ];
7938
7939 assert_eq!(
7940 resolve_sealed_bind_dirs(&exec_allow, &policy),
7941 vec![
7942 PathBuf::from("/opt/toolchain/bin"),
7943 PathBuf::from("/opt/py/lib")
7944 ]
7945 );
7946 }
7947
7948 #[test]
7953 fn host_path_execute_roots_include_interpreter_runtime_dirs_and_staged_trees() {
7954 use crate::host_paths::{check_execute_roots, HostPath, HostPathAccess, HostPathMechanism};
7955 let policy = CapabilityPolicy {
7956 shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
7957 binary: "python3".to_string(),
7958 dirs: vec![murmur_artifact::InterpreterRuntimeDir {
7959 path: "/nonexistent-host-path-test/interp".to_string(),
7960 list_dir: true,
7961 }],
7962 }],
7963 shell_staged_runtime: vec![murmur_artifact::StagedRuntimeGrant {
7964 binary: "python3".to_string(),
7965 source_path: "/nonexistent-host-path-test/opt/py".to_string(),
7966 pin: "pin-1".to_string(),
7967 }],
7968 ..CapabilityPolicy::default()
7969 };
7970 let roots = execute_roots_from(&[], &policy);
7971 let dirs: Vec<&Path> = roots.iter().map(|root| root.dir.as_path()).collect();
7972 assert_eq!(
7973 dirs,
7974 [
7975 Path::new("/nonexistent-host-path-test/interp"),
7976 Path::new("/nonexistent-host-path-test/opt/py"),
7977 ]
7978 );
7979 let read_write = |path: &str| HostPath {
7980 declared: path.to_string(),
7981 path: PathBuf::from(path),
7982 access: HostPathAccess::ReadWrite,
7983 credential_locations: Vec::new(),
7984 };
7985
7986 let inside_interpreter = [read_write("/nonexistent-host-path-test/interp/cache")];
7987 for mechanism in [HostPathMechanism::Landlock, HostPathMechanism::Bind] {
7988 assert!(check_execute_roots(&inside_interpreter, &roots, mechanism).is_err());
7989 }
7990
7991 let holding_staged = [read_write("/nonexistent-host-path-test/opt")];
7992 assert!(check_execute_roots(&holding_staged, &roots, HostPathMechanism::Landlock).is_err());
7993 assert_eq!(
7994 check_execute_roots(&holding_staged, &roots, HostPathMechanism::Bind).unwrap(),
7995 [PathBuf::from("/nonexistent-host-path-test/opt/py")]
7996 );
7997 }
7998
7999 #[test]
8004 fn staged_runtime_dirs_keep_fixed_path_overlaps_and_drop_only_duplicates_and_relatives() {
8005 use murmur_artifact::StagedRuntimeGrant;
8006
8007 let grant = |binary: &str, source: &str| StagedRuntimeGrant {
8008 binary: binary.to_string(),
8009 source_path: source.to_string(),
8010 pin: "pin-1".to_string(),
8011 };
8012 let policy = CapabilityPolicy {
8013 shell_staged_runtime: vec![
8014 grant("python3", "/opt/py"),
8015 grant("perl", "/usr/lib/perl5"),
8018 grant("python3.12", "/opt/py"),
8020 grant("ruby", "relative/ruby"),
8021 ],
8022 ..CapabilityPolicy::default()
8023 };
8024
8025 assert_eq!(
8026 resolve_staged_runtime_dirs(&policy),
8027 vec![PathBuf::from("/opt/py"), PathBuf::from("/usr/lib/perl5")]
8028 );
8029 }
8030
8031 #[test]
8035 fn every_staged_runtime_dir_also_gets_a_listable_landlock_grant() {
8036 use murmur_artifact::StagedRuntimeGrant;
8037
8038 let policy = CapabilityPolicy {
8039 shell_staged_runtime: vec![StagedRuntimeGrant {
8040 binary: "python3".to_string(),
8041 source_path: "/opt/py".to_string(),
8042 pin: "pin-1".to_string(),
8043 }],
8044 ..CapabilityPolicy::default()
8045 };
8046
8047 let dirs = resolve_staged_runtime_dirs(&policy);
8048 let grants = resolve_staged_runtime_landlock_grants(&policy);
8049
8050 assert_eq!(
8051 grants,
8052 vec![LandlockGrant {
8053 path: PathBuf::from("/opt/py"),
8054 list_dir: true,
8055 executable: true,
8056 }],
8057 "a staged tree is walked by the runtime that uses it, so it must be listable",
8058 );
8059 assert_eq!(
8060 grants
8061 .iter()
8062 .map(|grant| grant.path.clone())
8063 .collect::<Vec<_>>(),
8064 dirs,
8065 "the bound set and the granted set must not drift apart",
8066 );
8067 }
8068
8069 #[test]
8070 fn every_sealed_runtime_path_gets_a_listable_non_executable_landlock_grant() {
8071 let grants = resolve_sealed_runtime_landlock_grants(EnforcementTier::KernelSealed);
8072
8073 assert_eq!(
8074 grants,
8075 crate::sealed::SEALED_RUNTIME_PATHS
8076 .iter()
8077 .map(|path| LandlockGrant {
8078 path: PathBuf::from(path),
8079 list_dir: true,
8080 executable: false,
8081 })
8082 .collect::<Vec<_>>(),
8083 "the composed root binds exactly these, so exactly these must be enumerable — \
8084 the bound set and the granted set must not drift apart",
8085 );
8086 assert!(
8087 grants.iter().all(|grant| grant.list_dir),
8088 "a runtime tree an interpreter walks is useless without getdents64",
8089 );
8090 assert!(
8091 grants.iter().all(|grant| !grant.executable),
8092 "Execute is the exec allowlist: granting it over /usr, /bin and /sbin would let a \
8093 sealed capsule run every binary the host ships, whatever shell.allow says",
8094 );
8095 }
8096
8097 #[test]
8098 fn sealed_runtime_landlock_grants_are_sealed_tier_only() {
8099 for tier in [
8100 EnforcementTier::KernelFull,
8101 EnforcementTier::KernelSeccompOnly,
8102 EnforcementTier::EnvironmentOnly,
8103 ] {
8104 assert!(
8105 resolve_sealed_runtime_landlock_grants(tier).is_empty(),
8106 "{tier:?} has no composed root, so granting ReadDir on SEALED_RUNTIME_PATHS \
8107 would enumerate the real host filesystem",
8108 );
8109 }
8110 }
8111
8112 #[test]
8117 fn shell_enforcement_grants_sealed_runtime_paths_only_when_sealed_applies() {
8118 let policy = CapabilityPolicy::default();
8119 let usr = PathBuf::from("/usr");
8120
8121 let scoped = ShellEnforcement::resolve_declared(
8122 &policy,
8123 murmur_artifact::ContainmentClass::Scoped,
8124 HostProbe::probe(),
8125 )
8126 .expect("an empty policy resolves")
8127 .with_test_session_dirs();
8128 assert!(
8129 !scoped.landlock_grants.iter().any(|grant| grant.path == usr),
8130 "a scoped capsule runs Landlock over the real host filesystem — /usr must stay \
8131 unenumerable there",
8132 );
8133
8134 let sealed = ShellEnforcement::resolve_declared(
8135 &policy,
8136 murmur_artifact::ContainmentClass::Sealed,
8137 HostProbe::probe(),
8138 )
8139 .expect("an empty policy resolves")
8140 .with_test_session_dirs();
8141 let granted: Vec<&LandlockGrant> = sealed
8142 .landlock_grants
8143 .iter()
8144 .filter(|grant| {
8145 crate::sealed::SEALED_RUNTIME_PATHS.contains(&grant.path.to_str().unwrap_or(""))
8146 })
8147 .collect();
8148 if sealed.tier == EnforcementTier::KernelSealed {
8149 assert_eq!(
8150 granted.len(),
8151 crate::sealed::SEALED_RUNTIME_PATHS.len(),
8152 "a sealed session must carry one listable grant per bound runtime path",
8153 );
8154 assert!(granted
8155 .iter()
8156 .all(|grant| grant.list_dir && !grant.executable));
8157 } else {
8158 assert!(
8159 granted.is_empty(),
8160 "this host fell back to {:?}, where there is no composed root to enumerate",
8161 sealed.tier,
8162 );
8163 }
8164 }
8165
8166 #[test]
8167 fn every_sealed_etc_path_gets_a_non_executable_landlock_grant() {
8168 let grants = resolve_sealed_etc_landlock_grants(EnforcementTier::KernelSealed);
8169
8170 assert_eq!(
8171 grants,
8172 crate::sealed::SEALED_ETC_PATHS
8173 .iter()
8174 .map(|entry| LandlockGrant {
8175 path: PathBuf::from(entry.path),
8176 list_dir: entry.directory,
8177 executable: false,
8178 })
8179 .collect::<Vec<_>>(),
8180 "the composed root binds exactly these /etc entries, so exactly these must be \
8181 readable — the bound set and the granted set must not drift apart",
8182 );
8183 assert!(
8184 grants.iter().all(|grant| !grant.executable),
8185 "Execute is the exec allowlist, and nothing in /etc is a program a manifest asked to \
8186 run — /etc/alternatives is a directory of symlinks into /usr/bin",
8187 );
8188
8189 let listable: Vec<&str> = grants
8193 .iter()
8194 .filter(|grant| grant.list_dir)
8195 .map(|grant| grant.path.to_str().unwrap())
8196 .collect();
8197 assert_eq!(
8198 listable,
8199 vec![
8200 "/etc/ld.so.conf.d",
8201 "/etc/alternatives",
8202 "/etc/ssl",
8203 "/etc/pki",
8204 "/etc/ca-certificates",
8205 "/etc/terminfo",
8206 ],
8207 "ReadDir belongs to exactly the directory entries: os.listdir('/etc/ssl/certs') and \
8208 the c_rehash symlink lookup need it, and a regular file has no meaningful ReadDir",
8209 );
8210 }
8211
8212 #[test]
8213 fn sealed_etc_landlock_grants_are_sealed_tier_only() {
8214 for tier in [
8215 EnforcementTier::KernelFull,
8216 EnforcementTier::KernelSeccompOnly,
8217 EnforcementTier::EnvironmentOnly,
8218 ] {
8219 assert!(
8220 resolve_sealed_etc_landlock_grants(tier).is_empty(),
8221 "{tier:?} has no composed root, so these rules would hand out the real host's \
8222 /etc — its trust store, resolv.conf and account databases",
8223 );
8224 }
8225 }
8226
8227 #[test]
8232 fn shell_enforcement_grants_sealed_etc_paths_only_when_sealed_applies() {
8233 let policy = CapabilityPolicy::default();
8234 let is_etc_grant = |grant: &LandlockGrant| {
8235 crate::sealed::SEALED_ETC_PATHS
8236 .iter()
8237 .any(|entry| Path::new(entry.path) == grant.path)
8238 };
8239
8240 let scoped = ShellEnforcement::resolve_declared(
8241 &policy,
8242 murmur_artifact::ContainmentClass::Scoped,
8243 HostProbe::probe(),
8244 )
8245 .expect("an empty policy resolves")
8246 .with_test_session_dirs();
8247 let name_resolution = |grant: &LandlockGrant| {
8250 SCOPED_NAME_RESOLUTION_PATHS
8251 .iter()
8252 .any(|path| Path::new(path) == grant.path)
8253 };
8254 assert!(
8255 !scoped
8256 .landlock_grants
8257 .iter()
8258 .any(|grant| is_etc_grant(grant) && !name_resolution(grant)),
8259 "a scoped capsule runs Landlock over the real host filesystem — /etc/shadow's \
8260 neighbours must not become readable there",
8261 );
8262 assert_eq!(
8263 scoped
8264 .landlock_grants
8265 .iter()
8266 .filter(|grant| name_resolution(grant))
8267 .count(),
8268 SCOPED_NAME_RESOLUTION_PATHS.len()
8269 );
8270
8271 let sealed = ShellEnforcement::resolve_declared(
8272 &policy,
8273 murmur_artifact::ContainmentClass::Sealed,
8274 HostProbe::probe(),
8275 )
8276 .expect("an empty policy resolves")
8277 .with_test_session_dirs();
8278 let granted: Vec<&LandlockGrant> = sealed
8279 .landlock_grants
8280 .iter()
8281 .filter(|grant| is_etc_grant(grant))
8282 .collect();
8283 if sealed.tier == EnforcementTier::KernelSealed {
8284 assert_eq!(
8285 granted.len(),
8286 crate::sealed::SEALED_ETC_PATHS.len(),
8287 "a sealed session must carry one read grant per bound /etc entry",
8288 );
8289 assert!(granted.iter().all(|grant| !grant.executable));
8290 } else {
8291 assert!(
8294 granted
8295 .iter()
8296 .all(|grant| name_resolution(grant) && !grant.executable && !grant.list_dir),
8297 "this host fell back to {:?}, where there is no composed root and /etc is the \
8298 host's own: {granted:?}",
8299 sealed.tier,
8300 );
8301 assert_eq!(
8302 granted.len(),
8303 resolve_scoped_name_resolution_landlock_grants(sealed.tier).len(),
8304 "{:?}: {granted:?}",
8305 sealed.tier,
8306 );
8307 }
8308 }
8309
8310 #[test]
8311 fn resolve_network_allowlist_ips_empty_list_is_empty() {
8312 let ips = resolve_network_allowlist_ips(&[]).unwrap();
8313 assert!(ips.is_empty());
8314 }
8315
8316 #[test]
8317 fn resolve_network_allowlist_ips_resolves_localhost_to_loopback() {
8318 let ips = resolve_network_allowlist_ips(&["localhost".to_string()]).unwrap();
8319 assert!(
8320 !ips.is_empty(),
8321 "localhost should resolve to at least one address"
8322 );
8323 assert!(
8324 ips.iter().all(|ip| ip.is_loopback()),
8325 "every resolved localhost address should be loopback, got {ips:?}"
8326 );
8327 }
8328
8329 #[test]
8330 fn resolve_network_allowlist_ips_rejects_invalid_host_syntax_cleanly() {
8331 let error = resolve_network_allowlist_ips(&["".to_string()]).unwrap_err();
8332 assert!(!error.is_empty());
8333 }
8334
8335 #[test]
8340 fn resolve_network_allowlist_ips_skips_unresolvable_host_without_failing() {
8341 let ips =
8342 resolve_network_allowlist_ips(&["definitely-not-a-real-host.invalid".to_string()])
8343 .expect("an unresolvable host must not be a hard error");
8344 assert!(
8345 ips.is_empty(),
8346 "an unresolvable host should contribute no IPs, got {ips:?}"
8347 );
8348 }
8349
8350 #[test]
8353 fn resolve_network_allowlist_ips_keeps_resolvable_hosts_when_another_is_unresolvable() {
8354 let ips = resolve_network_allowlist_ips(&[
8355 "definitely-not-a-real-host.invalid".to_string(),
8356 "localhost".to_string(),
8357 ])
8358 .expect("a mix of resolvable and unresolvable hosts must not be a hard error");
8359
8360 assert!(
8361 !ips.is_empty(),
8362 "localhost's addresses should survive alongside an unresolvable host"
8363 );
8364 assert!(
8365 ips.iter().all(|ip| ip.is_loopback()),
8366 "only localhost should have contributed addresses, got {ips:?}"
8367 );
8368 }
8369
8370 #[test]
8371 fn resolve_network_allowlist_ips_dedupes_across_hosts() {
8372 let ips =
8375 resolve_network_allowlist_ips(&["localhost".to_string(), "localhost:8080".to_string()])
8376 .unwrap();
8377 let mut sorted = ips.clone();
8378 sorted.sort();
8379 sorted.dedup();
8380 assert_eq!(
8381 ips.len(),
8382 sorted.len(),
8383 "resolved IPs should already be deduplicated"
8384 );
8385 }
8386
8387 #[test]
8388 fn environment_only_has_no_resolved_ips_and_environment_only_tier() {
8389 let enforcement = ShellEnforcement::environment_only();
8390 assert_eq!(enforcement.tier, EnforcementTier::EnvironmentOnly);
8391 assert!(enforcement.network_allow_ips.is_empty());
8392 }
8393
8394 #[cfg(any(target_os = "linux", target_os = "macos"))]
8398 #[test]
8399 fn nproc_bound_without_a_scope_is_headroom_over_a_live_count() {
8400 match ShellEnforcement::environment_only()
8401 .with_test_session_dirs()
8402 .nproc_bound()
8403 {
8404 crate::resources::NprocBound::Headroom { baseline } => assert!(
8405 baseline > 0,
8406 "the test process is owned by this uid, so the count cannot be zero"
8407 ),
8408 other => panic!("no scope must give headroom, got {other:?}"),
8409 }
8410 }
8411
8412 #[cfg(target_os = "linux")]
8415 #[test]
8416 fn nproc_bound_without_a_scope_is_measured_on_each_call() {
8417 let _lock = crate::resources::test_support::host_threads_lock();
8418 let enforcement = ShellEnforcement::environment_only().with_test_session_dirs();
8419 let baseline = |bound| match bound {
8420 crate::resources::NprocBound::Headroom { baseline } => baseline,
8421 other => panic!("no scope must give headroom, got {other:?}"),
8422 };
8423
8424 let first = baseline(enforcement.nproc_bound());
8425 let holders = crate::resources::test_support::HeldThreads::start(200);
8426 let second = baseline(enforcement.nproc_bound());
8427 drop(holders);
8428
8429 assert!(
8430 second > first,
8431 "200 threads started between the calls must raise the baseline: {first} then {second}"
8432 );
8433 }
8434
8435 #[cfg(target_os = "linux")]
8437 #[test]
8438 fn nproc_bound_with_a_scope_leaves_rlimit_nproc_to_pids_max() {
8439 if !crate::cgroup::cgroup_delegation_available() {
8440 crate::runtime_err!(
8441 "[SKIP-HOST] nproc_bound_with_a_scope_leaves_rlimit_nproc_to_pids_max: this host \
8442 cannot delegate a cgroup v2 scope"
8443 );
8444 return;
8445 }
8446 let temp = tempfile::tempdir().unwrap();
8447 let limits = crate::resources::HostResourceLimits::default();
8448 let scope = crate::cgroup::prepare_scope(true, &limits, "ses_nproc_bound", temp.path())
8449 .expect("this host delegates a cgroup scope")
8450 .scope;
8451 let enforcement = ShellEnforcement::environment_only()
8452 .with_test_session_dirs()
8453 .with_host_bounding(scope, None);
8454
8455 assert_eq!(
8456 enforcement.nproc_bound(),
8457 crate::resources::NprocBound::CgroupPidsMax
8458 );
8459 }
8460
8461 #[cfg(target_os = "linux")]
8468 #[test]
8469 fn a_scoped_capsule_keeps_spawning_after_the_host_adds_threads() {
8470 const TEST: &str = "a_scoped_capsule_keeps_spawning_after_the_host_adds_threads";
8471 if !crate::cgroup::cgroup_delegation_available() {
8472 crate::runtime_err!("[SKIP-HOST] {TEST}: this host cannot delegate a cgroup v2 scope");
8473 return;
8474 }
8475 let _lock = crate::resources::test_support::host_threads_lock();
8476
8477 let temp = tempfile::tempdir().unwrap();
8478 let limits = crate::resources::HostResourceLimits {
8479 max_processes: 32,
8480 max_open_files: 256,
8481 ..crate::resources::HostResourceLimits::default()
8482 };
8483 let scope = crate::cgroup::prepare_scope(true, &limits, "ses_nproc_race", temp.path())
8484 .expect("this host delegates a cgroup scope")
8485 .scope;
8486 assert!(
8487 scope.is_some(),
8488 "a required scope on Linux is always created"
8489 );
8490 let node_available = crate::sandbox::resolve_invoked_binary_path("node") != "node";
8491 let mut shell_allow = vec!["bash".to_string()];
8492 if node_available {
8493 shell_allow.push("node".to_string());
8494 }
8495 let policy = CapabilityPolicy {
8496 shell_allow,
8497 resources: limits,
8498 ..CapabilityPolicy::default()
8499 };
8500 let mut enforcement = ShellEnforcement::environment_only().with_test_session_dirs();
8501 enforcement.resource_limits = limits;
8502 let enforcement = enforcement.with_host_bounding(scope, None);
8503
8504 let holders = crate::resources::test_support::HeldThreads::start(400);
8505
8506 let result = crate::shell::execute_shell(
8507 "bash",
8508 &[
8509 "-c",
8510 "for i in $(seq 1 40); do sleep 0.2 & done; wait; ulimit -Hu; ulimit -Hn",
8511 ],
8512 &[],
8513 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
8514 &policy,
8515 &enforcement,
8516 )
8517 .expect("bash must run");
8518 assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
8519 assert_eq!(result.resource_limit_hit, None);
8520 let (_, own_hard) = crate::resources::test_support::own_rlimit_nproc();
8521 let lines: Vec<&str> = result.stdout.lines().collect();
8522 assert_eq!(
8523 lines,
8524 [
8525 crate::resources::test_support::rendered_rlimit(own_hard).as_str(),
8526 "256"
8527 ],
8528 "RLIMIT_NPROC is inherited unchanged and RLIMIT_NOFILE is the declared ceiling",
8529 );
8530
8531 if node_available {
8532 let script = "const { spawn } = require('child_process');\n\
8533 const runs = Array.from({ length: 8 }, () => new Promise((resolve) => {\n\
8534 const child = spawn(process.execPath, ['-e', 'process.exit(0)']);\n\
8535 child.once('error', (error) => resolve(error.message));\n\
8536 child.once('exit', (code) => resolve(code === 0 ? null : `exit ${code}`));\n\
8537 }));\n\
8538 Promise.all(runs).then((outcomes) => {\n\
8539 const failures = outcomes.filter((outcome) => outcome !== null);\n\
8540 if (failures.length > 0) { console.error(failures.join('\\n')); process.exit(1); }\n\
8541 });\n";
8542 let result = crate::shell::execute_shell(
8543 "node",
8544 &["-e", script],
8545 &[],
8546 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
8547 &policy,
8548 &enforcement,
8549 )
8550 .expect("node must run");
8551 assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
8552 } else {
8553 crate::runtime_err!("[SKIP-HOST] {TEST}: no node on PATH, skipping the node half");
8554 }
8555
8556 drop(holders);
8557 }
8558
8559 #[cfg(target_os = "linux")]
8563 #[test]
8564 fn without_a_scope_the_nproc_baseline_is_measured_per_spawn() {
8565 const TEST: &str = "without_a_scope_the_nproc_baseline_is_measured_per_spawn";
8566 let _lock = crate::resources::test_support::host_threads_lock();
8567
8568 let temp = tempfile::tempdir().unwrap();
8569 let policy = CapabilityPolicy {
8570 shell_allow: vec!["bash".to_string()],
8571 resources: crate::resources::HostResourceLimits {
8572 max_processes: 64,
8573 ..crate::resources::HostResourceLimits::default()
8574 },
8575 ..CapabilityPolicy::default()
8576 };
8577 let mut enforcement = ShellEnforcement::environment_only().with_test_session_dirs();
8578 enforcement.resource_limits = policy.resources;
8579
8580 let before = crate::resources::uid_task_count().expect("Linux can count the uid's tasks");
8581 let (_, own_hard) = crate::resources::test_support::own_rlimit_nproc();
8582 if own_hard != libc::RLIM_INFINITY && own_hard < before + 664 {
8583 crate::runtime_err!(
8584 "[SKIP-HOST] {TEST}: the inherited hard RLIMIT_NPROC is {own_hard}, too close to \
8585 the uid's {before} tasks for 300 more threads to show in a child's ceiling"
8586 );
8587 return;
8588 }
8589
8590 let hard_nproc = || -> u64 {
8591 let result = crate::shell::execute_shell(
8592 "bash",
8593 &["-c", "ulimit -Hu"],
8594 &[],
8595 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
8596 &policy,
8597 &enforcement,
8598 )
8599 .expect("bash must run");
8600 assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
8601 result.stdout.trim().parse().expect("a finite ceiling")
8602 };
8603
8604 let holders = crate::resources::test_support::HeldThreads::start(300);
8605 let held = hard_nproc();
8606 assert!(
8607 held >= before + 64 + 150,
8608 "the ceiling must count the holder threads started after the enforcement was \
8609 resolved: {held} with {before} tasks before them"
8610 );
8611
8612 drop(holders);
8613 let released = hard_nproc();
8614 assert!(
8615 released < held,
8616 "the ceiling must drop once the holders exit: {released} after, {held} while held"
8617 );
8618 }
8619
8620 fn make_executable(path: &Path, contents: &str) {
8621 use std::os::unix::fs::PermissionsExt;
8622 std::fs::write(path, contents).unwrap();
8623 let mut perms = std::fs::metadata(path).unwrap().permissions();
8624 perms.set_mode(0o755);
8625 std::fs::set_permissions(path, perms).unwrap();
8626 }
8627
8628 fn bootstrap_log_contents(workdir: &Path) -> String {
8629 std::fs::read_to_string(workdir.join("logs").join("bootstrap.log")).unwrap_or_default()
8630 }
8631
8632 #[test]
8635 fn resolve_exec_allowlist_resolves_bare_name_via_path_dirs() {
8636 let temp = tempfile::tempdir().unwrap();
8637 let bin_dir = temp.path().join("bin");
8638 std::fs::create_dir(&bin_dir).unwrap();
8639 let tool = bin_dir.join("mytool");
8640 make_executable(&tool, "#!/bin/sh\nexit 0\n");
8641
8642 let resolved = resolve_exec_allowlist_in(&["mytool".to_string()], &[bin_dir]);
8643 assert_eq!(resolved, vec![std::fs::canonicalize(&tool).unwrap()]);
8644 }
8645
8646 #[test]
8647 fn resolve_exec_allowlist_skips_entries_that_do_not_resolve() {
8648 let temp = tempfile::tempdir().unwrap();
8649 let resolved = resolve_exec_allowlist_in(
8650 &["definitely-not-a-real-binary".to_string(), String::new()],
8651 &[temp.path().to_path_buf()],
8652 );
8653 assert!(
8654 resolved.is_empty(),
8655 "unresolvable entries must shrink (not fail) the allow set: {resolved:?}"
8656 );
8657 }
8658
8659 #[test]
8660 fn resolve_exec_allowlist_ignores_non_executable_path_matches() {
8661 let temp = tempfile::tempdir().unwrap();
8662 std::fs::write(temp.path().join("mytool"), "not executable").unwrap();
8663 let resolved =
8664 resolve_exec_allowlist_in(&["mytool".to_string()], &[temp.path().to_path_buf()]);
8665 assert!(resolved.is_empty());
8666 }
8667
8668 #[test]
8669 fn resolve_exec_allowlist_canonicalizes_path_matches_through_symlinks() {
8670 let temp = tempfile::tempdir().unwrap();
8671 let real = temp.path().join("real-tool");
8672 make_executable(&real, "#!/bin/sh\nexit 0\n");
8673 let bin_dir = temp.path().join("bin");
8674 std::fs::create_dir(&bin_dir).unwrap();
8675 std::os::unix::fs::symlink(&real, bin_dir.join("link-tool")).unwrap();
8676
8677 let resolved = resolve_exec_allowlist_in(&["link-tool".to_string()], &[bin_dir]);
8678 assert_eq!(resolved, vec![std::fs::canonicalize(&real).unwrap()]);
8679 }
8680
8681 #[test]
8684 fn resolve_invoked_binary_path_resolves_bare_name_via_path_dirs() {
8685 let temp = tempfile::tempdir().unwrap();
8686 let bin_dir = temp.path().join("bin");
8687 std::fs::create_dir(&bin_dir).unwrap();
8688 let tool = bin_dir.join("mytool");
8689 make_executable(&tool, "#!/bin/sh\nexit 0\n");
8690
8691 let resolved = resolve_invoked_binary_path_in("mytool", &[bin_dir]);
8692 assert_eq!(
8693 resolved,
8694 std::fs::canonicalize(&tool)
8695 .unwrap()
8696 .to_string_lossy()
8697 .into_owned(),
8698 "a resolvable name must report the canonical absolute path, not the bare name"
8699 );
8700 }
8701
8702 #[test]
8703 fn resolve_invoked_binary_path_falls_back_to_bare_name_when_unresolvable() {
8704 let temp = tempfile::tempdir().unwrap();
8705 let path_dirs = [temp.path().to_path_buf()];
8706 let resolved = resolve_invoked_binary_path_in("definitely-not-a-real-binary", &path_dirs);
8707 assert_eq!(
8708 resolved, "definitely-not-a-real-binary",
8709 "an unresolvable name must fall back to the bare name, never error"
8710 );
8711 assert_eq!(resolve_invoked_binary_path_in("", &[]), "");
8712 }
8713
8714 #[test]
8715 fn resolve_invoked_binary_path_takes_the_first_path_match() {
8716 let temp = tempfile::tempdir().unwrap();
8717 let first = temp.path().join("first");
8718 let second = temp.path().join("second");
8719 std::fs::create_dir(&first).unwrap();
8720 std::fs::create_dir(&second).unwrap();
8721 make_executable(&first.join("mytool"), "#!/bin/sh\nexit 0\n");
8722 make_executable(&second.join("mytool"), "#!/bin/sh\nexit 1\n");
8723
8724 let resolved = resolve_invoked_binary_path_in("mytool", &[first.clone(), second]);
8725 assert_eq!(
8726 resolved,
8727 std::fs::canonicalize(first.join("mytool"))
8728 .unwrap()
8729 .to_string_lossy()
8730 .into_owned(),
8731 "reporting must match execvp: the first PATH hit is the one that ran"
8732 );
8733 }
8734
8735 #[test]
8736 fn resolve_invoked_binary_path_ignores_non_executable_path_matches() {
8737 let temp = tempfile::tempdir().unwrap();
8738 std::fs::write(temp.path().join("mytool"), "not executable").unwrap();
8739 assert_eq!(
8740 resolve_invoked_binary_path_in("mytool", &[temp.path().to_path_buf()]),
8741 "mytool"
8742 );
8743 }
8744
8745 #[test]
8746 fn resolve_invoked_binary_path_canonicalizes_a_path_entry_through_symlinks() {
8747 let temp = tempfile::tempdir().unwrap();
8748 let real = temp.path().join("real-tool");
8749 make_executable(&real, "#!/bin/sh\nexit 0\n");
8750 let link = temp.path().join("link-tool");
8751 std::os::unix::fs::symlink(&real, &link).unwrap();
8752
8753 assert_eq!(
8754 resolve_invoked_binary_path_in(&link.to_string_lossy(), &[]),
8755 std::fs::canonicalize(&real)
8756 .unwrap()
8757 .to_string_lossy()
8758 .into_owned()
8759 );
8760 }
8761
8762 #[test]
8765 fn parse_elf_extracts_interp_needed_and_runpath() {
8766 let bytes = build_elf64(
8767 Some("/lib64/ld-linux-x86-64.so.2"),
8768 &["libc.so.6", "libm.so.6"],
8769 Some("$ORIGIN/../lib:/opt/custom/lib"),
8770 );
8771 let deps = parse_elf_dependencies(bytes.as_slice()).expect("well-formed ELF64 must parse");
8772 assert_eq!(deps.interp.as_deref(), Some("/lib64/ld-linux-x86-64.so.2"));
8773 assert_eq!(
8774 deps.needed,
8775 vec!["libc.so.6".to_string(), "libm.so.6".to_string()]
8776 );
8777 assert_eq!(
8778 deps.runpaths,
8779 vec!["$ORIGIN/../lib".to_string(), "/opt/custom/lib".to_string()],
8780 "DT_RUNPATH must be split on ':' with empty parts dropped"
8781 );
8782 }
8783
8784 #[test]
8785 fn parse_elf_static_binary_contributes_only_itself() {
8786 let bytes = build_elf64(None, &[], None);
8788 let deps =
8789 parse_elf_dependencies(bytes.as_slice()).expect("a static ELF64 is still valid ELF");
8790 assert_eq!(deps, ElfDependencies::default());
8791 }
8792
8793 #[test]
8794 fn parse_elf_rejects_non_elf_and_non_elf64() {
8795 assert!(parse_elf_dependencies(&b"not an elf at all, way too short"[..]).is_none());
8796 let mut elf32 = build_elf64(Some("/lib/ld.so"), &["libc.so.6"], None);
8798 elf32[4] = 1;
8799 assert!(
8800 parse_elf_dependencies(elf32.as_slice()).is_none(),
8801 "32-bit ELF is not a binary this runtime execs and must not parse"
8802 );
8803 }
8804
8805 #[cfg(target_os = "linux")]
8809 #[test]
8810 #[allow(clippy::print_stdout)]
8811 fn a_file_read_by_position_parses_exactly_as_its_bytes_in_memory() {
8812 let mut binaries = Vec::new();
8813 for name in ["python3", "perl", "bash", "git"] {
8814 match find_on_path(name) {
8815 Some(path) => binaries.push(std::fs::canonicalize(path).unwrap()),
8816 None => println!("[SKIP] no {name} on PATH"),
8817 }
8818 }
8819 let mut images: std::collections::BTreeSet<PathBuf> =
8820 resolve_landlock_grants(&binaries).into_iter().collect();
8821 let dynload = std::process::Command::new("python3")
8822 .args(["-I", "-S", "-c", "import sys; print('\\n'.join(sys.path))"])
8823 .output()
8824 .ok()
8825 .map(|output| String::from_utf8_lossy(&output.stdout).into_owned())
8826 .unwrap_or_default();
8827 for dir in dynload.lines().filter(|line| line.ends_with("lib-dynload")) {
8828 for entry in std::fs::read_dir(dir).into_iter().flatten().flatten() {
8829 images.insert(entry.path());
8830 }
8831 }
8832 let compare = |path: &Path| {
8833 let Ok(bytes) = std::fs::read(path) else {
8834 return;
8835 };
8836 let file = ElfFile::open_host(path).unwrap();
8837 assert_eq!(
8838 parse_elf_dependencies(bytes.as_slice()),
8839 parse_elf_dependencies(&file),
8840 "{}",
8841 path.display()
8842 );
8843 assert_eq!(
8844 elf_dynamic_symbol_names(bytes.as_slice()),
8845 elf_dynamic_symbol_names(&file),
8846 "{}",
8847 path.display()
8848 );
8849 assert_eq!(
8850 elf_is_shared_object(bytes.as_slice()),
8851 elf_is_shared_object(&file),
8852 "{}",
8853 path.display()
8854 );
8855 };
8856 for path in &images {
8857 compare(path);
8858 }
8859 println!("compared {} host images", images.len());
8860
8861 let temp = tempfile::tempdir().unwrap();
8864 let mut variants = 0;
8865 for binary in &binaries {
8866 let bytes = std::fs::read(binary).unwrap();
8867 let mut cuts = vec![
8868 0,
8869 1,
8870 17,
8871 18,
8872 63,
8873 64,
8874 100,
8875 4096,
8876 bytes.len() / 2,
8877 bytes.len() - 1,
8878 ];
8879 for field in [32usize, 40] {
8880 if let Some(offset) = read_u64(&bytes, field, true) {
8881 cuts.push(offset as usize + 30);
8882 }
8883 }
8884 let mut copies: Vec<Vec<u8>> = cuts
8885 .into_iter()
8886 .filter(|cut| *cut <= bytes.len())
8887 .map(|cut| bytes[..cut].to_vec())
8888 .collect();
8889 for field in [32usize, 40] {
8890 let mut moved = bytes.clone();
8891 moved[field..field + 8].copy_from_slice(&(bytes.len() as u64 - 8).to_le_bytes());
8892 copies.push(moved);
8893 }
8894 let mut big_endian = bytes.clone();
8895 big_endian[5] = 2;
8896 copies.push(big_endian);
8897 for copy in copies {
8898 let path = temp.path().join(format!("variant-{variants}"));
8899 std::fs::write(&path, ©).unwrap();
8900 compare(&path);
8901 variants += 1;
8902 }
8903 }
8904 println!("compared {variants} malformed images");
8905 }
8906
8907 #[cfg(target_os = "linux")]
8910 #[test]
8911 #[allow(clippy::print_stdout)]
8912 fn resolving_a_closure_reads_no_binary_in_full() {
8913 let (_temp, root) = crate::interpreter_runtime::tests::canonical_tempdir();
8914 let Some(stand_in) = crate::interpreter_runtime::tests::dash_copy(&root, "padded") else {
8915 return;
8916 };
8917 crate::interpreter_runtime::tests::pad_with_zeros(&stand_in, 64 << 20);
8918 let unpadded = resolve_landlock_grants(&[std::fs::canonicalize("/bin/dash").unwrap()]);
8919 let before = crate::interpreter_runtime::tests::thread_rchar();
8920 let grants = resolve_landlock_grants(std::slice::from_ref(&stand_in));
8921 let read = crate::interpreter_runtime::tests::thread_rchar() - before;
8922 assert_eq!(grants.len(), unpadded.len(), "{grants:?}");
8923 assert!(grants.contains(&stand_in));
8924 assert!(
8925 read < 8 * 1024 * 1024,
8926 "resolving the closure read {read} bytes"
8927 );
8928 println!("resolving the padded stand-in's closure read {read} bytes");
8929 }
8930
8931 #[test]
8934 fn resolve_soname_finds_via_search_dirs_and_runpath_origin() {
8935 let temp = tempfile::tempdir().unwrap();
8936 let origin = temp.path().join("app");
8937 std::fs::create_dir(&origin).unwrap();
8938 let libs = temp.path().join("libs");
8939 std::fs::create_dir(&libs).unwrap();
8940 std::fs::write(libs.join("libc.so.6"), "x").unwrap();
8941
8942 assert_eq!(
8944 resolve_soname("libc.so.6", &origin, &[], std::slice::from_ref(&libs)),
8945 Some(std::fs::canonicalize(libs.join("libc.so.6")).unwrap())
8946 );
8947 assert_eq!(
8949 resolve_soname("libmissing.so", &origin, &[], std::slice::from_ref(&libs)),
8950 None
8951 );
8952
8953 let origin_lib = origin.join("lib");
8955 std::fs::create_dir(&origin_lib).unwrap();
8956 std::fs::write(origin_lib.join("libfoo.so"), "x").unwrap();
8957 assert_eq!(
8958 resolve_soname("libfoo.so", &origin, &["$ORIGIN/lib".to_string()], &[]),
8959 Some(std::fs::canonicalize(origin_lib.join("libfoo.so")).unwrap())
8960 );
8961 }
8962
8963 #[test]
8966 fn resolve_landlock_grants_derives_interp_and_needed_closure() {
8967 let temp = tempfile::tempdir().unwrap();
8968 let bin_dir = temp.path().join("bin");
8969 let lib_dir = temp.path().join("lib");
8970 std::fs::create_dir(&bin_dir).unwrap();
8971 std::fs::create_dir(&lib_dir).unwrap();
8972
8973 let interp = bin_dir.join("ld-fake.so");
8975 std::fs::write(&interp, "not really elf").unwrap();
8976 std::fs::write(lib_dir.join("libfake.so.1"), "not really elf").unwrap();
8977
8978 let prog = bin_dir.join("prog");
8980 let elf = build_elf64(Some(interp.to_str().unwrap()), &["libfake.so.1"], None);
8981 std::fs::write(&prog, &elf).unwrap();
8982
8983 let grants =
8984 resolve_landlock_grants_in(std::slice::from_ref(&prog), std::slice::from_ref(&lib_dir));
8985
8986 let expect = |p: PathBuf| std::fs::canonicalize(p).unwrap();
8987 assert!(
8988 grants.contains(&expect(prog)),
8989 "the binary itself must be granted: {grants:?}"
8990 );
8991 assert!(
8992 grants.contains(&expect(interp)),
8993 "the ELF interpreter must be granted: {grants:?}"
8994 );
8995 assert!(
8996 grants.contains(&expect(lib_dir.join("libfake.so.1"))),
8997 "each resolved DT_NEEDED library must be granted: {grants:?}"
8998 );
8999 }
9000
9001 #[test]
9002 fn resolve_landlock_grants_skips_unresolvable_deps_and_missing_binaries() {
9003 let temp = tempfile::tempdir().unwrap();
9004 let bin_dir = temp.path().join("bin");
9005 std::fs::create_dir(&bin_dir).unwrap();
9006
9007 let prog = bin_dir.join("prog");
9009 std::fs::write(
9010 &prog,
9011 build_elf64(Some("/definitely/not/here.so"), &["libnope.so"], None),
9012 )
9013 .unwrap();
9014 let missing = bin_dir.join("does-not-exist");
9015
9016 let grants =
9017 resolve_landlock_grants_in(&[prog.clone(), missing], std::slice::from_ref(&bin_dir));
9018
9019 assert_eq!(grants, vec![std::fs::canonicalize(&prog).unwrap()]);
9022 }
9023
9024 fn interp_grant(binary: &str, dirs: &[(&str, bool)]) -> InterpreterRuntimeGrant {
9027 InterpreterRuntimeGrant {
9028 binary: binary.to_string(),
9029 dirs: dirs
9030 .iter()
9031 .map(|(path, list_dir)| murmur_artifact::InterpreterRuntimeDir {
9032 path: path.to_string(),
9033 list_dir: *list_dir,
9034 })
9035 .collect(),
9036 }
9037 }
9038
9039 #[test]
9040 fn resolve_interpreter_runtime_grants_copies_list_dir_verbatim() {
9041 let grants = resolve_interpreter_runtime_grants(&[interp_grant(
9044 "python3",
9045 &[
9046 ("/usr/lib/python3.11", true),
9047 ("/usr/lib/python3.11/lib-dynload", false),
9048 ],
9049 )]);
9050
9051 assert_eq!(
9052 grants,
9053 vec![
9054 LandlockGrant {
9055 path: PathBuf::from("/usr/lib/python3.11"),
9056 list_dir: true,
9057 executable: true,
9058 },
9059 LandlockGrant {
9060 path: PathBuf::from("/usr/lib/python3.11/lib-dynload"),
9061 list_dir: false,
9062 executable: true,
9063 },
9064 ]
9065 );
9066 }
9067
9068 #[test]
9069 fn resolve_interpreter_runtime_grants_flattens_multiple_grants_and_needs_no_existing_path() {
9070 let grants = resolve_interpreter_runtime_grants(&[
9073 interp_grant("python3", &[("/opt/py/nonexistent", false)]),
9074 interp_grant("ruby", &[("/opt/rb/also-nonexistent", true)]),
9075 ]);
9076
9077 assert_eq!(
9078 grants,
9079 vec![
9080 LandlockGrant {
9081 path: PathBuf::from("/opt/py/nonexistent"),
9082 list_dir: false,
9083 executable: true,
9084 },
9085 LandlockGrant {
9086 path: PathBuf::from("/opt/rb/also-nonexistent"),
9087 list_dir: true,
9088 executable: true,
9089 },
9090 ]
9091 );
9092 assert!(resolve_interpreter_runtime_grants(&[]).is_empty());
9093 }
9094
9095 #[test]
9096 fn non_listable_files_marks_every_closure_file_non_enumerable() {
9097 let wrapped = LandlockGrant::non_listable_files(vec![
9100 PathBuf::from("/lib/x86_64-linux-gnu/libc.so.6"),
9101 PathBuf::from("/usr/bin/bash"),
9102 ]);
9103 assert!(wrapped.iter().all(|grant| !grant.list_dir), "{wrapped:?}");
9104 assert_eq!(
9105 wrapped.iter().map(|g| g.path.clone()).collect::<Vec<_>>(),
9106 vec![
9107 PathBuf::from("/lib/x86_64-linux-gnu/libc.so.6"),
9108 PathBuf::from("/usr/bin/bash"),
9109 ]
9110 );
9111 }
9112
9113 #[test]
9127 fn capsule_device_grants_are_exactly_three_devices_with_only_dev_null_writable() {
9128 let listed: Vec<(&str, bool)> = CAPSULE_DEVICE_GRANTS
9129 .iter()
9130 .map(|device| (device.path, device.writable))
9131 .collect();
9132 assert_eq!(
9133 listed,
9134 vec![
9135 ("/dev/null", true),
9136 ("/dev/zero", false),
9137 ("/dev/urandom", false),
9138 ],
9139 "the capsule device set is fixed at exactly these three paths and these access \
9140 levels. /dev/null must stay writable — Python's subprocess.DEVNULL opens it O_RDWR \
9141 and a shell `2>/dev/null` redirect opens it O_WRONLY, so a read-only grant breaks \
9142 both. Widening this list is allowed only on demonstrated workload failure, and the \
9143 failure gets recorded alongside the widening (see the manual acceptance procedure on \
9144 the diagnostics reference page)."
9145 );
9146 }
9147
9148 #[test]
9149 fn capsule_device_grants_exclude_the_devices_deliberately_left_out() {
9150 for excluded in [
9155 "/dev/random",
9156 "/dev/full",
9157 "/dev/tty",
9158 "/dev/console",
9159 "/dev/mem",
9160 "/dev/sda",
9161 "/dev",
9162 ] {
9163 assert!(
9164 !CAPSULE_DEVICE_GRANTS
9165 .iter()
9166 .any(|device| device.path == excluded),
9167 "{excluded} must not be in the capsule device set — it is denied by the ordinary \
9168 no-matching-rule path, and adding it here would be the only way to change that"
9169 );
9170 }
9171 }
9172
9173 #[test]
9174 fn capsule_device_grants_name_absolute_paths_under_dev() {
9175 for device in CAPSULE_DEVICE_GRANTS {
9178 assert!(
9179 device.path.starts_with("/dev/"),
9180 "{} must be an absolute path under /dev",
9181 device.path
9182 );
9183 }
9184 }
9185
9186 #[test]
9194 fn denied_socket_domains_denies_unix_by_default() {
9195 let denied = denied_socket_domains(false);
9196 assert!(
9197 denied.contains(&LINUX_AF_UNIX),
9198 "AF_UNIX must be denied when the manifest does not declare \
9199 capabilities.network.unix_sockets — this is the /var/run/docker.sock escape"
9200 );
9201 }
9202
9203 #[test]
9204 fn denied_socket_domains_omits_unix_only_when_explicitly_allowed() {
9205 let denied = denied_socket_domains(true);
9206 assert!(
9207 !denied.contains(&LINUX_AF_UNIX),
9208 "a capsule that declared capabilities.network.unix_sockets: true must get no \
9209 AF_UNIX deny rule at all"
9210 );
9211 }
9212
9213 #[test]
9216 fn denied_socket_domains_always_denies_netlink_and_packet() {
9217 for unix_sockets_allowed in [false, true] {
9218 let denied = denied_socket_domains(unix_sockets_allowed);
9219 assert!(
9220 denied.contains(&LINUX_AF_NETLINK),
9221 "AF_NETLINK must be denied unconditionally (unix_sockets_allowed = \
9222 {unix_sockets_allowed})"
9223 );
9224 assert!(
9225 denied.contains(&LINUX_AF_PACKET),
9226 "AF_PACKET must be denied unconditionally (unix_sockets_allowed = \
9227 {unix_sockets_allowed})"
9228 );
9229 }
9230 }
9231
9232 #[test]
9236 fn denied_socket_domains_never_denies_ip_families() {
9237 for unix_sockets_allowed in [false, true] {
9238 let denied = denied_socket_domains(unix_sockets_allowed);
9239 assert!(!denied.contains(&LINUX_AF_INET_DOMAIN));
9240 assert!(!denied.contains(&LINUX_AF_INET6_DOMAIN));
9241 }
9242 }
9243
9244 #[test]
9247 fn denied_socket_domains_returns_exactly_the_documented_sets() {
9248 assert_eq!(
9249 denied_socket_domains(false),
9250 vec![LINUX_AF_NETLINK, LINUX_AF_PACKET, LINUX_AF_UNIX]
9251 );
9252 assert_eq!(
9253 denied_socket_domains(true),
9254 vec![LINUX_AF_NETLINK, LINUX_AF_PACKET]
9255 );
9256 }
9257
9258 #[cfg(target_os = "linux")]
9264 #[test]
9265 fn linux_socket_domain_constants_match_libc() {
9266 assert_eq!(LINUX_AF_UNIX, libc::AF_UNIX);
9267 assert_eq!(LINUX_AF_NETLINK, libc::AF_NETLINK);
9268 assert_eq!(LINUX_AF_PACKET, libc::AF_PACKET);
9269 assert_eq!(LINUX_AF_INET_DOMAIN, libc::AF_INET);
9270 assert_eq!(LINUX_AF_INET6_DOMAIN, libc::AF_INET6);
9271 }
9272
9273 #[test]
9278 fn socket_domain_allow_and_deny_sets_never_overlap() {
9279 for unix_sockets_allowed in [false, true] {
9280 let denied = denied_socket_domains(unix_sockets_allowed);
9281 for domain in allowed_socket_domains(unix_sockets_allowed) {
9282 assert!(
9283 !denied.contains(&domain),
9284 "socket domain {domain} is both allowed and denied \
9285 (unix_sockets_allowed = {unix_sockets_allowed})"
9286 );
9287 }
9288 }
9289 }
9290
9291 #[test]
9296 fn allowed_socket_domains_takes_unix_back_exactly_when_declared() {
9297 assert_eq!(
9298 allowed_socket_domains(false),
9299 vec![LINUX_AF_INET_DOMAIN, LINUX_AF_INET6_DOMAIN]
9300 );
9301 assert_eq!(
9302 allowed_socket_domains(true),
9303 vec![LINUX_AF_INET_DOMAIN, LINUX_AF_INET6_DOMAIN, LINUX_AF_UNIX]
9304 );
9305 }
9306
9307 #[test]
9315 fn allowlist_contains_no_syscall_that_must_stay_denied() {
9316 for name in SECCOMP_MUST_STAY_DENIED {
9317 assert!(
9318 !SECCOMP_SYSCALL_ALLOWLIST.contains(name),
9319 "{name} is in SECCOMP_MUST_STAY_DENIED but was added to \
9320 SECCOMP_SYSCALL_ALLOWLIST — if this is deliberate, remove it from the deny list \
9321 in the same edit and say why in the commit"
9322 );
9323 }
9324 }
9325
9326 #[test]
9331 fn allowlist_permits_exec_because_landlock_now_decides_it() {
9332 for name in ["execve", "execveat"] {
9333 assert!(
9334 SECCOMP_SYSCALL_ALLOWLIST.contains(&name),
9335 "{name} must reach the kernel — the default action is a deny, so omitting it \
9336 refuses the child's own exec and nothing runs"
9337 );
9338 }
9339 }
9340
9341 #[test]
9346 fn allowlist_excludes_socket_which_is_ruled_on_by_domain() {
9347 assert!(
9348 !SECCOMP_SYSCALL_ALLOWLIST.contains(&"socket"),
9349 "socket must be permitted per-domain by allowed_socket_domains, never unconditionally"
9350 );
9351 }
9352
9353 #[test]
9356 fn allowlist_has_no_duplicates() {
9357 let mut seen = std::collections::HashSet::new();
9358 for name in SECCOMP_SYSCALL_ALLOWLIST {
9359 assert!(seen.insert(*name), "{name} appears twice in the allowlist");
9360 }
9361 }
9362
9363 #[test]
9366 fn allowlist_covers_the_syscalls_no_process_can_run_without() {
9367 for name in [
9368 "read",
9369 "write",
9370 "openat",
9371 "close",
9372 "mmap",
9373 "munmap",
9374 "brk",
9375 "exit_group",
9376 "futex",
9377 "rt_sigreturn",
9378 "clone",
9379 "wait4",
9380 "getpid",
9381 "getdents64",
9382 ] {
9383 assert!(
9384 SECCOMP_SYSCALL_ALLOWLIST.contains(&name),
9385 "{name} is missing from the allowlist — no process could run under this filter"
9386 );
9387 }
9388 }
9389
9390 #[test]
9391 fn network_ip_allowed_matches_only_the_resolved_allowlist() {
9392 let allow = vec![IpAddr::from([127, 0, 0, 1])];
9393 assert!(network_ip_allowed(IpAddr::from([127, 0, 0, 1]), &allow));
9394 assert!(!network_ip_allowed(IpAddr::from([8, 8, 8, 8]), &allow));
9395 assert!(
9396 !network_ip_allowed(IpAddr::from([127, 0, 0, 1]), &[]),
9397 "an empty resolved allowlist must deny every destination"
9398 );
9399 }
9400
9401 #[test]
9404 fn tier_warning_kernel_full_warns_when_a_shell_is_declared() {
9405 assert_eq!(
9409 tier_warning(EnforcementTier::KernelFull, false),
9410 Some((W_SEC_005, KERNEL_ENFORCEMENT_NOTICE))
9411 );
9412 assert_eq!(tier_warning(EnforcementTier::KernelFull, true), None);
9413 }
9414
9415 #[test]
9419 fn tier_warning_kernel_sealed_states_what_proc_is() {
9420 let (code, notice) = tier_warning(EnforcementTier::KernelSealed, false).unwrap();
9421 assert_eq!(code, W_SEC_005);
9422 assert!(!notice.contains("no PID namespace"), "{notice}");
9423 assert!(notice.contains("own PID namespace"), "{notice}");
9424 assert!(notice.contains("private procfs"), "{notice}");
9425 assert!(notice.contains("no host process appears"), "{notice}");
9426 assert!(
9427 notice.contains("Landlock refuses every open beneath it"),
9428 "{notice}"
9429 );
9430 assert!(notice.contains("stat(2) on /proc/<pid>"), "{notice}");
9431 assert!(!notice.contains("any process of the same user"), "{notice}");
9432 assert_eq!(tier_warning(EnforcementTier::KernelSealed, true), None);
9433 }
9434
9435 #[test]
9436 fn tier_warning_all_tiers_warn_only_when_shell_allow_is_nonempty() {
9437 assert_eq!(
9438 tier_warning(EnforcementTier::KernelSeccompOnly, false),
9439 Some((W_SEC_002, SECCOMP_ONLY_WARNING))
9440 );
9441 assert_eq!(
9442 tier_warning(EnforcementTier::EnvironmentOnly, false),
9443 Some((W_SEC_001, ENVIRONMENT_ONLY_WARNING))
9444 );
9445 assert_eq!(tier_warning(EnforcementTier::KernelFull, true), None);
9446 assert_eq!(tier_warning(EnforcementTier::KernelSeccompOnly, true), None);
9447 assert_eq!(tier_warning(EnforcementTier::EnvironmentOnly, true), None);
9448 }
9449
9450 #[test]
9455 fn aggregate_bounding_warns_only_off_linux_and_only_with_a_subprocess_route() {
9456 assert_eq!(
9457 aggregate_bounding_warning(false, true, false),
9458 Some((W_SEC_010, NO_AGGREGATE_BOUNDING_WARNING))
9459 );
9460 assert_eq!(
9461 aggregate_bounding_warning(false, false, false),
9462 None,
9463 "a capsule that cannot spawn a subprocess has nothing to bound"
9464 );
9465 assert_eq!(
9466 aggregate_bounding_warning(true, true, false),
9467 None,
9468 "on Linux this case is a refused launch, not a warning"
9469 );
9470 assert_eq!(
9471 aggregate_bounding_warning(true, true, true),
9472 None,
9473 "a scope exists, so there is no gap"
9474 );
9475 }
9476
9477 #[test]
9481 fn aggregate_bounding_warning_states_why_rlimit_nproc_is_insufficient() {
9482 let (_, message) = aggregate_bounding_warning(false, true, false).unwrap();
9483 assert!(message.contains("per-uid"), "message was: {message}");
9484 assert!(message.contains("fork bomb"), "message was: {message}");
9485 assert!(message.contains("pids.max"), "message was: {message}");
9486 }
9487
9488 #[test]
9489 fn warn_for_missing_aggregate_bounding_writes_the_code_and_link_to_bootstrap_log() {
9490 let temp = tempfile::tempdir().unwrap();
9491 warn_for_missing_aggregate_bounding(
9492 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
9493 true,
9494 false,
9495 );
9496 let log = bootstrap_log_contents(temp.path());
9497
9498 if cfg!(target_os = "linux") {
9499 assert!(
9500 log.is_empty(),
9501 "Linux refuses the launch instead of warning: {log}"
9502 );
9503 } else {
9504 assert!(log.contains(W_SEC_010), "log was: {log}");
9505 assert!(
9506 log.contains(&security_warning_link(W_SEC_010)),
9507 "log was: {log}"
9508 );
9509 }
9510 }
9511
9512 fn warning_test_policy() -> CapabilityPolicy {
9513 CapabilityPolicy {
9514 shell_allow: vec!["bash".to_string()],
9515 network_allow: vec!["https://api.example.com".to_string()],
9516 ..CapabilityPolicy::default()
9517 }
9518 }
9519
9520 #[test]
9521 fn warn_for_enforcement_tier_kernel_full_logs_scope_when_shell_declared() {
9522 let temp = tempfile::tempdir().unwrap();
9523 warn_for_enforcement_tier(
9524 EnforcementTier::KernelFull,
9525 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
9526 &warning_test_policy(),
9527 );
9528 let log = bootstrap_log_contents(temp.path());
9529 assert!(
9530 log.contains(W_SEC_005),
9531 "KernelFull must not be silent — it must state what the tier does and does not cover: {log}"
9532 );
9533 assert!(
9534 log.contains("denied, not absent"),
9535 "must state the scope's documented limit: paths outside the workdir are denied, not \
9536 absent: {log}"
9537 );
9538 assert!(
9539 log.contains("read+execute") && log.contains("outside the workdir"),
9540 "must describe what is now actually granted (narrow, derived read+execute outside \
9541 the workdir), not an unfixed grant bug: {log}"
9542 );
9543 assert!(
9544 log.contains(&security_warning_link(W_SEC_005)),
9545 "must link to the diagnostics doc page: {log}"
9546 );
9547 }
9548
9549 #[test]
9550 fn warn_for_enforcement_tier_kernel_full_silent_without_shell_allow() {
9551 let temp = tempfile::tempdir().unwrap();
9552 warn_for_enforcement_tier(
9553 EnforcementTier::KernelFull,
9554 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
9555 &CapabilityPolicy::default(),
9556 );
9557 assert_eq!(
9558 bootstrap_log_contents(temp.path()),
9559 "",
9560 "with no shell declared there is no subprocess to enforce on — stay silent"
9561 );
9562 }
9563
9564 #[test]
9565 fn warn_for_enforcement_tier_seccomp_only_logs_the_landlock_gap() {
9566 let temp = tempfile::tempdir().unwrap();
9567 warn_for_enforcement_tier(
9568 EnforcementTier::KernelSeccompOnly,
9569 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
9570 &warning_test_policy(),
9571 );
9572 let log = bootstrap_log_contents(temp.path());
9573 assert!(
9574 log.contains("Landlock"),
9575 "must name the missing primitive: {log}"
9576 );
9577 assert!(
9578 log.contains("filesystem"),
9579 "must name what is not enforced: {log}"
9580 );
9581 assert!(
9582 log.contains(W_SEC_002),
9583 "must carry its warning code: {log}"
9584 );
9585 assert!(
9586 log.contains(&security_warning_link(W_SEC_002)),
9587 "must link to the diagnostics doc page: {log}"
9588 );
9589 }
9590
9591 #[test]
9592 fn warn_for_enforcement_tier_environment_only_logs_permanent_and_bypass_warnings() {
9593 let temp = tempfile::tempdir().unwrap();
9594 warn_for_enforcement_tier(
9595 EnforcementTier::EnvironmentOnly,
9596 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
9597 &warning_test_policy(),
9598 );
9599 let log = bootstrap_log_contents(temp.path());
9600 assert!(
9601 log.contains("permanent"),
9602 "must state the tier is permanent, not pending: {log}"
9603 );
9604 assert!(
9605 log.contains("bash"),
9606 "the still-accurate bash/network bypass warning must also fire: {log}"
9607 );
9608 assert!(
9609 log.contains(W_SEC_001),
9610 "must carry the tier warning code: {log}"
9611 );
9612 assert!(
9613 log.contains(W_SEC_003),
9614 "must carry the bash/network bypass warning code: {log}"
9615 );
9616 }
9617
9618 struct ForcePrepareFailureGuard;
9621
9622 impl ForcePrepareFailureGuard {
9623 fn new() -> Self {
9624 FORCE_PREPARE_FAILURE.with(|flag| flag.set(true));
9625 Self
9626 }
9627 }
9628
9629 impl Drop for ForcePrepareFailureGuard {
9630 fn drop(&mut self) {
9631 FORCE_PREPARE_FAILURE.with(|flag| flag.set(false));
9632 }
9633 }
9634
9635 #[test]
9636 fn execute_shell_fails_closed_when_enforcement_setup_fails() {
9637 let temp = tempfile::tempdir().unwrap();
9638 let policy = CapabilityPolicy {
9639 shell_allow: vec!["bash".to_string()],
9640 ..CapabilityPolicy::default()
9641 };
9642 let marker = temp.path().join("spawned-anyway.marker");
9643 let script = format!("echo ran > '{}'", marker.display());
9644
9645 {
9646 let _guard = ForcePrepareFailureGuard::new();
9647 let error = crate::shell::execute_shell(
9648 "bash",
9649 &["-c", &script],
9650 &[],
9651 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
9652 &policy,
9653 &ShellEnforcement::environment_only().with_test_session_dirs(),
9654 )
9655 .unwrap_err();
9656 assert!(
9657 error.to_string().contains("sandbox"),
9658 "the error must name what failed to initialize: {error}"
9659 );
9660 assert!(
9661 !marker.exists(),
9662 "no subprocess may be spawned at all when enforcement setup fails"
9663 );
9664 }
9665
9666 let result = crate::shell::execute_shell(
9669 "bash",
9670 &["-c", &script],
9671 &[],
9672 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
9673 &policy,
9674 &ShellEnforcement::environment_only().with_test_session_dirs(),
9675 )
9676 .unwrap();
9677 assert_eq!(result.exit_code, 0);
9678 assert!(marker.exists());
9679 }
9680}
9681
9682#[cfg(target_os = "linux")]
9683#[cfg(test)]
9684#[allow(clippy::print_stdout, clippy::print_stderr)]
9685mod landlock_probe_isolation {
9686 #[test]
9698 fn the_landlock_probe_does_not_restrict_the_calling_process() {
9699 let before = crate::namespace_probe::probe_namespaces().sealed_root;
9700 let _ = super::linux_enforce::probe_landlock_full_access();
9701 let after = crate::namespace_probe::probe_namespaces().sealed_root;
9702
9703 assert_eq!(
9704 before, after,
9705 "the Landlock probe changed what the sealed probe reports ({before:?} -> {after:?}) — \
9706 it has restricted the calling process instead of a forked child, which permanently \
9707 forbids the mount family to this process and everything it spawns",
9708 );
9709 }
9710}
9711
9712#[cfg(all(test, target_os = "linux"))]
9713#[allow(clippy::print_stdout, clippy::print_stderr)]
9714mod linux_integration_tests {
9715 use std::path::Path;
9716
9717 use super::*;
9718
9719 fn host_bounding_base() -> ShellEnforcement {
9732 let base = tempfile::tempdir().unwrap().keep();
9735 ShellEnforcement::environment_only().with_runtime_dir(test_runtime_dir(&base), None)
9736 }
9737
9738 #[test]
9750 fn workdir_landlock_grant_withholds_device_and_socket_creation() {
9751 use landlock::{Access, AccessFs, ABI};
9752
9753 assert_eq!(
9754 LANDLOCK_TARGET_ABI, 3,
9755 "this test pins the ABI v3 set; raising the cap needs this test revisited with it"
9756 );
9757 let all_v3 = AccessFs::from_all(ABI::V3);
9758 let granted = linux_enforce::workdir_access_rights(true);
9762
9763 for withheld in [AccessFs::MakeChar, AccessFs::MakeBlock, AccessFs::MakeSock] {
9764 assert!(
9765 all_v3.contains(withheld),
9766 "{withheld:?} is expected to be part of Landlock ABI v3 — if it is not, this \
9767 test's premise (and the workdir grant's comment) needs revisiting"
9768 );
9769 assert!(
9770 !granted.contains(withheld),
9771 "the workdir grant must withhold {withheld:?}: with the full ABI set declared \
9772 by handle_access, withholding it here is what makes it denied rather than merely \
9773 un-granted"
9774 );
9775 }
9776
9777 for added in [AccessFs::Refer, AccessFs::Truncate] {
9778 assert!(
9779 granted.contains(added),
9780 "the workdir grant must carry {added:?}: without Refer a move between workdir \
9781 folders is EXDEV, and without Truncate a v3 ruleset refuses truncating the \
9782 capsule's own files"
9783 );
9784 }
9785
9786 assert!(
9787 granted.contains(AccessFs::MakeFifo),
9788 "MakeFifo stays granted — real build tooling creates named pipes in its working tree"
9789 );
9790
9791 let expected = all_v3 & !(AccessFs::MakeChar | AccessFs::MakeBlock | AccessFs::MakeSock);
9792 assert_eq!(
9793 granted, expected,
9794 "the workdir_exec grant must be exactly Landlock ABI v3 minus \
9795 MakeChar/MakeBlock/MakeSock — nothing else may be added or dropped without updating \
9796 this test and the constant's comment together"
9797 );
9798 }
9799
9800 #[test]
9804 fn workdir_rights_masked_to_abi_v1_equal_the_pre_refer_set() {
9805 use landlock::{Access, AccessFs, ABI};
9806
9807 let all_v1 = AccessFs::from_all(ABI::V1);
9808 let masked = linux_enforce::workdir_access_rights(true) & all_v1;
9809
9810 assert_eq!(
9811 masked,
9812 all_v1 & !(AccessFs::MakeChar | AccessFs::MakeBlock | AccessFs::MakeSock),
9813 "masked to ABI v1, the workdir grant must be the v1 set minus the three Make* rights"
9814 );
9815 assert!(!masked.contains(AccessFs::Refer));
9816 assert!(!masked.contains(AccessFs::Truncate));
9817 assert_eq!(
9818 linux_enforce::workdir_access_rights(false) & all_v1,
9819 masked & !AccessFs::Execute,
9820 "masking must not move the workdir_exec axis"
9821 );
9822 }
9823
9824 #[test]
9831 fn workdir_execute_right_is_granted_only_when_workdir_exec_is_declared() {
9832 use landlock::AccessFs;
9833
9834 assert!(
9835 !linux_enforce::workdir_access_rights(false).contains(AccessFs::Execute),
9836 "the default must withhold Execute from the workdir — that withholding is the whole \
9837 of what enforces capabilities.shell.allow now that the exec supervisor is gone"
9838 );
9839 assert!(
9840 linux_enforce::workdir_access_rights(true).contains(AccessFs::Execute),
9841 "capabilities.filesystem.workdir_exec: true must actually grant Execute, or the \
9842 compile-and-run workflows it exists for silently stop working"
9843 );
9844
9845 assert_eq!(
9847 linux_enforce::workdir_access_rights(true) & !AccessFs::Execute,
9848 linux_enforce::workdir_access_rights(false),
9849 "workdir_exec must be a one-bit axis — no other right may ride along with it"
9850 );
9851 }
9852
9853 fn sealed_with_runtime_dir(base: &Path) -> ShellEnforcement {
9855 ShellEnforcement {
9856 tier: EnforcementTier::KernelSealed,
9857 ..ShellEnforcement::environment_only().with_test_session_dirs()
9858 }
9859 .with_runtime_dir(test_runtime_dir(base), None)
9860 }
9861
9862 fn temp_dir_fd_of(enforcement: &ShellEnforcement) -> Option<Arc<std::os::fd::OwnedFd>> {
9865 enforcement
9866 .runtime_dir
9867 .as_ref()
9868 .and_then(|roots| roots.temp.as_ref())
9869 .map(|temp| Arc::new(temp.path_fd().unwrap()))
9870 }
9871
9872 fn home_dir_fd_of(enforcement: &ShellEnforcement) -> Option<Arc<std::os::fd::OwnedFd>> {
9875 enforcement
9876 .home_root()
9877 .map(|home| Arc::new(home.path_fd().unwrap()))
9878 }
9879
9880 #[test]
9889 fn the_temp_folder_and_runtime_dir_are_opened_for_landlock_and_the_sealed_bind() {
9890 let accessible = tempfile::tempdir().unwrap();
9891 let runtime = tempfile::tempdir().unwrap();
9892 let enforcement = sealed_with_runtime_dir(runtime.path());
9893 let temp_dir_fd = temp_dir_fd_of(&enforcement);
9894 let sources = SealedSourceFds::open(
9895 &enforcement,
9896 temp_dir_fd.as_ref(),
9897 home_dir_fd_of(&enforcement).as_ref(),
9898 )
9899 .expect("a real, writable workdir must yield the sealed sources");
9900 assert_eq!(
9901 sources.tmp_store_path,
9902 enforcement.temp_dir_path().unwrap(),
9903 "the composed root binds the session's temporary folder at /tmp"
9904 );
9905 assert_eq!(
9906 sources.home_dir.1,
9907 enforcement.home_dir_path().unwrap(),
9908 "the composed root binds the session's home folder at its own path"
9909 );
9910 let fds = linux_enforce::open_landlock_fds(linux_enforce::LandlockFdRequest {
9911 sealed: Some(&sources),
9912 temp_dir_fd,
9913 home_dir_fd: Some(Arc::clone(&sources.home_dir.0)),
9914 runtime_dir_fd: Some(Arc::clone(&sources.runtime_dir)),
9915 records_dir_fd: sources.records_dir.as_ref().map(|(fd, _)| Arc::clone(fd)),
9916 ..linux_enforce::LandlockFdRequest::workdir_only(accessible.path())
9917 })
9918 .expect("a real, writable workdir must yield sealed Landlock fds");
9919
9920 assert!(fds.temp_dir_fd().is_some());
9921 assert!(
9922 fds.home_dir_fd().is_some(),
9923 "the home folder takes the temporary folder's rule"
9924 );
9925 assert!(fds.runtime_dir_fd().is_some());
9926 assert!(
9927 fds.records_dir_fd().is_some(),
9928 "the records directory takes the runtime directory's read-only rule"
9929 );
9930 assert_eq!(
9931 std::fs::read_dir(accessible.path()).unwrap().count(),
9932 0,
9933 "enabling sealed must add nothing to the work folder"
9934 );
9935
9936 let fds = linux_enforce::open_landlock_fds(linux_enforce::LandlockFdRequest::workdir_only(
9937 accessible.path(),
9938 ))
9939 .expect("a real, writable workdir must yield Landlock fds on any tier");
9940 assert!(
9941 fds.temp_dir_fd().is_none(),
9942 "an enforcement holding no temporary folder grants none"
9943 );
9944 assert!(fds.home_dir_fd().is_none());
9945 }
9946
9947 #[test]
9950 fn a_sealed_root_needs_the_temp_folder() {
9951 let runtime = tempfile::tempdir().unwrap();
9952 let mut roots = test_runtime_dir(runtime.path());
9953 roots.temp = None;
9954 let enforcement = ShellEnforcement {
9955 tier: EnforcementTier::KernelSealed,
9956 ..ShellEnforcement::environment_only().with_test_session_dirs()
9957 }
9958 .with_runtime_dir(roots, None);
9959 let error =
9960 SealedSourceFds::open(&enforcement, None, home_dir_fd_of(&enforcement).as_ref())
9961 .err()
9962 .expect("refused");
9963 assert!(error.contains("temporary folder"), "{error}");
9964 }
9965
9966 #[test]
9969 fn no_home_folder_refuses_a_sealed_root() {
9970 let runtime = tempfile::tempdir().unwrap();
9971 let roots = crate::runtime_dir::RuntimeDirRoots {
9972 home: None,
9973 ..test_runtime_dir(runtime.path())
9974 };
9975 let enforcement = ShellEnforcement {
9976 tier: EnforcementTier::KernelSealed,
9977 ..ShellEnforcement::environment_only().with_test_session_dirs()
9978 }
9979 .with_runtime_dir(roots, None);
9980 let error =
9981 SealedSourceFds::open(&enforcement, temp_dir_fd_of(&enforcement).as_ref(), None)
9982 .err()
9983 .expect("refused");
9984 assert!(error.contains("no home folder"), "{error}");
9985 assert!(!runtime
9986 .path()
9987 .join(crate::runtime_dir::SEALED_ETC_SUBDIR)
9988 .join("passwd")
9989 .exists());
9990 }
9991
9992 #[test]
10000 fn the_synthetic_etc_files_are_written_into_sealed_etc_and_opened() {
10001 let accessible = tempfile::tempdir().unwrap();
10002 let runtime = tempfile::tempdir().unwrap();
10003 let enforcement = sealed_with_runtime_dir(runtime.path());
10004 let sources = SealedSourceFds::open(
10005 &enforcement,
10006 temp_dir_fd_of(&enforcement).as_ref(),
10007 home_dir_fd_of(&enforcement).as_ref(),
10008 )
10009 .unwrap();
10010 let fds = linux_enforce::open_landlock_fds(linux_enforce::LandlockFdRequest {
10011 sealed: Some(&sources),
10012 ..linux_enforce::LandlockFdRequest::workdir_only(accessible.path())
10013 })
10014 .expect("a real, writable workdir must yield sealed Landlock fds");
10015
10016 let synthetic: Vec<&crate::sealed::SealedEtcPath> = crate::sealed::SEALED_ETC_PATHS
10017 .iter()
10018 .filter(|entry| entry.synthetic.is_some())
10019 .collect();
10020 assert_eq!(
10021 fds.sealed_identity_fd_count(),
10022 synthetic.len(),
10023 "every synthetic /etc entry the composed root binds needs a rule of its own: {synthetic:?}",
10024 );
10025 let etc_dir = runtime.path().join(crate::runtime_dir::SEALED_ETC_SUBDIR);
10026 for entry in &synthetic {
10027 let path = crate::sealed::synthetic_etc_source(&etc_dir, entry.synthetic.unwrap());
10028 assert!(
10029 path.is_file(),
10030 "{} must be written into sealed-etc/",
10031 entry.path
10032 );
10033 }
10034 assert!(
10035 !accessible.path().join(".mur-etc").exists(),
10036 "the work folder gained .mur-etc"
10037 );
10038
10039 let fds = linux_enforce::open_landlock_fds(linux_enforce::LandlockFdRequest::workdir_only(
10040 accessible.path(),
10041 ))
10042 .unwrap();
10043 assert_eq!(fds.sealed_identity_fd_count(), 0);
10044 }
10045
10046 #[test]
10049 fn a_sealed_root_needs_the_runtime_directory() {
10050 let enforcement = ShellEnforcement {
10051 tier: EnforcementTier::KernelSealed,
10052 ..ShellEnforcement::environment_only()
10053 };
10054 let error = SealedSourceFds::open(&enforcement, None, None)
10055 .err()
10056 .expect("refused");
10057 assert!(error.contains("runtime directory"), "{error}");
10058 }
10059
10060 #[test]
10068 fn home_folder_is_the_synthetic_passwd_entrys_home_and_home() {
10069 use std::os::unix::fs::MetadataExt;
10070
10071 let runtime = tempfile::tempdir().unwrap();
10072 let roots = test_runtime_dir(runtime.path());
10073 let home = roots.home.clone().unwrap();
10074 let written = ensure_sealed_identity_files(&roots.sealed_etc, &home).unwrap();
10075 let env = crate::shell::build_shell_env(
10076 &crate::types::CapabilityPolicy::default(),
10077 &[],
10078 Some(home.path()),
10079 None,
10080 None,
10081 None,
10082 )
10083 .unwrap();
10084
10085 let passwd = std::fs::read_to_string(&written[0]).unwrap();
10086 let owner = std::fs::metadata(home.path()).unwrap();
10087 let entry: Vec<&str> = passwd
10088 .lines()
10089 .last()
10090 .expect("the capsule's own entry is the last line")
10091 .split(':')
10092 .collect();
10093
10094 assert_eq!(entry[2], owner.uid().to_string(), "uid field");
10095 assert_eq!(entry[3], owner.gid().to_string(), "gid field");
10096 assert_eq!(
10097 Some(&entry[5].to_string()),
10098 env.get("HOME"),
10099 "pw_dir and $HOME are the same string or the entry is worse than useless",
10100 );
10101 assert_eq!(
10102 entry[5],
10103 runtime
10104 .path()
10105 .join(crate::records_dir::RECORDS_DIR_NAME)
10106 .join(crate::records_dir::HOME_SUBDIR)
10107 .to_str()
10108 .unwrap()
10109 );
10110
10111 let group = std::fs::read_to_string(&written[1]).unwrap();
10112 assert_eq!(
10113 group.lines().last().unwrap().split(':').nth(2),
10114 Some(owner.gid().to_string().as_str()),
10115 "gid field of the capsule's own group",
10116 );
10117 }
10118
10119 fn sealed_planted() -> (
10121 tempfile::TempDir,
10122 PathBuf,
10123 PathBuf,
10124 crate::workdir_fs::WorkdirRoot,
10125 ) {
10126 let dir = tempfile::tempdir().unwrap();
10127 let accessible = dir.path().join("work");
10128 let outside = dir.path().join("outside");
10129 std::fs::create_dir_all(&accessible).unwrap();
10130 std::fs::create_dir_all(&outside).unwrap();
10131 std::fs::write(outside.join("sentinel.txt"), b"operator data").unwrap();
10132 let root = crate::workdir_fs::WorkdirRoot::open(&accessible).unwrap();
10133 (dir, root.path().to_path_buf(), outside, root)
10134 }
10135
10136 #[test]
10139 fn sealed_identity_files_keep_their_inode() {
10140 use std::os::unix::fs::MetadataExt;
10141
10142 let (_dir, _session, _outside, root) = sealed_planted();
10143 let runtime = tempfile::tempdir().unwrap();
10144 let roots = test_runtime_dir(runtime.path());
10145 let passwd = roots.sealed_etc.path().join("passwd");
10146 ensure_sealed_identity_files(&roots.sealed_etc, &root).unwrap();
10147 let first = std::fs::metadata(&passwd).unwrap().ino();
10148 ensure_sealed_identity_files(&roots.sealed_etc, &root).unwrap();
10149 assert_eq!(std::fs::metadata(&passwd).unwrap().ino(), first);
10150 assert!(root.take_refusals().is_empty());
10151 }
10152
10153 #[test]
10165 fn the_composed_root_tmp_rule_carries_exactly_the_workdir_right_set() {
10166 use landlock::AccessFs;
10167
10168 for workdir_exec in [false, true] {
10169 let rights = linux_enforce::workdir_access_rights(workdir_exec);
10170
10171 for needed in [
10172 AccessFs::WriteFile,
10173 AccessFs::ReadFile,
10174 AccessFs::ReadDir,
10175 AccessFs::MakeReg,
10176 AccessFs::MakeDir,
10177 AccessFs::RemoveFile,
10178 AccessFs::RemoveDir,
10179 ] {
10180 assert!(
10181 rights.contains(needed),
10182 "/tmp carries the workdir's rights, so it must carry {needed:?}: without it \
10183 `cc`, `mktemp` and `pip` fail on a mount that is genuinely writable",
10184 );
10185 }
10186
10187 assert_eq!(
10188 rights.contains(AccessFs::Execute),
10189 workdir_exec,
10190 "/tmp is backed by a directory inside the workdir, so Execute there must follow \
10191 capabilities.filesystem.workdir_exec exactly — granting it unconditionally would \
10192 make /tmp a hole through which capsule-written bytes run",
10193 );
10194 }
10195
10196 assert_eq!(
10197 linux_enforce::workdir_access_rights(true) & !AccessFs::Execute,
10198 linux_enforce::workdir_access_rights(false),
10199 "the /tmp rule and the workdir rule share one right-set binding in \
10200 apply_landlock_scope; if that set ever grows a second conditional axis, this test and \
10201 that binding must be revisited together",
10202 );
10203 }
10204
10205 fn unconfined_enforcement(declared: murmur_artifact::ResourceCapabilities) -> ShellEnforcement {
10208 ShellEnforcement {
10209 tier: EnforcementTier::Unconfined,
10210 declared_resources: declared,
10211 temp_mapping: crate::temp_dir::tier_temp_mapping(EnforcementTier::Unconfined),
10212 proc_view: crate::proc_view::tier_proc_view(EnforcementTier::Unconfined),
10213 ..ShellEnforcement::environment_only()
10214 }
10215 .with_test_session_dirs()
10216 }
10217
10218 fn status_line(status: &str, field: &str) -> String {
10221 status
10222 .lines()
10223 .find(|line| line.starts_with(field))
10224 .unwrap_or_else(|| panic!("no {field} line in {status}"))
10225 .to_string()
10226 }
10227
10228 fn spawn_unconfined(enforcement: &ShellEnforcement, script: &str) -> String {
10230 let mut command = std::process::Command::new("bash");
10231 command.args(["-c", script]);
10232 let supervisor = prepare_enforcement(&mut command, enforcement, Path::new("/tmp"))
10233 .expect("the unconfined tier installs nothing that can fail");
10234 assert!(matches!(supervisor, SupervisorHandle::Noop));
10235 let output = command.output().expect("bash runs");
10236 assert!(output.status.success(), "{output:?}");
10237 String::from_utf8(output.stdout).unwrap()
10238 }
10239
10240 #[test]
10244 fn an_unconfined_child_keeps_no_new_privs_capabilities_and_network_namespace() {
10245 let enforcement = unconfined_enforcement(Default::default());
10246 let child = spawn_unconfined(
10247 &enforcement,
10248 "cat /proc/self/status; echo \"netns=$(readlink /proc/self/ns/net)\"",
10249 );
10250 let own = std::fs::read_to_string("/proc/self/status").unwrap();
10251 for field in ["NoNewPrivs:", "CapEff:"] {
10252 assert_eq!(
10253 status_line(&child, field),
10254 status_line(&own, field),
10255 "{child}"
10256 );
10257 }
10258 let own_netns = std::fs::read_link("/proc/self/ns/net").unwrap();
10259 assert!(
10260 child.contains(&format!("netns={}", own_netns.display())),
10261 "{child}"
10262 );
10263 assert_eq!(
10264 enforcement.session_netns.creations(),
10265 0,
10266 "the unconfined tier creates no network namespace"
10267 );
10268 }
10269
10270 #[test]
10273 fn an_unconfined_child_gets_only_the_declared_rlimits() {
10274 let read = |resource| {
10275 let limit = rustix::process::getrlimit(resource);
10276 let show = |value: Option<u64>| {
10277 value.map_or_else(|| "unlimited".to_string(), |value| value.to_string())
10278 };
10279 format!("{} {}", show(limit.current), show(limit.maximum))
10280 };
10281 let undeclared = spawn_unconfined(
10282 &unconfined_enforcement(Default::default()),
10283 "echo \"$(ulimit -Sn) $(ulimit -Hn)\"; echo \"$(ulimit -Sc) $(ulimit -Hc)\"",
10284 );
10285 assert_eq!(
10286 undeclared,
10287 format!(
10288 "{}\n{}\n",
10289 read(rustix::process::Resource::Nofile),
10290 read(rustix::process::Resource::Core)
10291 )
10292 );
10293 let declared = spawn_unconfined(
10294 &unconfined_enforcement(murmur_artifact::ResourceCapabilities {
10295 max_open_files: Some(256),
10296 ..Default::default()
10297 }),
10298 "echo \"$(ulimit -Sn) $(ulimit -Hn)\"; echo \"$(ulimit -Sc) $(ulimit -Hc)\"",
10299 );
10300 assert_eq!(
10301 declared,
10302 format!("256 256\n{}\n", read(rustix::process::Resource::Core))
10303 );
10304 }
10305
10306 #[test]
10307 fn prepare_enforcement_is_noop_for_environment_only_tier_even_on_linux() {
10308 let mut command = std::process::Command::new("true");
10309 let enforcement = ShellEnforcement::environment_only().with_test_session_dirs();
10310 let supervisor = prepare_enforcement(&mut command, &enforcement, Path::new("/tmp"))
10311 .expect("environment_only must never fail");
10312 assert!(matches!(supervisor, SupervisorHandle::Noop));
10313 assert_eq!(
10314 enforcement.session_netns.creations(),
10315 0,
10316 "a tier with no kernel sandbox must not create a network namespace"
10317 );
10318 }
10319
10320 #[test]
10326 fn kernel_tier_allows_exec_within_shell_allowlist() {
10327 if crate::network_namespace::skip_without_egress_namespace(
10328 "kernel_tier_allows_exec_within_shell_allowlist",
10329 ) {
10330 return;
10331 }
10332 let tier = detect_enforcement_tier();
10333 if tier == EnforcementTier::EnvironmentOnly {
10334 eprintln!(
10335 "skipping kernel_tier_allows_exec_within_shell_allowlist: resolved to \
10336 EnvironmentOnly on a Linux host — degrading gracefully"
10337 );
10338 return;
10339 }
10340
10341 let temp = tempfile::tempdir().unwrap();
10342 let policy = CapabilityPolicy {
10343 shell_allow: vec!["bash".to_string()],
10344 ..CapabilityPolicy::default()
10345 };
10346 let enforcement = ShellEnforcement {
10347 tier,
10348 network_allow_ips: Vec::new(),
10349 unix_sockets_allowed: policy.unix_sockets_allowed,
10350 landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
10351 &resolve_exec_allowlist(&policy.shell_allow),
10352 )),
10353 ..host_bounding_base()
10354 };
10355
10356 let result = crate::shell::execute_shell(
10357 "bash",
10358 &["-c", "exit 7"],
10359 &[],
10360 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
10361 &policy,
10362 &enforcement,
10363 )
10364 .unwrap();
10365
10366 assert_eq!(
10367 result.exit_code, 7,
10368 "bash itself is allowlisted, so kernel enforcement must permit its own execve — \
10369 proving the allowlist isn't a blanket deny"
10370 );
10371 }
10372
10373 #[test]
10374 fn kernel_tier_allows_nested_exec_of_second_allowlisted_binary() {
10375 if crate::network_namespace::skip_without_egress_namespace(
10376 "kernel_tier_allows_nested_exec_of_second_allowlisted_binary",
10377 ) {
10378 return;
10379 }
10380 let tier = detect_enforcement_tier();
10381 if tier == EnforcementTier::EnvironmentOnly {
10382 eprintln!(
10383 "skipping kernel_tier_allows_nested_exec_of_second_allowlisted_binary: \
10384 resolved to EnvironmentOnly on a Linux host — degrading gracefully"
10385 );
10386 return;
10387 }
10388
10389 let temp = tempfile::tempdir().unwrap();
10390 let policy = CapabilityPolicy {
10391 shell_allow: vec!["bash".to_string(), "ls".to_string()],
10392 ..CapabilityPolicy::default()
10393 };
10394 let enforcement = ShellEnforcement {
10395 tier,
10396 network_allow_ips: Vec::new(),
10397 unix_sockets_allowed: policy.unix_sockets_allowed,
10398 landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
10399 &resolve_exec_allowlist(&policy.shell_allow),
10400 )),
10401 ..host_bounding_base()
10402 };
10403
10404 let result = crate::shell::execute_shell(
10408 "bash",
10409 &["-c", "ls"],
10410 &[],
10411 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
10412 &policy,
10413 &enforcement,
10414 )
10415 .unwrap();
10416
10417 assert_eq!(
10418 result.exit_code, 0,
10419 "a second allowlisted binary exec'd from inside allowlisted bash must succeed \
10420 (stderr: {})",
10421 result.stderr
10422 );
10423 }
10424
10425 #[test]
10426 fn kernel_tier_denies_network_connect_outside_allowlist() {
10427 if crate::network_namespace::skip_without_egress_namespace(
10428 "kernel_tier_denies_network_connect_outside_allowlist",
10429 ) {
10430 return;
10431 }
10432 let tier = detect_enforcement_tier();
10433 if tier == EnforcementTier::EnvironmentOnly {
10434 eprintln!(
10435 "skipping kernel_tier_denies_network_connect_outside_allowlist: resolved to \
10436 EnvironmentOnly on a Linux host — degrading gracefully"
10437 );
10438 return;
10439 }
10440
10441 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
10446 let port = listener.local_addr().unwrap().port();
10447
10448 let temp = tempfile::tempdir().unwrap();
10449 let policy = CapabilityPolicy {
10450 shell_allow: vec!["bash".to_string()],
10451 ..CapabilityPolicy::default()
10452 };
10453 let enforcement = ShellEnforcement {
10454 tier,
10455 network_allow_rules: Vec::new(),
10458 network_allow_ips: Vec::new(),
10459 unix_sockets_allowed: policy.unix_sockets_allowed,
10460 landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
10461 &resolve_exec_allowlist(&policy.shell_allow),
10462 )),
10463 ..host_bounding_base()
10464 };
10465
10466 let script = format!("exec 3<>/dev/tcp/127.0.0.1/{port}");
10468 let result = crate::shell::execute_shell(
10469 "bash",
10470 &["-c", &script],
10471 &[],
10472 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
10473 &policy,
10474 &enforcement,
10475 )
10476 .unwrap();
10477
10478 assert_ne!(
10479 result.exit_code, 0,
10480 "a direct connect() to a destination outside capabilities.network.allow must fail \
10481 even though the port is really open on the host — the subprocess is in its own \
10482 network namespace with no route to it"
10483 );
10484 }
10485
10486 #[test]
10498 fn kernel_tier_reaches_an_allowlisted_destination_through_the_egress_proxy() {
10499 if crate::network_namespace::skip_without_egress_namespace(
10500 "kernel_tier_reaches_an_allowlisted_destination_through_the_egress_proxy",
10501 ) {
10502 return;
10503 }
10504 let tier = detect_enforcement_tier();
10505 if tier == EnforcementTier::EnvironmentOnly {
10506 eprintln!(
10507 "skipping kernel_tier_reaches_an_allowlisted_destination_through_the_egress_proxy: \
10508 resolved to EnvironmentOnly on a Linux host — degrading gracefully"
10509 );
10510 return;
10511 }
10512
10513 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
10516 let port = listener.local_addr().unwrap().port();
10517 let server = std::thread::spawn(move || {
10518 if let Ok((mut stream, _)) = listener.accept() {
10519 use std::io::Write;
10520 let _ = stream.write_all(b"pong\n");
10521 }
10522 });
10523
10524 let temp = tempfile::tempdir().unwrap();
10525 let allow = vec![format!("http://127.0.0.1:{port}")];
10528 let policy = CapabilityPolicy {
10529 shell_allow: vec!["bash".to_string()],
10530 network_allow: allow.clone(),
10531 ..CapabilityPolicy::default()
10532 };
10533 let rules = crate::network_policy::parse_network_allow_rules(&allow).unwrap();
10534 let enforcement = ShellEnforcement {
10535 tier,
10536 egress_tcp_ports: crate::egress_proxy::egress_listen_ports(&rules),
10537 network_allow_rules: rules,
10538 network_allow_ips: resolve_network_allowlist_ips(&allow).unwrap(),
10539 unix_sockets_allowed: policy.unix_sockets_allowed,
10540 landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
10541 &resolve_exec_allowlist(&policy.shell_allow),
10542 )),
10543 ..host_bounding_base()
10544 };
10545
10546 let script =
10549 format!("exec 3<>/dev/tcp/127.0.0.1/{port} && read -r reply <&3 && echo \"$reply\"");
10550 let result = crate::shell::execute_shell(
10551 "bash",
10552 &["-c", &script],
10553 &[],
10554 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
10555 &policy,
10556 &enforcement,
10557 )
10558 .unwrap();
10559 let _ = server.join();
10560
10561 assert_eq!(
10562 result.exit_code, 0,
10563 "a destination inside capabilities.network.allow must stay reachable — enforcement \
10564 adds denials for out-of-policy destinations, it does not degrade permitted ones \
10565 (stderr: {})",
10566 result.stderr
10567 );
10568 assert!(
10569 result.stdout.contains("pong"),
10570 "the host listener's bytes must come back through the proxy unmodified \
10571 (stdout: {:?}, stderr: {})",
10572 result.stdout,
10573 result.stderr
10574 );
10575 }
10576
10577 #[test]
10578 fn kernel_full_denies_filesystem_access_outside_workdir() {
10579 if crate::network_namespace::skip_without_egress_namespace(
10580 "kernel_full_denies_filesystem_access_outside_workdir",
10581 ) {
10582 return;
10583 }
10584 let tier = detect_enforcement_tier();
10585 if tier != EnforcementTier::KernelFull {
10586 eprintln!(
10587 "skipping kernel_full_denies_filesystem_access_outside_workdir: requires \
10588 EnforcementTier::KernelFull (Landlock ABI, kernel 5.13+); detected {tier:?} \
10589 instead"
10590 );
10591 return;
10592 }
10593
10594 let temp = tempfile::tempdir().unwrap();
10595 let policy = CapabilityPolicy {
10596 shell_allow: vec!["bash".to_string()],
10597 ..CapabilityPolicy::default()
10598 };
10599 let enforcement = ShellEnforcement {
10600 tier,
10601 network_allow_ips: Vec::new(),
10602 unix_sockets_allowed: policy.unix_sockets_allowed,
10603 landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
10604 &resolve_exec_allowlist(&policy.shell_allow),
10605 )),
10606 ..host_bounding_base()
10607 };
10608
10609 let result = crate::shell::execute_shell(
10610 "bash",
10611 &["-c", "cat /etc/hostname"],
10612 &[],
10613 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
10614 &policy,
10615 &enforcement,
10616 )
10617 .unwrap();
10618
10619 assert_ne!(
10620 result.exit_code, 0,
10621 "reading a file outside the Landlock-scoped workdir must fail under KernelFull"
10622 );
10623 }
10624
10625 fn scoped_landlock_enforcement(
10634 test: &str,
10635 policy: &CapabilityPolicy,
10636 min_abi: u8,
10637 ) -> Option<ShellEnforcement> {
10638 if crate::network_namespace::skip_without_egress_namespace(test) {
10639 return None;
10640 }
10641 let probe = HostProbe::probe();
10642 let enforcement = ShellEnforcement::resolve_declared(
10643 policy,
10644 murmur_artifact::ContainmentClass::Scoped,
10645 probe.clone(),
10646 )
10647 .unwrap()
10648 .with_test_session_dirs();
10649 if enforcement.tier != EnforcementTier::KernelFull
10650 || enforcement.landlock_abi < Some(min_abi)
10651 {
10652 eprintln!(
10653 "skipping {test}: needs EnforcementTier::KernelFull with a Landlock ruleset at ABI \
10654 {min_abi} or later; resolved {:?} at ABI {:?} (kernel reports {:?})",
10655 enforcement.tier,
10656 enforcement.landlock_abi,
10657 probe.landlock_kernel_abi()
10658 );
10659 return None;
10660 }
10661 Some(enforcement)
10662 }
10663
10664 fn coreutils_policy(extra: &[&str]) -> CapabilityPolicy {
10665 let mut shell_allow: Vec<String> = ["bash", "mkdir", "mv", "ln", "stat", "cat"]
10666 .iter()
10667 .map(|name| name.to_string())
10668 .collect();
10669 shell_allow.extend(extra.iter().map(|name| name.to_string()));
10670 CapabilityPolicy {
10671 shell_allow,
10672 ..CapabilityPolicy::default()
10673 }
10674 }
10675
10676 fn run_bash_in(
10677 workdir: &Path,
10678 script: &str,
10679 policy: &CapabilityPolicy,
10680 enforcement: &ShellEnforcement,
10681 ) -> crate::shell::ShellResult {
10682 let result = crate::shell::execute_shell(
10683 "bash",
10684 &["-c", script],
10685 &[],
10686 &crate::workdir_fs::WorkdirRoot::open(workdir).unwrap(),
10687 policy,
10688 enforcement,
10689 )
10690 .unwrap();
10691 println!(
10692 "exit {}\n--- stdout\n{}--- stderr\n{}---",
10693 result.exit_code, result.stdout, result.stderr
10694 );
10695 result
10696 }
10697
10698 fn work_and_outside() -> (tempfile::TempDir, PathBuf, PathBuf) {
10701 let base = tempfile::tempdir().unwrap();
10702 let work = base.path().join("work");
10703 let outside = base.path().join("outside");
10704 std::fs::create_dir(&work).unwrap();
10705 std::fs::create_dir(&outside).unwrap();
10706 (base, work, outside)
10707 }
10708
10709 #[test]
10712 fn kernel_tier_moves_files_between_workdir_folders() {
10713 let test = "kernel_tier_moves_files_between_workdir_folders";
10714 let policy = coreutils_policy(&[]);
10715 let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 3) else {
10716 return;
10717 };
10718 assert_eq!(enforcement.tier, EnforcementTier::KernelFull);
10719 assert_eq!(enforcement.landlock_abi, Some(3));
10720
10721 let temp = tempfile::tempdir().unwrap();
10722 let result = run_bash_in(
10723 temp.path(),
10724 r#"set -e
10725mkdir a b
10726echo payload > a/x
10727before=$(stat -c %i a/x)
10728mv a/x b/x
10729after=$(stat -c %i b/x)
10730echo "mv a/x b/x: inode $before -> $after"
10731[ "$before" = "$after" ] || { echo "inode changed, mv copied instead of renaming" >&2; exit 10; }
10732ln b/x a/y
10733[ "$(cat a/y)" = payload ] || exit 11
10734echo "ln b/x a/y: ok"
10735mkdir a/d
10736mv a/d b/d
10737[ -d b/d ] && [ ! -e a/d ] || exit 12
10738echo "mv a/d b/d: ok"
10739"#,
10740 &policy,
10741 &enforcement,
10742 );
10743
10744 assert_eq!(
10745 result.exit_code, 0,
10746 "a move, a hard link and a directory reparent between workdir folders must all \
10747 succeed under Refer"
10748 );
10749 }
10750
10751 #[test]
10758 fn home_folder_is_writable_like_tmp_and_the_records_around_it_are_not() {
10759 let test = "home_folder_is_writable_like_tmp_and_the_records_around_it_are_not";
10760 let policy = coreutils_policy(&["rm", "rmdir"]);
10761 let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
10762 return;
10763 };
10764 eprintln!(
10765 "{test}: tier {:?}, Landlock ruleset ABI {:?} (kernel {:?}), /tmp {:?}",
10766 enforcement.tier,
10767 enforcement.landlock_abi,
10768 HostProbe::probe().landlock_kernel_abi(),
10769 enforcement.installed_temp_mapping()
10770 );
10771 let records = enforcement.records_root().unwrap().clone();
10772 records
10773 .create_dir_all(crate::records_dir::RESULTS_SUBDIR, 0o700)
10774 .unwrap();
10775 let home = enforcement.home_dir_path().unwrap().to_path_buf();
10776
10777 let workdir = tempfile::tempdir().unwrap();
10778 let result = run_bash_in(
10779 workdir.path(),
10780 &format!(
10781 r#"[ "$HOME" = '{home}' ] && echo home-is-the-folder
10782echo a > "$HOME/a" && mv "$HOME/a" "$HOME/b" && mkdir "$HOME/d" && mv "$HOME/b" "$HOME/d/c" \
10783 && rm "$HOME/d/c" && rmdir "$HOME/d" && echo home-writable
10784if echo x > "$MURMUR_RECORDS_DIR/out/result.txt"; then echo result-written; else echo result-refused; fi
10785if mkdir -p "$MURMUR_RECORDS_DIR/children/x"; then echo child-created; else echo child-refused; fi
10786if mv "$HOME" "$HOME.moved"; then echo home-moved; else echo home-move-refused; fi
10787"#,
10788 home = home.display()
10789 ),
10790 &policy,
10791 &enforcement,
10792 );
10793
10794 assert_eq!(
10795 result.stdout,
10796 "home-is-the-folder\nhome-writable\nresult-refused\nchild-refused\nhome-move-refused\n",
10797 "stderr: {}",
10798 result.stderr
10799 );
10800 assert!(home.is_dir());
10801 assert_eq!(std::fs::read_dir(&home).unwrap().count(), 0);
10802 assert!(!records.host_path("out/result.txt").exists());
10803 assert!(!records.host_path("children").exists());
10804 assert!(!home.with_extension("moved").exists());
10805 assert_eq!(std::fs::read_dir(workdir.path()).unwrap().count(), 0);
10806 }
10807
10808 #[test]
10817 fn children_records_are_not_reachable_by_a_parents_commands() {
10818 let test = "children_records_are_not_reachable_by_a_parents_commands";
10819 let policy = coreutils_policy(&["ls"]);
10820 let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
10821 return;
10822 };
10823 let runtime_dir = enforcement.runtime_dir_path().unwrap().to_path_buf();
10824 std::fs::write(runtime_dir.join("MURMUR.md"), "RUNTIME-DIR-CONTROL\n").unwrap();
10825 let base = tempfile::tempdir().unwrap();
10826 let child_records = base
10827 .path()
10828 .join("records/children/worker-0123456789abcdef/ses_child");
10829 let result_file = child_records.join("out/result.txt");
10830 std::fs::create_dir_all(result_file.parent().unwrap()).unwrap();
10831 std::fs::write(&result_file, "CHILD-ANSWER-7Q2W").unwrap();
10832
10833 let workdir = tempfile::tempdir().unwrap();
10834 let result = run_bash_in(
10835 workdir.path(),
10836 &format!(
10837 r#"cat "$MURMUR_RUNTIME_DIR/MURMUR.md"
10838if ls '{dir}'; then echo listed; else echo list-refused; fi
10839if cat '{file}'; then echo read; else echo read-refused; fi
10840"#,
10841 dir = child_records.display(),
10842 file = result_file.display(),
10843 ),
10844 &policy,
10845 &enforcement,
10846 );
10847
10848 assert_eq!(
10849 result.stdout, "RUNTIME-DIR-CONTROL\nlist-refused\nread-refused\n",
10850 "stderr: {}",
10851 result.stderr
10852 );
10853 assert!(
10854 !result.stdout.contains("CHILD-ANSWER") && !result.stderr.contains("CHILD-ANSWER"),
10855 "the child's answer reached the parent's command: {result:?}"
10856 );
10857 for line in result.stderr.lines() {
10858 assert!(
10859 line.contains("Permission denied") || line.contains("No such file or directory"),
10860 "an unexpected refusal: {}",
10861 result.stderr
10862 );
10863 }
10864 assert_eq!(result.stderr.lines().count(), 2, "{}", result.stderr);
10865 }
10866
10867 #[test]
10871 fn kernel_tier_at_abi_v1_refuses_cross_folder_links_with_exdev() {
10872 let test = "kernel_tier_at_abi_v1_refuses_cross_folder_links_with_exdev";
10873 let policy = coreutils_policy(&[]);
10874 let Some(mut enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
10875 return;
10876 };
10877 enforcement.landlock_abi = Some(1);
10878
10879 let temp = tempfile::tempdir().unwrap();
10880 let result = run_bash_in(
10881 temp.path(),
10882 r#"mkdir a b || exit 20
10883echo payload > a/x || exit 21
10884before=$(stat -c %i a/x)
10885mv a/x b/x || exit 22
10886[ -f b/x ] && [ ! -e a/x ] || exit 23
10887after=$(stat -c %i b/x)
10888echo "mv a/x b/x: inode $before -> $after"
10889[ "$before" != "$after" ] || exit 24
10890if ln b/x a/y; then exit 25; fi
10891exit 42
10892"#,
10893 &policy,
10894 &enforcement,
10895 );
10896
10897 assert_eq!(
10898 result.exit_code, 42,
10899 "the capsule must launch at ABI v1 and run the script to its own exit code: mv falls \
10900 back to copy-and-delete (a new inode) and ln fails"
10901 );
10902 assert!(
10903 result.stderr.contains("Invalid cross-device link"),
10904 "ln across workdir folders at ABI v1 must fail with EXDEV; stderr: {}",
10905 result.stderr
10906 );
10907 }
10908
10909 #[test]
10911 fn kernel_tier_refuses_rename_out_of_the_workdir() {
10912 let test = "kernel_tier_refuses_rename_out_of_the_workdir";
10913 let policy = coreutils_policy(&[]);
10914 let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 2) else {
10915 return;
10916 };
10917
10918 let (_base, work, outside) = work_and_outside();
10919 let script = format!(
10920 r#"echo payload > x || exit 20
10921if mv x '{outside}/x'; then exit 21; fi
10922if ln x '{outside}/x2'; then exit 22; fi
10923exit 0
10924"#,
10925 outside = outside.display()
10926 );
10927 let result = run_bash_in(&work, &script, &policy, &enforcement);
10928
10929 assert_eq!(
10930 result.exit_code, 0,
10931 "mv and ln out of the workdir must both fail"
10932 );
10933 assert!(
10934 work.join("x").exists(),
10935 "the source must stay in the workdir"
10936 );
10937 assert!(!outside.join("x").exists(), "nothing may be moved out");
10938 assert!(!outside.join("x2").exists(), "nothing may be linked out");
10939 }
10940
10941 #[test]
10943 fn kernel_tier_refuses_rename_into_the_workdir() {
10944 let test = "kernel_tier_refuses_rename_into_the_workdir";
10945 let policy = coreutils_policy(&[]);
10946 let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 2) else {
10947 return;
10948 };
10949
10950 let (_base, work, outside) = work_and_outside();
10951 std::fs::write(outside.join("y"), "original\n").unwrap();
10952 let script = format!(
10953 r#"if mv '{outside}/y' ./y; then exit 21; fi
10954if ln '{outside}/y' ./y2; then exit 22; fi
10955exit 0
10956"#,
10957 outside = outside.display()
10958 );
10959 let result = run_bash_in(&work, &script, &policy, &enforcement);
10960
10961 assert_eq!(
10962 result.exit_code, 0,
10963 "mv and ln into the workdir must both fail"
10964 );
10965 assert_eq!(
10966 std::fs::read_to_string(outside.join("y")).unwrap(),
10967 "original\n",
10968 "the outside file must be untouched"
10969 );
10970 assert!(!work.join("y").exists(), "nothing may be moved in");
10971 assert!(!work.join("y2").exists(), "nothing may be linked in");
10972 }
10973
10974 #[test]
10977 fn kernel_tier_truncate_outside_the_workdir_is_refused() {
10978 use std::os::unix::fs::PermissionsExt;
10979
10980 let test = "kernel_tier_truncate_outside_the_workdir_is_refused";
10981 let policy = coreutils_policy(&["perl"]);
10982 let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 3) else {
10983 return;
10984 };
10985
10986 let (_base, work, outside) = work_and_outside();
10987 let target = outside.join("t");
10988 std::fs::write(&target, "keep\n").unwrap();
10989 std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o644)).unwrap();
10990 let script = format!(
10991 r#"perl -e 'truncate($ARGV[0], 0) or die "$!\n"' '{}'"#,
10992 target.display()
10993 );
10994
10995 let refused = run_bash_in(&work, &script, &policy, &enforcement);
10996 assert_ne!(
10997 refused.exit_code, 0,
10998 "truncate(2) outside the workdir must be refused at ABI 3"
10999 );
11000 assert!(
11001 refused.stderr.contains("Permission denied"),
11002 "the refusal must be EACCES; stderr: {}",
11003 refused.stderr
11004 );
11005 assert_eq!(std::fs::read_to_string(&target).unwrap(), "keep\n");
11006
11007 let mut at_v1 = enforcement.clone();
11008 at_v1.landlock_abi = Some(1);
11009 let unmediated = run_bash_in(&work, &script, &policy, &at_v1);
11010 assert_eq!(
11011 unmediated.exit_code, 0,
11012 "at ABI v1 truncate(2) is not mediated, which is the gap this test pins as closed above"
11013 );
11014 assert_eq!(std::fs::read_to_string(&target).unwrap(), "");
11015 }
11016
11017 #[test]
11020 fn kernel_tier_truncate_inside_the_workdir_and_dev_null_still_work() {
11021 let test = "kernel_tier_truncate_inside_the_workdir_and_dev_null_still_work";
11022 let policy = coreutils_policy(&["truncate"]);
11023 let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 3) else {
11024 return;
11025 };
11026
11027 let temp = tempfile::tempdir().unwrap();
11028 let result = run_bash_in(
11029 temp.path(),
11030 r#"set -e
11031echo hello > f && truncate -s 0 f && [ ! -s f ]
11032echo again > f && echo over > f && [ "$(cat f)" = over ]
11033: > g
11034echo discard > /dev/null
11035echo ok
11036"#,
11037 &policy,
11038 &enforcement,
11039 );
11040
11041 assert_eq!(
11042 result.exit_code, 0,
11043 "truncating inside the workdir and writing to /dev/null must keep working"
11044 );
11045 }
11046
11047 #[test]
11058 #[ignore = "needs MURMUR_TEST_UV and MURMUR_TEST_WHEEL; see the doc comment"]
11059 fn kernel_tier_uv_installs_a_wheel_in_the_workdir() {
11060 let test = "kernel_tier_uv_installs_a_wheel_in_the_workdir";
11061 let input = |name: &str| {
11062 let value = std::env::var(name)
11063 .unwrap_or_else(|_| panic!("{test} needs {name} set to an absolute path"));
11064 let path = PathBuf::from(value);
11065 assert!(
11066 path.is_absolute() && path.is_file(),
11067 "{name}={} is not a file",
11068 path.display()
11069 );
11070 path
11071 };
11072 let uv = input("MURMUR_TEST_UV");
11073 let wheel = input("MURMUR_TEST_WHEEL");
11074 let python = std::fs::canonicalize("/usr/bin/python3").expect("host python3");
11075 let stdlib = std::process::Command::new(&python)
11076 .args([
11077 "-I",
11078 "-c",
11079 "import sysconfig; print(sysconfig.get_paths()['stdlib'])",
11080 ])
11081 .output()
11082 .expect("host python3 runs");
11083 let stdlib = String::from_utf8(stdlib.stdout).unwrap().trim().to_string();
11084
11085 let policy = CapabilityPolicy {
11086 shell_allow: vec![
11087 "bash".to_string(),
11088 uv.display().to_string(),
11089 python.display().to_string(),
11090 ],
11091 shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
11092 binary: "python3".to_string(),
11093 dirs: vec![murmur_artifact::InterpreterRuntimeDir {
11094 path: stdlib,
11095 list_dir: true,
11096 }],
11097 }],
11098 ..CapabilityPolicy::default()
11099 };
11100 let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 2) else {
11101 return;
11102 };
11103
11104 let wheel_name = wheel.file_name().unwrap().to_str().unwrap().to_string();
11105 let script = format!(
11106 r#"set -e
11107export UV_CACHE_DIR="$PWD/.uv-cache" UV_PYTHON_DOWNLOADS=never
11108'{uv}' venv --python '{python}' .venv
11109'{uv}' pip install --offline --no-index --python .venv/bin/python './{wheel_name}'
11110.venv/bin/python -c 'import six; print(six.__version__)'
11111"#,
11112 uv = uv.display(),
11113 python = python.display(),
11114 );
11115 let install = |enforcement: &ShellEnforcement| {
11116 let temp = tempfile::tempdir().unwrap();
11117 std::fs::copy(&wheel, temp.path().join(&wheel_name)).unwrap();
11118 run_bash_in(temp.path(), &script, &policy, enforcement)
11119 };
11120
11121 let at_probed = install(&enforcement);
11122 assert_eq!(
11123 at_probed.exit_code, 0,
11124 "uv must install the wheel at ABI {:?}",
11125 enforcement.landlock_abi
11126 );
11127 assert!(at_probed.stdout.contains("1.16.0"), "{}", at_probed.stdout);
11128
11129 let mut at_v1 = enforcement.clone();
11130 at_v1.landlock_abi = Some(1);
11131 let refused = install(&at_v1);
11132 let output = format!("{}{}", refused.stdout, refused.stderr);
11133 assert_ne!(refused.exit_code, 0, "at ABI v1 the install must fail");
11134 assert!(
11135 output.contains("os error 18") || output.contains("Invalid cross-device link"),
11136 "at ABI v1 the install must fail with EXDEV: {output}"
11137 );
11138 }
11139
11140 #[test]
11146 fn kernel_full_runs_nontrivial_shell_allowlist_but_a_pass_here_does_not_prove_the_landlock_fix()
11147 {
11148 if crate::network_namespace::skip_without_egress_namespace("kernel_full_runs_nontrivial_shell_allowlist_but_a_pass_here_does_not_prove_the_landlock_fix") {
11149 return;
11150 }
11151 let tier = detect_enforcement_tier();
11152 if tier != EnforcementTier::KernelFull {
11153 eprintln!(
11154 "SKIP — PROVES NOTHING ABOUT THE LANDLOCK FIX ON THIS HOST: \
11155 kernel_full_runs_nontrivial_shell_allowlist_... requires \
11156 EnforcementTier::KernelFull (Landlock ABI, kernel 5.13+); detected {tier:?}. \
11157 This run does NOT execute the derived-grant Landlock code path at all, so a \
11158 green result here is not evidence the workdir-scope exec bug is fixed — only a \
11159 real KernelFull Linux host can prove that."
11160 );
11161 return;
11162 }
11163
11164 let temp = tempfile::tempdir().unwrap();
11165 let policy = CapabilityPolicy {
11170 shell_allow: vec!["bash".to_string(), "cat".to_string()],
11171 ..CapabilityPolicy::default()
11172 };
11173 let exec_allow_paths = resolve_exec_allowlist(&policy.shell_allow);
11174 let enforcement = ShellEnforcement {
11175 tier,
11176 network_allow_ips: Vec::new(),
11177 unix_sockets_allowed: policy.unix_sockets_allowed,
11178 landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
11179 &exec_allow_paths,
11180 )),
11181 ..host_bounding_base()
11182 };
11183
11184 let result = crate::shell::execute_shell(
11187 "bash",
11188 &["-c", "echo landlock-grant-ok | cat"],
11189 &[],
11190 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
11191 &policy,
11192 &enforcement,
11193 )
11194 .expect("execute_shell should return Ok, not Err");
11195
11196 assert_eq!(
11197 result.exit_code, 0,
11198 "bash + cat are both allowlisted and dynamically linked; the derived Landlock grants \
11199 must let each exec and dynamic-link outside the workdir (stderr: {})",
11200 result.stderr
11201 );
11202 assert!(
11203 result.stdout.contains("landlock-grant-ok"),
11204 "the nested `cat` must actually run and pass its stdin through (stdout: {}, stderr: {})",
11205 result.stdout,
11206 result.stderr
11207 );
11208 }
11209
11210 #[test]
11211 fn kernel_full_denies_write_outside_workdir_but_a_pass_here_does_not_prove_the_landlock_fix() {
11212 if crate::network_namespace::skip_without_egress_namespace("kernel_full_denies_write_outside_workdir_but_a_pass_here_does_not_prove_the_landlock_fix") {
11213 return;
11214 }
11215 let tier = detect_enforcement_tier();
11216 if tier != EnforcementTier::KernelFull {
11217 eprintln!(
11218 "SKIP — PROVES NOTHING ABOUT THE LANDLOCK FIX ON THIS HOST: \
11219 kernel_full_denies_write_outside_workdir_... requires \
11220 EnforcementTier::KernelFull (Landlock ABI, kernel 5.13+); detected {tier:?}. \
11221 This run does NOT execute the Landlock code path, so a green result here is not \
11222 evidence writes outside the workdir are actually denied — only a real KernelFull \
11223 Linux host can prove that."
11224 );
11225 return;
11226 }
11227
11228 let workdir = tempfile::tempdir().unwrap();
11229 let outside = tempfile::tempdir().unwrap();
11232 let target = outside.path().join("should-not-be-written.txt");
11233
11234 let policy = CapabilityPolicy {
11235 shell_allow: vec!["bash".to_string()],
11236 ..CapabilityPolicy::default()
11237 };
11238 let exec_allow_paths = resolve_exec_allowlist(&policy.shell_allow);
11239 let enforcement = ShellEnforcement {
11240 tier,
11241 network_allow_ips: Vec::new(),
11242 unix_sockets_allowed: policy.unix_sockets_allowed,
11243 landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
11244 &exec_allow_paths,
11245 )),
11246 ..host_bounding_base()
11247 };
11248
11249 let script = format!("echo pwned > '{}'", target.display());
11253 let result = crate::shell::execute_shell(
11254 "bash",
11255 &["-c", &script],
11256 &[],
11257 &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
11258 &policy,
11259 &enforcement,
11260 )
11261 .expect("execute_shell should return Ok with a nonzero exit code, not Err");
11262
11263 assert!(
11264 !target.exists(),
11265 "a write to a path outside the Landlock-scoped workdir must be denied under KernelFull"
11266 );
11267 assert_ne!(
11268 result.exit_code, 0,
11269 "bash's redirection to an outside path must fail (Landlock denies the open-for-write)"
11270 );
11271 }
11272
11273 fn kernel_full_enforcement(
11277 tier: EnforcementTier,
11278 policy: &CapabilityPolicy,
11279 ) -> ShellEnforcement {
11280 let exec_allow_paths = resolve_exec_allowlist(&policy.shell_allow);
11281 let mut landlock_grants =
11282 LandlockGrant::non_listable_files(resolve_landlock_grants(&exec_allow_paths));
11283 landlock_grants.extend(resolve_interpreter_runtime_grants(
11284 &policy.shell_interpreter_runtime,
11285 ));
11286 ShellEnforcement {
11287 tier,
11288 network_allow_ips: Vec::new(),
11289 unix_sockets_allowed: policy.unix_sockets_allowed,
11290 landlock_grants,
11291 ..host_bounding_base()
11292 }
11293 }
11294
11295 #[test]
11300 fn with_harness_grants_adds_the_paths_and_the_exec_files_and_nothing_else() {
11301 let session = kernel_full_enforcement(EnforcementTier::KernelFull, &allow_only(&[]));
11302 let granted = crate::host_paths::HostPath {
11303 declared: "~/harness-state".to_string(),
11304 path: PathBuf::from("/srv/harness-state"),
11305 access: crate::host_paths::HostPathAccess::ReadWrite,
11306 credential_locations: Vec::new(),
11307 };
11308 let bash = std::fs::canonicalize("/bin/bash").expect("bash is installed");
11309 let scratch =
11312 tempfile::tempdir_in(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target"))
11313 .unwrap();
11314 let script = std::fs::canonicalize(scratch.path())
11315 .unwrap()
11316 .join("harness.sh");
11317 std::fs::write(&script, "#!/bin/bash\n").unwrap();
11318 let grants = HarnessEnforcementGrants {
11319 granted_paths: vec![granted.clone()],
11320 exec_files: vec![script.clone(), bash.clone()],
11321 };
11322 let harness = session.with_harness_grants(&grants).expect("no overlap");
11323
11324 assert_eq!(harness.host_paths.len(), session.host_paths.len() + 1);
11325 assert_eq!(harness.host_paths.last(), Some(&granted));
11326 let added: Vec<&LandlockGrant> = harness
11327 .landlock_grants
11328 .iter()
11329 .skip(session.landlock_grants.len())
11330 .collect();
11331 assert!(
11332 added
11333 .iter()
11334 .any(|grant| grant.path == bash && grant.executable),
11335 "the harness binary is an executable grant: {added:?}"
11336 );
11337 assert!(
11338 added.iter().all(|grant| !grant.list_dir),
11339 "every exec grant is a non-listable file: {added:?}"
11340 );
11341 assert!(
11342 added.iter().all(|grant| grant.path != granted.path),
11343 "a granted path is never an executable Landlock grant: {added:?}"
11344 );
11345 assert!(
11346 added
11347 .iter()
11348 .any(|grant| grant.path == script && grant.executable),
11349 "the harness script is an executable grant: {added:?}"
11350 );
11351 assert!(
11352 harness
11353 .sealed_bind_dirs
11354 .iter()
11355 .any(|dir| Some(dir.as_path()) == script.parent()),
11356 "{:?}",
11357 harness.sealed_bind_dirs
11358 );
11359
11360 assert!(session.host_paths.iter().all(|path| path != &granted));
11362 assert!(session
11363 .landlock_grants
11364 .iter()
11365 .all(|grant| grant.path != bash));
11366 }
11367
11368 #[test]
11371 fn with_harness_grants_adds_nothing_without_a_landlock_domain() {
11372 let grants = HarnessEnforcementGrants {
11373 granted_paths: vec![crate::host_paths::HostPath {
11374 declared: "/srv/x".to_string(),
11375 path: PathBuf::from("/srv/x"),
11376 access: crate::host_paths::HostPathAccess::Read,
11377 credential_locations: Vec::new(),
11378 }],
11379 exec_files: vec![PathBuf::from("/bin/sh")],
11380 };
11381 for tier in [
11382 EnforcementTier::Unconfined,
11383 EnforcementTier::EnvironmentOnly,
11384 ] {
11385 let session = ShellEnforcement {
11386 tier,
11387 ..host_bounding_base()
11388 };
11389 let harness = session.with_harness_grants(&grants).unwrap();
11390 assert_eq!(
11391 harness.host_paths.len(),
11392 session.host_paths.len(),
11393 "{tier:?}"
11394 );
11395 assert_eq!(
11396 harness.landlock_grants.len(),
11397 session.landlock_grants.len(),
11398 "{tier:?}"
11399 );
11400 }
11401 }
11402
11403 #[test]
11413 fn home_folder_and_tmp_in_the_records_dir_on_a_sealed_root() {
11414 if crate::network_namespace::skip_without_egress_namespace(
11415 "home_folder_and_tmp_in_the_records_dir_on_a_sealed_root",
11416 ) {
11417 return;
11418 }
11419 let probe = HostProbe::probe();
11420 if probe.tier() != EnforcementTier::KernelSealed {
11421 eprintln!(
11422 "SKIP — PROVES NOTHING ABOUT THE SEALED ROOT ON THIS HOST: \
11423 home_folder_and_tmp_in_the_records_dir_on_a_sealed_root needs a host that reaches \
11424 KernelSealed (unprivileged user namespaces plus a usable Landlock ABI); detected \
11425 {:?}. No composed root is built in this run.",
11426 probe.tier()
11427 );
11428 return;
11429 }
11430
11431 let accessible = tempfile::tempdir().unwrap();
11432 let policy = CapabilityPolicy {
11433 shell_allow: vec![
11434 "bash".to_string(),
11435 "cat".to_string(),
11436 "id".to_string(),
11437 "touch".to_string(),
11438 "getent".to_string(),
11439 "cut".to_string(),
11440 ],
11441 ..CapabilityPolicy::default()
11442 };
11443 let runtime = tempfile::tempdir().unwrap();
11444 let enforcement = ShellEnforcement::resolve_declared(
11445 &policy,
11446 murmur_artifact::ContainmentClass::Sealed,
11447 probe.clone(),
11448 )
11449 .expect("a sealed-capable host resolves sealed enforcement")
11450 .with_runtime_dir(test_runtime_dir(runtime.path()), None);
11451 let child_result = runtime
11454 .path()
11455 .join("children/worker-0123456789abcdef/out/result.txt");
11456 std::fs::create_dir_all(child_result.parent().unwrap()).unwrap();
11457 std::fs::write(&child_result, "the child's answer").unwrap();
11458 assert_eq!(enforcement.tier, EnforcementTier::KernelSealed);
11459 eprintln!(
11460 "home_folder_and_tmp_in_the_records_dir_on_a_sealed_root: tier {:?}, Landlock \
11461 ruleset ABI {:?} (kernel {:?})",
11462 enforcement.tier,
11463 enforcement.landlock_abi,
11464 probe.landlock_kernel_abi()
11465 );
11466 let probe_name = format!("sealed-probe-{}", std::process::id());
11467
11468 let result = crate::shell::execute_shell(
11469 "bash",
11470 &[
11471 "-c",
11472 &format!(
11473 "echo t > /tmp/{probe_name} && cat /tmp/{probe_name} && id -un && \
11474 printf '%s\\n' \"$TMPDIR\" && printf '%s\\n' \"$HOME\" && \
11475 getent passwd \"$(id -u)\" | cut -d: -f6 && cd ~ && touch f && echo touched && \
11476 {{ cat '{child}' 2>&1 || true; }}",
11477 child = child_result.display()
11478 ),
11479 ],
11480 &[],
11481 &crate::workdir_fs::WorkdirRoot::open(accessible.path()).unwrap(),
11482 &policy,
11483 &enforcement,
11484 )
11485 .expect("execute_shell returns Ok even when the command itself fails");
11486
11487 assert_eq!(
11488 result.exit_code, 0,
11489 "stdout: {} stderr: {}",
11490 result.stdout, result.stderr
11491 );
11492 let mut lines = result.stdout.lines();
11493 assert_eq!(
11494 lines.next(),
11495 Some("t"),
11496 "the capsule's /tmp must be writable"
11497 );
11498 assert!(
11499 lines.next().is_some_and(|name| !name.is_empty()),
11500 "`id -un` resolves only through the synthetic /etc/passwd the composed root binds: \
11501 {}",
11502 result.stdout
11503 );
11504 assert_eq!(
11505 lines.next(),
11506 Some("/tmp"),
11507 "TMPDIR names /tmp inside the root"
11508 );
11509 let home = enforcement.home_dir_path().unwrap().to_str().unwrap();
11510 assert_eq!(lines.next(), Some(home), "$HOME is the home folder");
11511 assert_eq!(lines.next(), Some(home), "passwd's home is the home folder");
11512 assert_eq!(
11513 lines.next(),
11514 Some("touched"),
11515 "~ is writable inside the root"
11516 );
11517 let child_read = lines.next().unwrap_or_default();
11518 assert!(
11519 child_read.contains("No such file or directory"),
11520 "a child's result is not in the composed root: {}",
11521 result.stdout
11522 );
11523 assert!(
11524 !result.stdout.contains("the child's answer"),
11525 "{}",
11526 result.stdout
11527 );
11528 assert!(Path::new(home).join("f").is_file());
11529
11530 let folder = enforcement.temp_dir_path().unwrap();
11531 assert_eq!(
11532 std::fs::read_to_string(folder.join(&probe_name)).unwrap(),
11533 "t\n",
11534 "the bytes written to /tmp land in the session's temporary folder"
11535 );
11536 assert!(
11537 !Path::new("/tmp").join(&probe_name).exists(),
11538 "nothing reaches the host's /tmp"
11539 );
11540 assert!(runtime
11541 .path()
11542 .join(crate::runtime_dir::SEALED_ETC_SUBDIR)
11543 .join("passwd")
11544 .is_file());
11545 assert_eq!(
11546 std::fs::read_dir(accessible.path()).unwrap().count(),
11547 0,
11548 "enabling sealed must add nothing to the work folder"
11549 );
11550 }
11551
11552 #[test]
11556 fn kernel_full_interpreter_runtime_list_dir_false_opens_file_but_denies_listing() {
11557 if crate::network_namespace::skip_without_egress_namespace(
11558 "kernel_full_interpreter_runtime_list_dir_false_opens_file_but_denies_listing",
11559 ) {
11560 return;
11561 }
11562 let tier = detect_enforcement_tier();
11563 if tier != EnforcementTier::KernelFull {
11564 eprintln!(
11565 "SKIP — PROVES NOTHING ABOUT THE LANDLOCK FIX ON THIS HOST: \
11566 kernel_full_interpreter_runtime_list_dir_false_... requires \
11567 EnforcementTier::KernelFull (Landlock ABI, kernel 5.13+); detected {tier:?}. \
11568 This run does NOT execute the interpreter_runtime Landlock code path, so a green \
11569 result here is not evidence list_dir:false behaves correctly — only a real \
11570 KernelFull Linux host can prove that."
11571 );
11572 return;
11573 }
11574
11575 let workdir = tempfile::tempdir().unwrap();
11576 let stdlib = tempfile::tempdir().unwrap();
11578 let module = stdlib.path().join("mod.py");
11579 std::fs::write(&module, "x = 1\n").unwrap();
11580
11581 let policy = CapabilityPolicy {
11582 shell_allow: vec!["bash".to_string(), "cat".to_string(), "ls".to_string()],
11583 shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
11584 binary: "bash".to_string(),
11585 dirs: vec![murmur_artifact::InterpreterRuntimeDir {
11586 path: stdlib.path().to_string_lossy().into_owned(),
11587 list_dir: false,
11588 }],
11589 }],
11590 ..CapabilityPolicy::default()
11591 };
11592 let enforcement = kernel_full_enforcement(tier, &policy);
11593
11594 let read = crate::shell::execute_shell(
11597 "bash",
11598 &["-c", &format!("cat '{}'", module.display())],
11599 &[],
11600 &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
11601 &policy,
11602 &enforcement,
11603 )
11604 .expect("execute_shell should return Ok");
11605 assert_eq!(
11606 read.exit_code, 0,
11607 "list_dir:false must still permit opening a known file inside the grant (stderr: {})",
11608 read.stderr
11609 );
11610 assert!(read.stdout.contains("x = 1"), "stdout: {}", read.stdout);
11611
11612 let list = crate::shell::execute_shell(
11614 "bash",
11615 &["-c", &format!("ls '{}'", stdlib.path().display())],
11616 &[],
11617 &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
11618 &policy,
11619 &enforcement,
11620 )
11621 .expect("execute_shell should return Ok with a nonzero exit code");
11622 assert_ne!(
11623 list.exit_code, 0,
11624 "list_dir:false must deny enumerating the granted directory's own entries"
11625 );
11626 }
11627
11628 #[test]
11629 fn kernel_full_interpreter_runtime_list_dir_true_lists_dir_but_not_its_parent() {
11630 if crate::network_namespace::skip_without_egress_namespace(
11631 "kernel_full_interpreter_runtime_list_dir_true_lists_dir_but_not_its_parent",
11632 ) {
11633 return;
11634 }
11635 let tier = detect_enforcement_tier();
11636 if tier != EnforcementTier::KernelFull {
11637 eprintln!(
11638 "SKIP — PROVES NOTHING ABOUT THE LANDLOCK FIX ON THIS HOST: \
11639 kernel_full_interpreter_runtime_list_dir_true_... requires \
11640 EnforcementTier::KernelFull (Landlock ABI, kernel 5.13+); detected {tier:?}. \
11641 This run does NOT execute the interpreter_runtime Landlock code path, so a green \
11642 result here is not evidence list_dir:true (and its non-widening to the parent) \
11643 behaves correctly — only a real KernelFull Linux host can prove that."
11644 );
11645 return;
11646 }
11647
11648 let workdir = tempfile::tempdir().unwrap();
11649 let parent = tempfile::tempdir().unwrap();
11651 let stdlib = parent.path().join("stdlib");
11652 std::fs::create_dir(&stdlib).unwrap();
11653 std::fs::write(stdlib.join("mod.py"), "x = 1\n").unwrap();
11654
11655 let policy = CapabilityPolicy {
11656 shell_allow: vec!["bash".to_string(), "ls".to_string()],
11657 shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
11658 binary: "bash".to_string(),
11659 dirs: vec![murmur_artifact::InterpreterRuntimeDir {
11660 path: stdlib.to_string_lossy().into_owned(),
11661 list_dir: true,
11662 }],
11663 }],
11664 ..CapabilityPolicy::default()
11665 };
11666 let enforcement = kernel_full_enforcement(tier, &policy);
11667
11668 let list = crate::shell::execute_shell(
11670 "bash",
11671 &["-c", &format!("ls '{}'", stdlib.display())],
11672 &[],
11673 &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
11674 &policy,
11675 &enforcement,
11676 )
11677 .expect("execute_shell should return Ok");
11678 assert_eq!(
11679 list.exit_code, 0,
11680 "list_dir:true must permit enumerating the granted directory (stderr: {})",
11681 list.stderr
11682 );
11683 assert!(list.stdout.contains("mod.py"), "stdout: {}", list.stdout);
11684
11685 let list_parent = crate::shell::execute_shell(
11688 "bash",
11689 &["-c", &format!("ls '{}'", parent.path().display())],
11690 &[],
11691 &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
11692 &policy,
11693 &enforcement,
11694 )
11695 .expect("execute_shell should return Ok with a nonzero exit code");
11696 assert_ne!(
11697 list_parent.exit_code, 0,
11698 "a grant on stdlib/ must not make its (ungranted) parent enumerable"
11699 );
11700 }
11701
11702 #[test]
11713 fn kernel_full_non_executable_grant_lists_its_tree_but_refuses_to_run_from_it() {
11714 if crate::network_namespace::skip_without_egress_namespace(
11715 "kernel_full_non_executable_grant_lists_its_tree_but_refuses_to_run_from_it",
11716 ) {
11717 return;
11718 }
11719 let host_tier = detect_enforcement_tier();
11720 if !matches!(
11721 host_tier,
11722 EnforcementTier::KernelFull | EnforcementTier::KernelSealed
11723 ) {
11724 eprintln!(
11725 "SKIP — PROVES NOTHING ABOUT THE LANDLOCK FIX ON THIS HOST: \
11726 kernel_full_non_executable_grant_... needs a usable Landlock ABI (kernel 5.13+); \
11727 detected {host_tier:?}. This run does NOT install a Landlock domain, so a green \
11728 result is not evidence that withholding Execute denies execve."
11729 );
11730 return;
11731 }
11732
11733 let workdir = tempfile::tempdir().unwrap();
11734 let tree = tempfile::tempdir().unwrap();
11736 std::fs::write(tree.path().join("entry.txt"), "x\n").unwrap();
11737 let runme = tree.path().join("runme");
11738 std::fs::copy("/bin/echo", &runme).unwrap();
11739
11740 let policy = CapabilityPolicy {
11741 shell_allow: vec!["bash".to_string(), "ls".to_string()],
11742 ..CapabilityPolicy::default()
11743 };
11744 let with_tree_grant = |executable: bool| {
11745 let mut enforcement = kernel_full_enforcement(EnforcementTier::KernelFull, &policy);
11746 enforcement.landlock_grants.push(LandlockGrant {
11747 path: tree.path().to_path_buf(),
11748 list_dir: true,
11749 executable,
11750 });
11751 enforcement
11752 };
11753 let run = |enforcement: &ShellEnforcement, command: String| {
11754 crate::shell::execute_shell(
11755 "bash",
11756 &["-c", &command],
11757 &[],
11758 &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
11759 &policy,
11760 enforcement,
11761 )
11762 .expect("execute_shell should return Ok even when the command itself fails")
11763 };
11764
11765 let denied = with_tree_grant(false);
11766 let list = run(&denied, format!("ls '{}'", tree.path().display()));
11767 assert_eq!(
11768 list.exit_code, 0,
11769 "a non-executable grant must still be enumerable — that is its entire purpose \
11770 (stderr: {})",
11771 list.stderr
11772 );
11773 assert!(list.stdout.contains("entry.txt"), "stdout: {}", list.stdout);
11774
11775 let exec_denied = run(&denied, format!("'{}' ran", runme.display()));
11776 assert_ne!(
11777 exec_denied.exit_code, 0,
11778 "withholding Execute must deny execve inside the granted tree; a program ran instead \
11779 (stdout: {})",
11780 exec_denied.stdout
11781 );
11782 assert!(
11783 !exec_denied.stdout.contains("ran"),
11784 "the binary produced output, so it executed: {}",
11785 exec_denied.stdout
11786 );
11787
11788 let allowed = with_tree_grant(true);
11791 let exec_allowed = run(&allowed, format!("'{}' ran", runme.display()));
11792 assert_eq!(
11793 exec_allowed.exit_code, 0,
11794 "the same tree granted Execute must run (stderr: {})",
11795 exec_allowed.stderr
11796 );
11797 assert!(
11798 exec_allowed.stdout.contains("ran"),
11799 "stdout: {}",
11800 exec_allowed.stdout
11801 );
11802 }
11803
11804 #[test]
11817 fn kernel_full_non_executable_file_grant_is_readable_and_survives_a_bad_list_dir_claim() {
11818 if crate::network_namespace::skip_without_egress_namespace(
11819 "kernel_full_non_executable_file_grant_is_readable_and_survives_a_bad_list_dir_claim",
11820 ) {
11821 return;
11822 }
11823 let host_tier = detect_enforcement_tier();
11824 if !matches!(
11825 host_tier,
11826 EnforcementTier::KernelFull | EnforcementTier::KernelSealed
11827 ) {
11828 eprintln!(
11829 "SKIP — PROVES NOTHING ABOUT THE LANDLOCK FIX ON THIS HOST: \
11830 kernel_full_non_executable_file_grant_... needs a usable Landlock ABI (kernel \
11831 5.13+); detected {host_tier:?}. This run does NOT install a Landlock domain, so a \
11832 green result is not evidence that a file grant reads, nor that a ReadDir claim on \
11833 a file is narrowed instead of refusing the launch."
11834 );
11835 return;
11836 }
11837
11838 let workdir = tempfile::tempdir().unwrap();
11839 let store = tempfile::tempdir().unwrap();
11841 let bundle = store.path().join("ca-certificates.crt");
11842 std::fs::write(&bundle, "-----BEGIN CERTIFICATE-----\n").unwrap();
11843
11844 let policy = CapabilityPolicy {
11845 shell_allow: vec!["bash".to_string(), "cat".to_string()],
11846 ..CapabilityPolicy::default()
11847 };
11848 let with_file_grant = |list_dir: bool| {
11849 let mut enforcement = kernel_full_enforcement(EnforcementTier::KernelFull, &policy);
11850 enforcement.landlock_grants.push(LandlockGrant {
11851 path: bundle.clone(),
11852 list_dir,
11853 executable: false,
11854 });
11855 enforcement
11856 };
11857 let read = |enforcement: &ShellEnforcement| {
11858 crate::shell::execute_shell(
11859 "bash",
11860 &["-c", &format!("cat '{}'", bundle.display())],
11861 &[],
11862 &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
11863 &policy,
11864 enforcement,
11865 )
11866 .expect("execute_shell should return Ok even when the command itself fails")
11867 };
11868
11869 let honest = read(&with_file_grant(false));
11870 assert_eq!(
11871 honest.exit_code, 0,
11872 "ReadFile alone must open a granted regular file — this is what the /etc allowlist's \
11873 file entries rely on (stderr: {})",
11874 honest.stderr
11875 );
11876 assert!(
11877 honest.stdout.contains("BEGIN CERTIFICATE"),
11878 "stdout: {}",
11879 honest.stdout
11880 );
11881
11882 let overclaimed = read(&with_file_grant(true));
11883 assert_eq!(
11884 overclaimed.exit_code, 0,
11885 "a list_dir:true claim on a regular file must be narrowed to ReadFile, not turned \
11886 into an EINVAL that refuses the launch (stderr: {})",
11887 overclaimed.stderr
11888 );
11889 assert!(
11890 overclaimed.stdout.contains("BEGIN CERTIFICATE"),
11891 "stdout: {}",
11892 overclaimed.stdout
11893 );
11894 }
11895
11896 struct ForceNoNewPrivsFailureGuard;
11906
11907 impl ForceNoNewPrivsFailureGuard {
11908 fn new() -> Self {
11909 FORCE_NO_NEW_PRIVS_FAILURE.with(|flag| flag.set(true));
11910 Self
11911 }
11912 }
11913
11914 impl Drop for ForceNoNewPrivsFailureGuard {
11915 fn drop(&mut self) {
11916 FORCE_NO_NEW_PRIVS_FAILURE.with(|flag| flag.set(false));
11917 }
11918 }
11919
11920 struct ForceLandlockFailureGuard;
11921
11922 impl ForceLandlockFailureGuard {
11923 fn new() -> Self {
11924 FORCE_LANDLOCK_FAILURE.with(|flag| flag.set(true));
11925 Self
11926 }
11927 }
11928
11929 impl Drop for ForceLandlockFailureGuard {
11930 fn drop(&mut self) {
11931 FORCE_LANDLOCK_FAILURE.with(|flag| flag.set(false));
11932 }
11933 }
11934
11935 fn kernel_full_empty_grants() -> ShellEnforcement {
11939 ShellEnforcement {
11940 tier: EnforcementTier::KernelFull,
11941 network_allow_ips: Vec::new(),
11942 unix_sockets_allowed: false,
11943 landlock_grants: Vec::new(),
11944 ..host_bounding_base()
11945 }
11946 }
11947
11948 fn workdir_resolution_error() -> String {
11952 let temp = tempfile::tempdir().unwrap();
11953 let missing = temp.path().join("does-not-exist-workdir");
11954 let marker = temp.path().join("never-spawned.marker");
11955 let mut command = std::process::Command::new("bash");
11956 command
11957 .arg("-c")
11958 .arg(format!("echo ran > '{}'", marker.display()));
11959
11960 let error = prepare_enforcement(&mut command, &kernel_full_empty_grants(), &missing)
11961 .expect_err("an unresolvable workdir must fail before fork()");
11962 assert!(
11963 !marker.exists(),
11964 "prepare_enforcement must never spawn a subprocess: {error}"
11965 );
11966 error
11967 }
11968
11969 fn child_setup_failure_error<G>(arm: impl FnOnce() -> G) -> String {
11973 child_setup_failure_typed(&kernel_full_empty_grants(), arm).to_string()
11974 }
11975
11976 fn child_setup_failure_typed<G>(
11981 enforcement: &ShellEnforcement,
11982 arm: impl FnOnce() -> G,
11983 ) -> crate::shell::ShellExecError {
11984 let temp = tempfile::tempdir().unwrap();
11985 let policy = CapabilityPolicy {
11986 shell_allow: vec!["bash".to_string()],
11987 ..CapabilityPolicy::default()
11988 };
11989 let marker = temp.path().join("spawned-anyway.marker");
11990 let script = format!("echo ran > '{}'", marker.display());
11991
11992 let _guard = arm();
11993 let error = crate::shell::execute_shell(
11994 "bash",
11995 &["-c", &script],
11996 &[],
11997 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
11998 &policy,
11999 enforcement,
12000 )
12001 .expect_err("a forced child-side setup failure must make execute_shell return Err");
12002 assert!(
12003 !marker.exists(),
12004 "no subprocess may run when pre_exec setup fails: {error}"
12005 );
12006 error
12007 }
12008
12009 #[test]
12010 fn pre_exec_workdir_resolution_failure_is_caught_before_fork() {
12011 let error = workdir_resolution_error();
12012 assert!(
12013 error.contains("workdir") && error.contains("does-not-exist-workdir"),
12014 "the error must name the workdir path that failed to resolve: {error}"
12015 );
12016 assert!(
12017 !error.contains("os error 22"),
12018 "must not collapse to the bare EINVAL string: {error}"
12019 );
12020 }
12021
12022 #[test]
12023 fn pre_exec_no_new_privs_failure_is_distinct_and_fails_closed() {
12024 if crate::network_namespace::skip_without_egress_namespace(
12025 "pre_exec_no_new_privs_failure_is_distinct_and_fails_closed",
12026 ) {
12027 return;
12028 }
12029 let error = child_setup_failure_error(ForceNoNewPrivsFailureGuard::new);
12030 assert!(
12031 error.contains("no_new_privs"),
12032 "the error must name the no_new_privs step specifically: {error}"
12033 );
12034 assert!(
12035 !error.contains("os error 22"),
12036 "must not collapse to the bare EINVAL string: {error}"
12037 );
12038 }
12039
12040 #[test]
12041 fn pre_exec_landlock_failure_is_distinct_and_fails_closed() {
12042 if crate::network_namespace::skip_without_egress_namespace(
12043 "pre_exec_landlock_failure_is_distinct_and_fails_closed",
12044 ) {
12045 return;
12046 }
12047 let error = child_setup_failure_error(ForceLandlockFailureGuard::new);
12048 assert!(
12049 error.contains("landlock"),
12050 "the error must name the Landlock construction step specifically: {error}"
12051 );
12052 assert!(
12053 !error.contains("os error 22"),
12054 "must not collapse to the bare EINVAL string: {error}"
12055 );
12056 }
12057
12058 #[test]
12059 fn pre_exec_setup_failures_produce_pairwise_distinct_messages() {
12060 if crate::network_namespace::skip_without_egress_namespace(
12061 "pre_exec_setup_failures_produce_pairwise_distinct_messages",
12062 ) {
12063 return;
12064 }
12065 let workdir_msg = workdir_resolution_error();
12066 let no_new_privs_msg = child_setup_failure_error(ForceNoNewPrivsFailureGuard::new);
12067 let landlock_msg = child_setup_failure_error(ForceLandlockFailureGuard::new);
12068
12069 let messages = [
12070 ("workdir", &workdir_msg),
12071 ("no_new_privs", &no_new_privs_msg),
12072 ("landlock", &landlock_msg),
12073 ];
12074 for (i, (left_name, left)) in messages.iter().enumerate() {
12075 for (right_name, right) in &messages[i + 1..] {
12076 assert_ne!(left, right, "{left_name} vs {right_name} must differ");
12077 }
12078 }
12079
12080 for message in [&workdir_msg, &no_new_privs_msg, &landlock_msg] {
12081 assert!(!message.is_empty(), "a distinct message must not be empty");
12082 assert!(
12083 !message.contains("os error 22"),
12084 "no failure may read as a bare EINVAL: {message}"
12085 );
12086 }
12087 }
12088
12089 #[test]
12095 fn a_composed_root_failure_is_typed_and_session_fatal_not_just_another_message() {
12096 if crate::network_namespace::skip_without_egress_namespace(
12097 "a_composed_root_failure_is_typed_and_session_fatal_not_just_another_message",
12098 ) {
12099 return;
12100 }
12101 let error =
12102 child_setup_failure_typed(&sealed_test_enforcement(), ForceSealedRootFailureGuard::new);
12103
12104 assert!(
12105 matches!(
12106 error,
12107 crate::shell::ShellExecError::SealedRootConstructionFailed { .. }
12108 ),
12109 "a sealed-root diagnostic must survive as its own variant, not collapse into \
12110 ShellExecError::Failed: {error}"
12111 );
12112 let fatal = error
12113 .session_fatal()
12114 .expect("a composed-root failure must end the session, not just the tool call");
12115 assert!(
12116 matches!(
12117 fatal,
12118 crate::errors::RuntimeError::SealedRootConstructionFailed { .. }
12119 ),
12120 "must map to the RuntimeError the CLI renders as E-RUN-014: {fatal}"
12121 );
12122 let rendered = error.to_string();
12123 assert!(
12124 rendered.contains("composed root") && rendered.contains("unshare"),
12125 "the message must name both the mechanism and the step that failed: {rendered}"
12126 );
12127 assert!(
12128 !rendered.contains("os error 22"),
12129 "must not collapse to the bare EINVAL string: {rendered}"
12130 );
12131 }
12132
12133 #[test]
12137 fn an_ordinary_pre_exec_failure_is_not_session_fatal() {
12138 let landlock =
12139 child_setup_failure_typed(&kernel_full_empty_grants(), ForceLandlockFailureGuard::new);
12140 assert!(
12141 landlock.session_fatal().is_none(),
12142 "a Landlock setup failure is the tool call's problem, not the session's: {landlock}"
12143 );
12144
12145 let no_new_privs = child_setup_failure_typed(
12146 &kernel_full_empty_grants(),
12147 ForceNoNewPrivsFailureGuard::new,
12148 );
12149 assert!(
12150 no_new_privs.session_fatal().is_none(),
12151 "a no_new_privs failure is the tool call's problem, not the session's: {no_new_privs}"
12152 );
12153 }
12154
12155 fn server_policy(extra: &[&str]) -> CapabilityPolicy {
12163 CapabilityPolicy {
12164 shell_allow: ["bash", "nc", "sleep"]
12165 .iter()
12166 .chain(extra)
12167 .map(|binary| binary.to_string())
12168 .collect(),
12169 ..CapabilityPolicy::default()
12170 }
12171 }
12172
12173 fn session_enforcement(policy: &CapabilityPolicy) -> ShellEnforcement {
12175 let enforcement = ShellEnforcement::resolve_declared(
12176 policy,
12177 murmur_artifact::ContainmentClass::Scoped,
12178 HostProbe::probe(),
12179 )
12180 .expect("the policy resolves")
12181 .with_test_session_dirs();
12182 assert_eq!(
12183 enforcement.tier,
12184 EnforcementTier::KernelFull,
12185 "a scoped capsule on this host runs on KernelFull"
12186 );
12187 enforcement
12188 }
12189
12190 fn one_shot_server(reply: &str, port: u16) -> String {
12193 format!(
12194 "(printf \"{reply}\\n\" | nc -N -l 127.0.0.1 {port}) >/dev/null 2>&1 & echo $! > server.pid"
12195 )
12196 }
12197
12198 fn retrying_client(port: u16) -> String {
12201 format!(
12202 "for i in {{1..50}}; do exec 3<>/dev/tcp/127.0.0.1/{port} 2>/dev/null && break; \
12203 sleep 0.1; done; read -r reply <&3; echo \"$reply\""
12204 )
12205 }
12206
12207 fn bash(
12208 script: &str,
12209 workdir: &Path,
12210 policy: &CapabilityPolicy,
12211 enforcement: &ShellEnforcement,
12212 ) -> Result<crate::shell::ShellResult, crate::shell::ShellExecError> {
12213 crate::shell::execute_shell(
12214 "bash",
12215 &["-c", script],
12216 &[],
12217 &crate::workdir_fs::WorkdirRoot::open(workdir).unwrap(),
12218 policy,
12219 enforcement,
12220 )
12221 }
12222
12223 fn pid_in(workdir: &Path, file: &str) -> i32 {
12224 std::fs::read_to_string(workdir.join(file))
12225 .unwrap_or_else(|error| panic!("{file} was not written: {error}"))
12226 .trim()
12227 .parse()
12228 .unwrap_or_else(|error| panic!("{file} holds no pid: {error}"))
12229 }
12230
12231 fn process_exists(pid: i32) -> bool {
12232 Path::new(&format!("/proc/{pid}")).exists()
12233 }
12234
12235 fn process_gone_within(pid: i32, timeout: std::time::Duration) -> bool {
12238 let deadline = std::time::Instant::now() + timeout;
12239 while process_exists(pid) {
12240 if std::time::Instant::now() >= deadline {
12241 return false;
12242 }
12243 std::thread::sleep(std::time::Duration::from_millis(20));
12244 }
12245 true
12246 }
12247
12248 fn held_port_policy() -> CapabilityPolicy {
12251 CapabilityPolicy {
12252 network_allow: vec!["127.0.0.1:8091".to_string()],
12253 ..server_policy(&[])
12254 }
12255 }
12256
12257 fn held_8091_line() -> String {
12259 crate::held_ports::held_port_line(8091, &["127.0.0.1:8091"])
12260 }
12261
12262 fn assert_a_held_bind_is_named(enforcement: &ShellEnforcement, policy: &CapabilityPolicy) {
12265 let temp = tempfile::tempdir().unwrap();
12266 assert!(
12267 enforcement.observed_held_ports().is_some(),
12268 "{:?} with a held port observes binds",
12269 enforcement.tier
12270 );
12271
12272 let server = bash("nc -l 127.0.0.1 8091", temp.path(), policy, enforcement)
12273 .expect("the server command runs");
12274 eprintln!(
12275 "tier {:?}; nc exit {}; stderr {:?}",
12276 enforcement.tier,
12277 server.exit_code,
12278 server.stderr.trim()
12279 );
12280 assert_ne!(server.exit_code, 0, "stdout: {}", server.stdout);
12281 assert!(
12282 server.stderr.contains("Address already in use"),
12283 "the kernel's EADDRINUSE is unchanged: {}",
12284 server.stderr
12285 );
12286 assert_eq!(enforcement.take_held_port_lines(), vec![held_8091_line()]);
12287 assert!(enforcement.take_held_port_lines().is_empty());
12288 }
12289
12290 #[test]
12293 fn a_bind_on_a_held_port_names_the_allow_entry() {
12294 if crate::network_namespace::skip_without_egress_namespace(
12295 "a_bind_on_a_held_port_names_the_allow_entry",
12296 ) {
12297 return;
12298 }
12299 let policy = held_port_policy();
12300 let enforcement = session_enforcement(&policy);
12301 assert_a_held_bind_is_named(&enforcement, &policy);
12302 enforcement.end_session();
12303 }
12304
12305 #[test]
12308 fn a_bind_on_a_held_port_names_the_allow_entry_on_a_sealed_root() {
12309 if crate::network_namespace::skip_without_egress_namespace(
12310 "a_bind_on_a_held_port_names_the_allow_entry_on_a_sealed_root",
12311 ) {
12312 return;
12313 }
12314 let probe = HostProbe::probe();
12315 if probe.tier() != EnforcementTier::KernelSealed {
12316 eprintln!(
12317 "SKIP — PROVES NOTHING ABOUT THE SEALED ROOT ON THIS HOST: \
12318 a_bind_on_a_held_port_names_the_allow_entry_on_a_sealed_root needs a host that \
12319 reaches KernelSealed (unprivileged user namespaces plus a usable Landlock ABI); \
12320 detected {:?}. No composed root is built in this run.",
12321 probe.tier()
12322 );
12323 return;
12324 }
12325 let policy = held_port_policy();
12326 let enforcement = ShellEnforcement::resolve_declared(
12327 &policy,
12328 murmur_artifact::ContainmentClass::Sealed,
12329 probe,
12330 )
12331 .expect("a sealed-capable host resolves sealed enforcement")
12332 .with_test_session_dirs();
12333 assert_eq!(enforcement.tier, EnforcementTier::KernelSealed);
12334 assert_a_held_bind_is_named(&enforcement, &policy);
12335 enforcement.end_session();
12336 }
12337
12338 #[test]
12341 fn a_server_on_a_port_no_entry_names_is_reachable_and_unreported() {
12342 if crate::network_namespace::skip_without_egress_namespace(
12343 "a_server_on_a_port_no_entry_names_is_reachable_and_unreported",
12344 ) {
12345 return;
12346 }
12347 let temp = tempfile::tempdir().unwrap();
12348 let policy = held_port_policy();
12349 let enforcement = session_enforcement(&policy);
12350 assert!(enforcement.observed_held_ports().is_some());
12351
12352 let server = bash(
12353 &one_shot_server("pong", 8092),
12354 temp.path(),
12355 &policy,
12356 &enforcement,
12357 )
12358 .expect("the server command runs");
12359 assert_eq!(server.exit_code, 0, "stderr: {}", server.stderr);
12360 let client = bash(&retrying_client(8092), temp.path(), &policy, &enforcement)
12361 .expect("the client command runs");
12362 assert_eq!(client.exit_code, 0, "stderr: {}", client.stderr);
12363 assert_eq!(client.stdout.trim(), "pong");
12364 assert!(enforcement.take_held_port_lines().is_empty());
12365 assert_eq!(enforcement.session_netns.creations(), 1);
12366
12367 enforcement.end_session();
12368 }
12369
12370 #[test]
12373 fn a_held_bind_from_a_backgrounded_server_is_reported_after_its_command_returns() {
12374 if crate::network_namespace::skip_without_egress_namespace(
12375 "a_held_bind_from_a_backgrounded_server_is_reported_after_its_command_returns",
12376 ) {
12377 return;
12378 }
12379 let temp = tempfile::tempdir().unwrap();
12380 let policy = held_port_policy();
12381 let enforcement = session_enforcement(&policy);
12382
12383 let started = std::time::Instant::now();
12384 let command = bash(
12385 "nc -l 127.0.0.1 8091 >/dev/null 2>&1 & echo started",
12386 temp.path(),
12387 &policy,
12388 &enforcement,
12389 )
12390 .expect("the command runs");
12391 assert_eq!(command.exit_code, 0, "stderr: {}", command.stderr);
12392 assert_eq!(command.stdout.trim(), "started");
12393 assert!(started.elapsed() < std::time::Duration::from_secs(5));
12394
12395 let mut lines = Vec::new();
12396 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
12397 while lines.is_empty() && std::time::Instant::now() < deadline {
12398 lines.extend(enforcement.take_held_port_lines());
12399 std::thread::sleep(std::time::Duration::from_millis(20));
12400 }
12401 std::thread::sleep(std::time::Duration::from_millis(300));
12402 lines.extend(enforcement.take_held_port_lines());
12403 assert_eq!(lines, vec![held_8091_line()]);
12404
12405 enforcement.end_session();
12406 }
12407
12408 #[test]
12411 fn no_bind_observer_without_a_held_port_or_on_a_tier_without_landlock() {
12412 if crate::network_namespace::skip_without_egress_namespace(
12413 "no_bind_observer_without_a_held_port_or_on_a_tier_without_landlock",
12414 ) {
12415 return;
12416 }
12417 let temp = tempfile::tempdir().unwrap();
12418 let empty = session_enforcement(&server_policy(&[]));
12419 assert!(empty.bind_observation.is_none());
12420 assert!(empty.observed_held_ports().is_none());
12421 let mut command = std::process::Command::new("true");
12422 let handle = prepare_enforcement(&mut command, &empty, temp.path())
12423 .expect("the enforcement prepares");
12424 assert!(
12425 matches!(
12426 handle,
12427 SupervisorHandle::Linux {
12428 bind_listener: None,
12429 ..
12430 }
12431 ),
12432 "{handle:?}"
12433 );
12434 drop(command);
12435 empty.end_session();
12436
12437 let held = session_enforcement(&held_port_policy());
12438 assert_eq!(
12439 held.observed_held_ports()
12440 .map(crate::held_ports::HeldPorts::ports),
12441 Some(vec![8091])
12442 );
12443 for tier in [
12444 EnforcementTier::KernelSeccompOnly,
12445 EnforcementTier::EnvironmentOnly,
12446 EnforcementTier::Unconfined,
12447 ] {
12448 let other = ShellEnforcement {
12449 tier,
12450 ..held.clone()
12451 };
12452 assert!(other.observed_held_ports().is_none(), "{tier:?}");
12453 assert!(!observes_binds_on(tier), "{tier:?}");
12454 }
12455 assert!(ShellEnforcement::environment_only()
12456 .observed_held_ports()
12457 .is_none());
12458 held.end_session();
12459 }
12460
12461 #[test]
12463 fn ending_the_session_stops_the_bind_observer_and_drops_pending_records() {
12464 if crate::network_namespace::skip_without_egress_namespace(
12465 "ending_the_session_stops_the_bind_observer_and_drops_pending_records",
12466 ) {
12467 return;
12468 }
12469 let temp = tempfile::tempdir().unwrap();
12470 let policy = held_port_policy();
12471 let enforcement = session_enforcement(&policy);
12472 let server = bash("nc -l 127.0.0.1 8091", temp.path(), &policy, &enforcement)
12473 .expect("the server command runs");
12474 assert_ne!(server.exit_code, 0);
12475 assert!(enforcement.session_netns.bind_observer_running());
12476
12477 enforcement.end_session();
12478 assert!(!enforcement.session_netns.bind_observer_running());
12479 assert!(enforcement.take_held_port_lines().is_empty());
12480 }
12481
12482 #[test]
12484 #[allow(clippy::print_stdout)]
12485 fn a_listener_started_by_one_command_answers_the_next() {
12486 if crate::network_namespace::skip_without_egress_namespace(
12487 "a_listener_started_by_one_command_answers_the_next",
12488 ) {
12489 return;
12490 }
12491 let temp = tempfile::tempdir().unwrap();
12492 let policy = server_policy(&[]);
12493 let enforcement = session_enforcement(&policy);
12494
12495 let started = std::time::Instant::now();
12496 let server = bash(
12497 &one_shot_server("pong", 8080),
12498 temp.path(),
12499 &policy,
12500 &enforcement,
12501 )
12502 .expect("the server command runs");
12503 let server_ms = started.elapsed().as_millis();
12504 assert_eq!(server.exit_code, 0, "stderr: {}", server.stderr);
12505 assert!(
12506 started.elapsed() < std::time::Duration::from_secs(5),
12507 "a backgrounded server with its output redirected must not hold the command open"
12508 );
12509
12510 let client = bash(&retrying_client(8080), temp.path(), &policy, &enforcement)
12511 .expect("the client command runs");
12512 println!(
12513 "tier {:?}; command 1 exit {} in {server_ms} ms; command 2 exit {} stdout {:?}; \
12514 namespaces created: {}",
12515 enforcement.tier,
12516 server.exit_code,
12517 client.exit_code,
12518 client.stdout.trim(),
12519 enforcement.session_netns.creations()
12520 );
12521 assert_eq!(client.exit_code, 0, "stderr: {}", client.stderr);
12522 assert_eq!(client.stdout.trim(), "pong");
12523 assert_eq!(enforcement.session_netns.creations(), 1);
12524
12525 enforcement.end_session();
12526 }
12527
12528 #[test]
12531 #[allow(clippy::print_stdout)]
12532 fn a_demoted_server_stays_reachable_from_later_commands() {
12533 use crate::detached::{DetachPolicy, DetachedRegistry, DetachedReport};
12534
12535 if crate::network_namespace::skip_without_egress_namespace(
12536 "a_demoted_server_stays_reachable_from_later_commands",
12537 ) {
12538 return;
12539 }
12540 let temp = tempfile::tempdir().unwrap();
12541 let base = tempfile::tempdir().unwrap();
12542 let policy = server_policy(&[]);
12543 let enforcement =
12544 session_enforcement(&policy).with_runtime_dir(test_runtime_dir(base.path()), None);
12545 let (registry, mut reports) = DetachedRegistry::new();
12546
12547 let outcome = crate::shell::run_shell(
12548 "nc",
12549 &["-l", "127.0.0.1", "8080"],
12550 &[],
12551 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
12552 &policy,
12553 &enforcement,
12554 Some(DetachPolicy {
12555 grace: Some(std::time::Duration::ZERO),
12556 delivered: true,
12557 registry: Arc::clone(®istry),
12558 command: "-l 127.0.0.1 8080".to_string(),
12559 context_id: "ctx_demoted_server".to_string(),
12560 provenance: None,
12561 abandoned: None,
12562 canceled: None,
12563 }),
12564 )
12565 .expect("the server command runs");
12566 let crate::shell::ShellOutcome::Detached(info) = outcome else {
12567 panic!("a zero grace period demotes the server, got {outcome:?}");
12568 };
12569
12570 let client = bash(
12571 "for i in {1..50}; do exec 3<>/dev/tcp/127.0.0.1/8080 2>/dev/null && break; \
12572 sleep 0.1; done; echo ping >&3",
12573 temp.path(),
12574 &policy,
12575 &enforcement,
12576 )
12577 .expect("the client command runs");
12578 assert_eq!(client.exit_code, 0, "stderr: {}", client.stderr);
12579
12580 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
12581 let completion = loop {
12582 match reports.try_recv() {
12583 Ok(DetachedReport::Completed(completion)) => break completion,
12584 Ok(DetachedReport::Lost(_)) => {}
12585 Err(_) if std::time::Instant::now() < deadline => {
12586 std::thread::sleep(std::time::Duration::from_millis(20));
12587 }
12588 Err(error) => panic!("the demoted server never completed: {error}"),
12589 }
12590 };
12591 let log = std::fs::read_to_string(&completion.output_path)
12592 .expect("the demoted command's log is written");
12593 println!(
12594 "demoted as {}; client exit {}; completion {} exit {}; log has ping: {}",
12595 info.work_id,
12596 client.exit_code,
12597 completion.work_id,
12598 completion.exit_code,
12599 log.contains("ping")
12600 );
12601 assert_eq!(completion.work_id, info.work_id);
12602 assert_eq!(completion.exit_code, 0, "log: {log}");
12603 assert!(log.contains("ping"), "log: {log}");
12604
12605 enforcement.end_session();
12606 }
12607
12608 #[test]
12613 #[allow(clippy::print_stdout)]
12614 fn a_truncated_log_reads_back_through_the_shell_tool_and_refuses_a_write() {
12615 if crate::network_namespace::skip_without_egress_namespace(
12618 "a_truncated_log_reads_back_through_the_shell_tool_and_refuses_a_write",
12619 ) {
12620 return;
12621 }
12622 let base = home_tempdir();
12623 let workdir = home_tempdir();
12624 let policy = CapabilityPolicy {
12625 shell_allow: vec!["bash".to_string(), "grep".to_string()],
12626 ..CapabilityPolicy::default()
12627 };
12628 let enforcement = ShellEnforcement::resolve_declared(
12629 &policy,
12630 murmur_artifact::ContainmentClass::Scoped,
12631 HostProbe::probe(),
12632 )
12633 .expect("the policy resolves")
12634 .with_runtime_dir(test_runtime_dir(base.path()), None);
12635 let landlock = matches!(
12636 enforcement.tier,
12637 EnforcementTier::KernelFull | EnforcementTier::KernelSealed
12638 );
12639 println!(
12640 "tier {:?}; landlock ABI {:?}",
12641 enforcement.tier,
12642 detect_landlock_abi()
12643 );
12644
12645 let printed = run_bash_in(
12647 workdir.path(),
12648 "for ((i = 1; i <= 20000; i++)); do echo $i; done",
12649 &policy,
12650 &enforcement,
12651 );
12652 assert_eq!(printed.exit_code, 0, "{}", printed.stderr);
12653 assert!(printed.truncated);
12654 let path = printed
12655 .full_output_path
12656 .clone()
12657 .expect("a truncated command names its log");
12658 assert!(path.starts_with(enforcement.records_dir_path().unwrap().to_str().unwrap()));
12659 let before = std::fs::read(&path).unwrap();
12660
12661 let through_bash = run_bash_in(
12662 workdir.path(),
12663 &format!("grep -x 20000 {path}"),
12664 &policy,
12665 &enforcement,
12666 );
12667 assert_eq!(through_bash.exit_code, 0, "{}", through_bash.stderr);
12668 assert_eq!(through_bash.stdout, "20000\n");
12669
12670 let through_grep = crate::shell::execute_shell(
12671 "grep",
12672 &["-x", "20000", &path],
12673 &[],
12674 &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
12675 &policy,
12676 &enforcement,
12677 )
12678 .unwrap();
12679 assert_eq!(through_grep.exit_code, 0, "{}", through_grep.stderr);
12680 assert_eq!(through_grep.stdout, "20000\n");
12681
12682 if landlock {
12683 let write = run_bash_in(
12684 workdir.path(),
12685 &format!("echo x >> {path}"),
12686 &policy,
12687 &enforcement,
12688 );
12689 assert_ne!(write.exit_code, 0, "a command appended to its own log");
12690 assert_eq!(std::fs::read(&path).unwrap(), before);
12691 } else {
12692 println!(
12693 "[SKIP-HOST] a_truncated_log_reads_back_through_the_shell_tool_and_refuses_a_write: \
12694 the write half needs Landlock; detected {:?}",
12695 enforcement.tier
12696 );
12697 }
12698 assert!(!workdir.path().join("logs").exists());
12699 enforcement.end_session();
12700 }
12701
12702 #[test]
12705 #[allow(clippy::print_stdout)]
12706 fn two_sessions_do_not_share_loopback() {
12707 if crate::network_namespace::skip_without_egress_namespace(
12708 "two_sessions_do_not_share_loopback",
12709 ) {
12710 return;
12711 }
12712 let port = {
12713 let probe = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
12714 probe.local_addr().unwrap().port()
12715 };
12716 let policy = server_policy(&[]);
12717 let (dir_a, dir_b) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
12718 let session_a = session_enforcement(&policy);
12719 let session_b = session_enforcement(&policy);
12720
12721 for (dir, session, reply) in [
12722 (&dir_a, &session_a, "pong-a"),
12723 (&dir_b, &session_b, "pong-b"),
12724 ] {
12725 let server = bash(&one_shot_server(reply, port), dir.path(), &policy, session)
12726 .expect("the server command runs");
12727 assert_eq!(server.exit_code, 0, "stderr: {}", server.stderr);
12728 }
12729 std::thread::sleep(std::time::Duration::from_millis(300));
12731 let server_in = |dir: &tempfile::TempDir, session: &ShellEnforcement| -> Option<i32> {
12736 let join = session
12737 .session_netns
12738 .establish(
12739 &session.egress_tcp_ports,
12740 crate::egress_proxy::EgressPolicy::new(
12741 session.network_allow_rules.clone(),
12742 session.network_allow_ips.clone(),
12743 ),
12744 session.observed_held_ports(),
12745 )
12746 .expect("the session's namespace is established");
12747 let pid_ns = std::fs::read_link(format!("/proc/self/fd/{}", join.pid_ns_fd()))
12748 .expect("the session's PID namespace handle is readable");
12749 let namespace_pid = pid_in(dir.path(), "server.pid").to_string();
12750 std::fs::read_dir("/proc")
12751 .expect("/proc is readable")
12752 .filter_map(Result::ok)
12753 .filter_map(|entry| entry.file_name().to_str()?.parse::<i32>().ok())
12754 .find(|pid| {
12755 std::fs::read_link(format!("/proc/{pid}/ns/pid"))
12756 .is_ok_and(|namespace| namespace == pid_ns)
12757 && std::fs::read_to_string(format!("/proc/{pid}/status")).is_ok_and(
12758 |status| {
12759 status
12760 .lines()
12761 .find_map(|line| line.strip_prefix("NSpid:"))
12762 .and_then(|fields| fields.split_whitespace().last())
12763 == Some(namespace_pid.as_str())
12764 },
12765 )
12766 })
12767 };
12768 let servers: std::collections::HashSet<i32> = [(&dir_a, &session_a), (&dir_b, &session_b)]
12769 .into_iter()
12770 .filter_map(|(dir, session)| server_in(dir, session))
12771 .collect();
12772 assert_eq!(
12773 servers.len(),
12774 2,
12775 "a server's bind on {port} failed: {servers:?}"
12776 );
12777
12778 let host = std::net::TcpStream::connect(("127.0.0.1", port));
12779 let host_error = host.expect_err("the host must not reach a session's loopback server");
12780 assert_eq!(host_error.kind(), std::io::ErrorKind::ConnectionRefused);
12781
12782 let client_b = bash(&retrying_client(port), dir_b.path(), &policy, &session_b)
12783 .expect("B's client runs");
12784 assert_eq!(client_b.exit_code, 0, "stderr: {}", client_b.stderr);
12785
12786 let second_b = bash(
12788 &format!("exec 3<>/dev/tcp/127.0.0.1/{port}"),
12789 dir_b.path(),
12790 &policy,
12791 &session_b,
12792 )
12793 .expect("B's second client runs");
12794
12795 let client_a = bash(&retrying_client(port), dir_a.path(), &policy, &session_a)
12796 .expect("A's client runs");
12797 println!(
12798 "port {port}; host connect: {host_error}; B read {:?}; B's second client exit {} \
12799 stderr {:?}; A read {:?}",
12800 client_b.stdout.trim(),
12801 second_b.exit_code,
12802 second_b.stderr.trim(),
12803 client_a.stdout.trim()
12804 );
12805 assert_eq!(client_b.stdout.trim(), "pong-b");
12806 assert_ne!(second_b.exit_code, 0);
12807 assert!(
12808 second_b.stderr.contains("Connection refused"),
12809 "stderr: {}",
12810 second_b.stderr
12811 );
12812 assert_eq!(client_a.exit_code, 0, "stderr: {}", client_a.stderr);
12813 assert_eq!(client_a.stdout.trim(), "pong-a");
12814
12815 session_a.end_session();
12816 session_b.end_session();
12817 }
12818
12819 #[test]
12822 #[allow(clippy::print_stdout)]
12823 fn the_session_proxy_serves_every_command() {
12824 if crate::network_namespace::skip_without_egress_namespace(
12825 "the_session_proxy_serves_every_command",
12826 ) {
12827 return;
12828 }
12829 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
12831 let port = listener.local_addr().unwrap().port();
12832 std::thread::spawn(move || {
12833 for stream in listener.incoming() {
12834 use std::io::Write;
12835 if let Ok(mut stream) = stream {
12836 let _ = stream.write_all(b"pong\n");
12837 }
12838 }
12839 });
12840
12841 let temp = tempfile::tempdir().unwrap();
12842 let policy = CapabilityPolicy {
12843 network_allow: vec![format!("http://127.0.0.1:{port}")],
12844 ..server_policy(&[])
12845 };
12846 let enforcement = session_enforcement(&policy);
12847 let through_proxy =
12848 format!("exec 3<>/dev/tcp/127.0.0.1/{port} && read -r reply <&3 && echo \"$reply\"");
12849
12850 let first = bash(&through_proxy, temp.path(), &policy, &enforcement).unwrap();
12851 let unlisted = bash(
12852 "exec 3<>/dev/tcp/10.255.255.1/9",
12853 temp.path(),
12854 &policy,
12855 &enforcement,
12856 )
12857 .unwrap();
12858 let third = bash(&through_proxy, temp.path(), &policy, &enforcement).unwrap();
12859 println!(
12860 "first {:?} exit {}; unlisted exit {} stderr {:?}; third {:?} exit {}; namespaces \
12861 created: {}",
12862 first.stdout.trim(),
12863 first.exit_code,
12864 unlisted.exit_code,
12865 unlisted.stderr.trim(),
12866 third.stdout.trim(),
12867 third.exit_code,
12868 enforcement.session_netns.creations()
12869 );
12870 assert_eq!(first.exit_code, 0, "stderr: {}", first.stderr);
12871 assert_eq!(first.stdout.trim(), "pong");
12872 assert_ne!(unlisted.exit_code, 0, "an unlisted destination must fail");
12873 assert_eq!(third.exit_code, 0, "stderr: {}", third.stderr);
12874 assert_eq!(third.stdout.trim(), "pong");
12875 assert_eq!(enforcement.session_netns.creations(), 1);
12876
12877 enforcement.end_session();
12878 }
12879
12880 #[test]
12883 #[allow(clippy::print_stdout)]
12884 fn concurrent_first_commands_share_one_namespace() {
12885 if crate::network_namespace::skip_without_egress_namespace(
12886 "concurrent_first_commands_share_one_namespace",
12887 ) {
12888 return;
12889 }
12890 let temp = tempfile::tempdir().unwrap();
12891 let policy = server_policy(&[]);
12892 let enforcement = session_enforcement(&policy);
12893 let servers = (8081..=8083)
12896 .map(|port| one_shot_server("pong", port).replace("server.pid", &format!("{port}.pid")))
12897 .collect::<Vec<_>>()
12898 .join("; ");
12899 let scripts = [
12900 servers,
12901 retrying_client(8081),
12902 retrying_client(8082),
12903 retrying_client(8083),
12904 ];
12905
12906 let barrier = std::sync::Barrier::new(scripts.len());
12907 let results: Vec<crate::shell::ShellResult> = std::thread::scope(|scope| {
12908 let handles: Vec<_> = scripts
12909 .iter()
12910 .map(|script| {
12911 let enforcement = enforcement.clone();
12912 let (barrier, policy, workdir) = (&barrier, &policy, temp.path());
12913 scope.spawn(move || {
12914 barrier.wait();
12915 bash(script, workdir, policy, &enforcement).expect("the command runs")
12916 })
12917 })
12918 .collect();
12919 handles
12920 .into_iter()
12921 .map(|handle| handle.join().unwrap())
12922 .collect()
12923 });
12924
12925 for (index, result) in results.iter().enumerate() {
12926 println!(
12927 "command {index}: exit {} stdout {:?}",
12928 result.exit_code,
12929 result.stdout.trim()
12930 );
12931 assert_eq!(result.exit_code, 0, "stderr: {}", result.stderr);
12932 }
12933 for client in &results[1..] {
12934 assert_eq!(client.stdout.trim(), "pong");
12935 }
12936 println!(
12937 "namespaces created: {}",
12938 enforcement.session_netns.creations()
12939 );
12940 assert_eq!(enforcement.session_netns.creations(), 1);
12941
12942 enforcement.end_session();
12943 }
12944
12945 #[test]
12948 #[allow(clippy::print_stdout)]
12949 fn ending_the_session_kills_everything_it_started() {
12950 if crate::cgroup::skip_without_host_support(
12951 "ending_the_session_kills_everything_it_started",
12952 ) {
12953 return;
12954 }
12955 let temp = tempfile::tempdir().unwrap();
12956 let policy = server_policy(&["setsid"]);
12957 let scope =
12958 crate::cgroup::prepare_scope(true, &policy.resources, "ses_end_kills", temp.path())
12959 .expect("a delegated scope")
12960 .scope
12961 .expect("a required scope on Linux is never None");
12962 let enforcement =
12963 session_enforcement(&policy).with_host_bounding(Some(Arc::clone(&scope)), None);
12964
12965 let started = bash(
12966 "setsid sleep 300 >/dev/null 2>&1 & echo $! > sleeper.pid; \
12967 nc -l 127.0.0.1 8080 >/dev/null 2>&1 & echo $! > server.pid",
12968 temp.path(),
12969 &policy,
12970 &enforcement,
12971 )
12972 .expect("the command runs");
12973 assert_eq!(started.exit_code, 0, "stderr: {}", started.stderr);
12974 let sleeper = host_pid(&enforcement, pid_in(temp.path(), "sleeper.pid"));
12976 let server = host_pid(&enforcement, pid_in(temp.path(), "server.pid"));
12977 assert!(process_exists(sleeper), "the setsid sleeper is running");
12978 assert!(process_exists(server), "the background server is running");
12979
12980 let ending = std::time::Instant::now();
12981 enforcement.end_session();
12982 let took = ending.elapsed();
12983
12984 let sleeper_gone = process_gone_within(sleeper, std::time::Duration::from_secs(2));
12985 let server_gone = process_gone_within(server, std::time::Duration::from_secs(2));
12986 let procs = std::fs::read_to_string(scope.path().join("cgroup.procs")).unwrap();
12987 let after = bash("true", temp.path(), &policy, &enforcement);
12988 println!(
12989 "end_session took {} ms; sleeper {sleeper} gone: {sleeper_gone}; server {server} \
12990 gone: {server_gone}; cgroup.procs after: {:?}; next command: {:?}",
12991 took.as_millis(),
12992 procs.trim(),
12993 after.as_ref().map(|result| result.exit_code)
12994 );
12995 assert!(took < std::time::Duration::from_secs(3), "took {took:?}");
12996 assert!(sleeper_gone, "the setsid sleeper outlived its session");
12997 assert!(server_gone, "the background server outlived its session");
12998 assert!(procs.trim().is_empty(), "cgroup.procs: {procs}");
12999 let refusal = after
13000 .expect_err("a command after the session ended must be refused")
13001 .to_string();
13002 assert!(refusal.contains("session has ended"), "{refusal}");
13003 }
13004
13005 fn home_tempdir() -> tempfile::TempDir {
13013 tempfile::tempdir_in(std::env::var_os("HOME").expect("HOME is set")).unwrap()
13014 }
13015
13016 fn mapped_enforcement(
13021 test: &str,
13022 policy: &CapabilityPolicy,
13023 class: murmur_artifact::ContainmentClass,
13024 base: &Path,
13025 workdir: &Path,
13026 ) -> Option<ShellEnforcement> {
13027 if crate::network_namespace::skip_without_egress_namespace(test) {
13028 return None;
13029 }
13030 let probe = HostProbe::probe();
13031 let mount_namespace_usable = probe.mount_namespace_usable();
13032 let enforcement = ShellEnforcement::resolve_declared(policy, class, probe)
13033 .unwrap()
13034 .with_runtime_dir(test_runtime_dir(base), None);
13035 let mapping =
13036 crate::temp_dir::temp_mapping(enforcement.tier, mount_namespace_usable, workdir);
13037 if mapping != crate::temp_dir::TempMapping::MountNamespace {
13038 crate::runtime_err!(
13039 "[SKIP-HOST] {test}: this host maps no private /tmp at {class:?} ({mapping:?} on \
13040 {:?})",
13041 enforcement.tier
13042 );
13043 return None;
13044 }
13045 Some(enforcement.with_temp_mapping(mapping))
13046 }
13047
13048 fn unmapped_kernel_full_enforcement(
13051 test: &str,
13052 policy: &CapabilityPolicy,
13053 base: &Path,
13054 workdir: &Path,
13055 ) -> Option<ShellEnforcement> {
13056 if crate::network_namespace::skip_without_egress_namespace(test) {
13057 return None;
13058 }
13059 let live = HostProbe::probe();
13060 if !matches!(
13061 live.tier(),
13062 EnforcementTier::KernelFull | EnforcementTier::KernelSealed
13063 ) {
13064 crate::runtime_err!(
13065 "[SKIP-HOST] {test}: needs Landlock; detected {:?}",
13066 live.tier()
13067 );
13068 return None;
13069 }
13070 let refused = crate::namespace_probe::MountAttempt::Failed(
13071 crate::namespace_probe::MountAttemptError::Step {
13072 step: crate::namespace_probe::MountStep::UnshareMount,
13073 errno: libc::EPERM,
13074 },
13075 );
13076 let probe = HostProbe::from_parts(
13077 Some(true),
13078 crate::namespace_probe::NamespaceReading {
13079 mount: refused.clone(),
13080 sealed_root: refused,
13081 ..live.namespaces().clone()
13082 },
13083 )
13084 .with_landlock_kernel_abi(live.landlock_kernel_abi());
13085 assert!(!probe.mount_namespace_usable());
13086 let enforcement = ShellEnforcement::resolve_declared(
13087 policy,
13088 murmur_artifact::ContainmentClass::Scoped,
13089 probe,
13090 )
13091 .unwrap()
13092 .with_runtime_dir(test_runtime_dir(base), None);
13093 assert_eq!(enforcement.tier, EnforcementTier::KernelFull);
13094 let mapping = crate::temp_dir::temp_mapping(enforcement.tier, false, workdir);
13095 assert_eq!(
13096 mapping,
13097 crate::temp_dir::TempMapping::NotMapped(
13098 crate::temp_dir::NotMappedReason::NoMountNamespace
13099 )
13100 );
13101 Some(enforcement.with_temp_mapping(mapping))
13102 }
13103
13104 fn unique_tmp_name(label: &str) -> String {
13106 static NEXT: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
13107 format!(
13108 "murmur-probe-{label}-{}-{}",
13109 std::process::id(),
13110 NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
13111 )
13112 }
13113
13114 #[test]
13118 fn the_tmp_map_spec_reexposes_exact_paths_beneath_tmp_read_only_but_the_workdir() {
13119 let base = tempfile::tempdir().unwrap();
13120 let workdir = tempfile::tempdir().unwrap();
13121 let grant_dir = tempfile::tempdir().unwrap();
13122 let grant = grant_dir.path().join("bin");
13123 std::fs::write(&grant, b"#!/bin/sh\n").unwrap();
13124 let mut enforcement = ShellEnforcement {
13125 tier: EnforcementTier::KernelFull,
13126 ..ShellEnforcement::environment_only().with_test_session_dirs()
13127 }
13128 .with_runtime_dir(test_runtime_dir(base.path()), None)
13129 .with_temp_mapping(crate::temp_dir::TempMapping::MountNamespace);
13130 enforcement.landlock_grants =
13131 LandlockGrant::non_listable_files(vec![grant.clone(), PathBuf::from("/usr/bin/env")]);
13132 let folder = enforcement
13133 .runtime_dir
13134 .as_ref()
13135 .and_then(|roots| roots.temp.as_ref())
13136 .unwrap()
13137 .path_fd()
13138 .unwrap();
13139
13140 let spec = build_tmp_map_spec(&enforcement, &folder, workdir.path()).unwrap();
13141
13142 let canonical = |path: &Path| {
13143 std::fs::canonicalize(path)
13144 .unwrap()
13145 .to_string_lossy()
13146 .into_owned()
13147 };
13148 let mut expected = vec![
13149 (canonical(workdir.path()), false),
13150 (
13151 canonical(&base.path().join(crate::runtime_dir::RUNTIME_SUBDIR)),
13152 true,
13153 ),
13154 (
13155 canonical(&base.path().join(crate::records_dir::RECORDS_DIR_NAME)),
13156 true,
13157 ),
13158 (
13159 canonical(
13160 &base
13161 .path()
13162 .join(crate::records_dir::RECORDS_DIR_NAME)
13163 .join(crate::records_dir::HOME_SUBDIR),
13164 ),
13165 false,
13166 ),
13167 (canonical(&grant), true),
13168 ];
13169 expected.sort();
13170 assert_eq!(spec.reexposed_sources(), expected);
13171 }
13172
13173 #[test]
13176 fn a_literal_tmp_path_reaches_the_session_temp_folder() {
13177 const TEST: &str = "a_literal_tmp_path_reaches_the_session_temp_folder";
13178 for class in [
13179 murmur_artifact::ContainmentClass::Scoped,
13180 murmur_artifact::ContainmentClass::Advisory,
13181 ] {
13182 let base = home_tempdir();
13183 let workdir = home_tempdir();
13184 let policy = coreutils_policy(&[]);
13185 let Some(enforcement) =
13186 mapped_enforcement(TEST, &policy, class, base.path(), workdir.path())
13187 else {
13188 return;
13189 };
13190 let name = unique_tmp_name("literal");
13191 let result = run_bash_in(
13192 workdir.path(),
13193 &format!("echo x > /tmp/{name} && cat /tmp/{name} && printf '%s\\n' \"$TMPDIR\""),
13194 &policy,
13195 &enforcement,
13196 );
13197 assert_eq!(result.exit_code, 0, "{class:?}: {}", result.stderr);
13198 assert_eq!(result.stdout, "x\n/tmp\n", "{class:?}");
13199 let folder = enforcement.temp_dir_path().unwrap();
13200 assert_eq!(std::fs::read_to_string(folder.join(&name)).unwrap(), "x\n");
13201 assert!(
13202 !Path::new("/tmp").join(&name).exists(),
13203 "{class:?}: the host's /tmp gained {name}"
13204 );
13205 }
13206 }
13207
13208 #[test]
13211 fn a_literal_tmp_path_reaches_the_session_temp_folder_on_the_seccomp_only_tier() {
13212 const TEST: &str =
13213 "a_literal_tmp_path_reaches_the_session_temp_folder_on_the_seccomp_only_tier";
13214 let base = home_tempdir();
13215 let workdir = home_tempdir();
13216 let policy = coreutils_policy(&[]);
13217 let Some(enforcement) = mapped_enforcement(
13218 TEST,
13219 &policy,
13220 murmur_artifact::ContainmentClass::Scoped,
13221 base.path(),
13222 workdir.path(),
13223 ) else {
13224 return;
13225 };
13226 let enforcement = ShellEnforcement {
13227 tier: EnforcementTier::KernelSeccompOnly,
13228 ..enforcement
13229 };
13230 assert_eq!(
13231 enforcement.installed_temp_mapping(),
13232 crate::temp_dir::TempMapping::MountNamespace
13233 );
13234 let name = unique_tmp_name("seccomp-only");
13235 let result = run_bash_in(
13236 workdir.path(),
13237 &format!("echo x > /tmp/{name} && cat /tmp/{name} && printf '%s\\n' \"$TMPDIR\""),
13238 &policy,
13239 &enforcement,
13240 );
13241 assert_eq!(result.exit_code, 0, "{}", result.stderr);
13242 assert_eq!(result.stdout, "x\n/tmp\n");
13243 let folder = enforcement.temp_dir_path().unwrap();
13244 assert_eq!(std::fs::read_to_string(folder.join(&name)).unwrap(), "x\n");
13245 assert!(
13246 !Path::new("/tmp").join(&name).exists(),
13247 "the host's /tmp gained {name}"
13248 );
13249 }
13250
13251 #[test]
13253 fn two_sessions_do_not_share_tmp() {
13254 const TEST: &str = "two_sessions_do_not_share_tmp";
13255 let policy = coreutils_policy(&[]);
13256 let (base_a, base_b) = (home_tempdir(), home_tempdir());
13257 let (work_a, work_b) = (home_tempdir(), home_tempdir());
13258 let class = murmur_artifact::ContainmentClass::Scoped;
13259 let Some(a) = mapped_enforcement(TEST, &policy, class, base_a.path(), work_a.path()) else {
13260 return;
13261 };
13262 let Some(b) = mapped_enforcement(TEST, &policy, class, base_b.path(), work_b.path()) else {
13263 return;
13264 };
13265 let name = unique_tmp_name("shared");
13266
13267 let first = run_bash_in(work_a.path(), &format!("echo a > /tmp/{name}"), &policy, &a);
13268 assert_eq!(first.exit_code, 0, "{}", first.stderr);
13269 let second = run_bash_in(
13270 work_b.path(),
13271 &format!("test ! -e /tmp/{name} && echo b > /tmp/{name} && cat /tmp/{name}"),
13272 &policy,
13273 &b,
13274 );
13275 assert_eq!(second.exit_code, 0, "{}", second.stderr);
13276 assert_eq!(second.stdout, "b\n");
13277 let again = run_bash_in(work_a.path(), &format!("cat /tmp/{name}"), &policy, &a);
13278 assert_eq!(again.stdout, "a\n", "{}", again.stderr);
13279 assert_ne!(a.temp_dir_path(), b.temp_dir_path());
13280 assert!(!Path::new("/tmp").join(&name).exists());
13281 }
13282
13283 #[test]
13286 fn a_workdir_under_tmp_stays_reachable_when_tmp_is_mapped() {
13287 const TEST: &str = "a_workdir_under_tmp_stays_reachable_when_tmp_is_mapped";
13288 let base = tempfile::tempdir().unwrap();
13289 let workdir = tempfile::tempdir().unwrap();
13290 assert!(workdir.path().starts_with(crate::temp_dir::canonical_tmp()));
13291 let policy = coreutils_policy(&[]);
13292 let Some(enforcement) = mapped_enforcement(
13293 TEST,
13294 &policy,
13295 murmur_artifact::ContainmentClass::Scoped,
13296 base.path(),
13297 workdir.path(),
13298 ) else {
13299 return;
13300 };
13301 std::fs::write(
13302 enforcement.runtime_dir_path().unwrap().join("probe"),
13303 b"runtime\n",
13304 )
13305 .unwrap();
13306 let name = unique_tmp_name("under");
13307 let result = run_bash_in(
13308 workdir.path(),
13309 &format!(
13310 "echo a > \"$(pwd)/f\" && cat \"$(pwd)/f\" && cat \"$MURMUR_RUNTIME_DIR/probe\" && \
13311 echo h > \"$HOME/h\" && cat \"$HOME/h\" && echo t > /tmp/{name} && \
13312 cat /tmp/{name}"
13313 ),
13314 &policy,
13315 &enforcement,
13316 );
13317 assert_eq!(result.exit_code, 0, "{}", result.stderr);
13318 assert_eq!(result.stdout, "a\nruntime\nh\nt\n");
13319 assert_eq!(
13320 std::fs::read_to_string(workdir.path().join("f")).unwrap(),
13321 "a\n"
13322 );
13323
13324 let sibling = base.path().join("sibling");
13328 std::fs::write(&sibling, b"host\n").unwrap();
13329 let record = enforcement.records_dir_path().unwrap().join("trace.jsonl");
13330 std::fs::write(&record, b"{}\n").unwrap();
13331 let refused = run_bash_in(
13332 workdir.path(),
13333 &format!(
13334 "test ! -e {sibling} && echo absent; echo z >> \"$MURMUR_RECORDS_DIR/trace.jsonl\"",
13335 sibling = sibling.display()
13336 ),
13337 &policy,
13338 &enforcement,
13339 );
13340 assert!(refused.stdout.starts_with("absent\n"), "{}", refused.stderr);
13341 assert_ne!(refused.exit_code, 0);
13342 assert!(
13343 refused.stderr.contains("Read-only file system"),
13344 "{}",
13345 refused.stderr
13346 );
13347 assert_eq!(std::fs::read(&record).unwrap(), b"{}\n");
13348 let folder = enforcement.temp_dir_path().unwrap();
13349 assert_eq!(std::fs::read_to_string(folder.join(&name)).unwrap(), "t\n");
13350 assert!(!Path::new("/tmp").join(&name).exists());
13351 }
13352
13353 #[test]
13356 fn without_a_mount_namespace_tmp_is_refused_and_tmpdir_works() {
13357 const TEST: &str = "without_a_mount_namespace_tmp_is_refused_and_tmpdir_works";
13358 let base = home_tempdir();
13359 let workdir = home_tempdir();
13360 let policy = coreutils_policy(&[]);
13361 let Some(enforcement) =
13362 unmapped_kernel_full_enforcement(TEST, &policy, base.path(), workdir.path())
13363 else {
13364 return;
13365 };
13366 let name = unique_tmp_name("refused");
13367 let refused = run_bash_in(
13368 workdir.path(),
13369 &format!("echo y > /tmp/{name}"),
13370 &policy,
13371 &enforcement,
13372 );
13373 assert_ne!(refused.exit_code, 0);
13374 assert!(
13375 refused.stderr.contains("Permission denied"),
13376 "{}",
13377 refused.stderr
13378 );
13379 assert!(!Path::new("/tmp").join(&name).exists());
13380
13381 let folder = enforcement.temp_dir_path().unwrap().to_path_buf();
13382 let works = run_bash_in(
13383 workdir.path(),
13384 "echo z > \"$TMPDIR/m\" && cat \"$TMPDIR/m\" && printf '%s\\n' \"$TMPDIR\" \"$TMP\" \
13385 \"$TEMP\"",
13386 &policy,
13387 &enforcement,
13388 );
13389 assert_eq!(works.exit_code, 0, "{}", works.stderr);
13390 let folder_text = folder.to_string_lossy();
13391 assert_eq!(
13392 works.stdout,
13393 format!("z\n{folder_text}\n{folder_text}\n{folder_text}\n")
13394 );
13395 assert_eq!(std::fs::read_to_string(folder.join("m")).unwrap(), "z\n");
13396 }
13397
13398 #[test]
13401 fn a_record_cannot_be_moved_or_linked_into_tmp() {
13402 const TEST: &str = "a_record_cannot_be_moved_or_linked_into_tmp";
13403 let policy = coreutils_policy(&[]);
13404 for mapped in [true, false] {
13405 let base = home_tempdir();
13406 let workdir = home_tempdir();
13407 let enforcement = if mapped {
13408 mapped_enforcement(
13409 TEST,
13410 &policy,
13411 murmur_artifact::ContainmentClass::Scoped,
13412 base.path(),
13413 workdir.path(),
13414 )
13415 } else {
13416 unmapped_kernel_full_enforcement(TEST, &policy, base.path(), workdir.path())
13417 };
13418 let Some(enforcement) = enforcement else {
13419 return;
13420 };
13421 let records = enforcement.records_dir_path().unwrap().to_path_buf();
13422 let record = records.join("trace.jsonl");
13423 std::fs::write(&record, b"{\"event\":\"session_start\"}\n").unwrap();
13424 let before = std::fs::read(&record).unwrap();
13425 for script in [
13426 "ln \"$MURMUR_RECORDS_DIR/trace.jsonl\" /tmp/l",
13427 "ln \"$MURMUR_RECORDS_DIR/trace.jsonl\" \"$TMPDIR/l2\"",
13428 "ln \"$MURMUR_RECORDS_DIR/trace.jsonl\" \"$MURMUR_RECORDS_DIR/tmp/l3\"",
13429 "mv \"$MURMUR_RECORDS_DIR/trace.jsonl\" \"$TMPDIR/m\"",
13430 "mv \"$MURMUR_RECORDS_DIR/trace.jsonl\" \"$MURMUR_RECORDS_DIR/tmp/m2\"",
13431 "echo z >> \"$MURMUR_RECORDS_DIR/trace.jsonl\"",
13432 "mv \"$MURMUR_RECORDS_DIR/tmp\" \"$MURMUR_RECORDS_DIR/tmp-moved\"",
13433 ] {
13434 let result = run_bash_in(workdir.path(), script, &policy, &enforcement);
13435 assert_ne!(
13436 result.exit_code, 0,
13437 "mapped: {mapped}: `{script}` succeeded"
13438 );
13439 }
13440 assert_eq!(std::fs::read(&record).unwrap(), before, "mapped: {mapped}");
13441 assert!(records
13442 .join(crate::temp_dir::SESSION_TEMP_DIR_NAME)
13443 .is_dir());
13444 assert!(!records.join("tmp-moved").exists());
13445 }
13446 }
13447
13448 #[test]
13451 fn tmp_follows_workdir_exec() {
13452 const TEST: &str = "tmp_follows_workdir_exec";
13453 for (workdir_exec, expected) in [(false, 126), (true, 0)] {
13454 let base = home_tempdir();
13455 let workdir = home_tempdir();
13456 let policy = CapabilityPolicy {
13457 workdir_exec_allowed: workdir_exec,
13458 ..coreutils_policy(&["cp", "true"])
13459 };
13460 let Some(enforcement) = mapped_enforcement(
13461 TEST,
13462 &policy,
13463 murmur_artifact::ContainmentClass::Advisory,
13464 base.path(),
13465 workdir.path(),
13466 ) else {
13467 return;
13468 };
13469 let result = run_bash_in(
13470 workdir.path(),
13471 "cp /bin/true /tmp/t && /tmp/t",
13472 &policy,
13473 &enforcement,
13474 );
13475 assert_eq!(
13476 result.exit_code, expected,
13477 "workdir_exec: {workdir_exec}: {}",
13478 result.stderr
13479 );
13480 if !workdir_exec {
13481 assert!(
13482 result.stderr.contains("Permission denied"),
13483 "{}",
13484 result.stderr
13485 );
13486 }
13487 }
13488 }
13489
13490 #[test]
13492 fn a_failed_tmp_mapping_spawns_nothing() {
13493 const TEST: &str = "a_failed_tmp_mapping_spawns_nothing";
13494 let base = home_tempdir();
13495 let workdir = home_tempdir();
13496 let policy = coreutils_policy(&[]);
13497 let Some(enforcement) = mapped_enforcement(
13498 TEST,
13499 &policy,
13500 murmur_artifact::ContainmentClass::Scoped,
13501 base.path(),
13502 workdir.path(),
13503 ) else {
13504 return;
13505 };
13506 let error = {
13507 let _forced = ForceTmpMapFailureGuard::new();
13508 crate::shell::execute_shell(
13509 "bash",
13510 &["-c", "echo ran > \"$(pwd)/marker\""],
13511 &[],
13512 &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
13513 &policy,
13514 &enforcement,
13515 )
13516 .expect_err("a failed mapping fails the spawn")
13517 };
13518 let message = error.to_string();
13519 println!("{message}");
13520 assert!(
13521 message.contains("could not map /tmp onto this session's temporary folder"),
13522 "{message}"
13523 );
13524 assert!(
13525 matches!(error, crate::shell::ShellExecError::Failed(_)),
13526 "an ordinary spawn failure, not a session-fatal one: {error:?}"
13527 );
13528 assert!(!workdir.path().join("marker").exists(), "the command ran");
13529 }
13530
13531 #[test]
13534 fn a_link_swapped_onto_the_temp_folder_redirects_no_bind() {
13535 const TEST: &str = "a_link_swapped_onto_the_temp_folder_redirects_no_bind";
13536 let base = home_tempdir();
13537 let workdir = home_tempdir();
13538 let policy = coreutils_policy(&[]);
13539 let Some(enforcement) = mapped_enforcement(
13540 TEST,
13541 &policy,
13542 murmur_artifact::ContainmentClass::Scoped,
13543 base.path(),
13544 workdir.path(),
13545 ) else {
13546 return;
13547 };
13548 let folder = enforcement.temp_dir_path().unwrap().to_path_buf();
13549 let decoy = base.path().join("decoy");
13550 std::fs::create_dir(&decoy).unwrap();
13551 std::fs::rename(&folder, base.path().join("folder-moved")).unwrap();
13552 std::os::unix::fs::symlink(&decoy, &folder).unwrap();
13553
13554 let error = crate::shell::execute_shell(
13555 "bash",
13556 &["-c", "echo x > /tmp/swapped"],
13557 &[],
13558 &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
13559 &policy,
13560 &enforcement,
13561 )
13562 .expect_err("a swapped folder fails the spawn")
13563 .to_string();
13564 println!("{error}");
13565 assert!(
13566 error.contains("could not map /tmp onto this session's temporary folder"),
13567 "{error}"
13568 );
13569 assert!(error.contains("opening the temporary folder"), "{error}");
13570 assert!(
13571 error.contains(&std::io::Error::from_raw_os_error(libc::ESTALE).to_string()),
13572 "{error}"
13573 );
13574 assert_eq!(
13575 std::fs::read_dir(&decoy).unwrap().count(),
13576 0,
13577 "the link's target was written"
13578 );
13579 }
13580
13581 fn derived_interpreter_policy(binary: &str, work: &Path) -> CapabilityPolicy {
13585 let mut policy = CapabilityPolicy {
13586 shell_allow: vec![binary.to_string()],
13587 ..CapabilityPolicy::default()
13588 };
13589 let cache = tempfile::tempdir().unwrap();
13590 policy.shell_derived_interpreter_runtime =
13591 crate::interpreter_runtime::derive_interpreter_runtimes(
13592 &policy.shell_allow,
13593 &[],
13594 &[work],
13595 &crate::host_facts::HostFactsCache::at(cache.path().join("host_facts")),
13596 )
13597 .runtimes;
13598 policy
13599 }
13600
13601 fn unsandboxed_python(program: &str) -> String {
13603 let output = std::process::Command::new("python3")
13604 .args(["-I", "-c", program])
13605 .output()
13606 .unwrap();
13607 assert!(output.status.success(), "{output:?}");
13608 String::from_utf8(output.stdout).unwrap().trim().to_string()
13609 }
13610
13611 fn assert_derives_stdlib_and_sqlite(policy: &CapabilityPolicy) -> PathBuf {
13614 let stdlib = PathBuf::from(unsandboxed_python(
13615 "import encodings, os; print(os.path.dirname(os.path.dirname(encodings.__file__)))",
13616 ));
13617 let dynload = PathBuf::from(unsandboxed_python(
13618 "import _sqlite3, os; print(os.path.dirname(os.path.realpath(_sqlite3.__file__)))",
13619 ));
13620 let derived = &policy.shell_derived_interpreter_runtime;
13621 println!("derived: {derived:#?}");
13622 let dirs: Vec<&PathBuf> = derived.iter().flat_map(|runtime| &runtime.dirs).collect();
13623 let canonical = |path: &Path| std::fs::canonicalize(path).unwrap();
13624 assert!(
13625 dirs.contains(&&canonical(&stdlib)),
13626 "{stdlib:?} not in {dirs:?}"
13627 );
13628 assert!(
13629 dirs.contains(&&canonical(&dynload)),
13630 "{dynload:?} not in {dirs:?}"
13631 );
13632 canonical(&stdlib)
13633 }
13634
13635 fn python_probe_outside(secret: &Path, parent: &Path) -> String {
13638 format!(
13639 "import os\n\
13640 for label, act in (('open', lambda: open({secret:?}).read()), \
13641 ('listdir', lambda: os.listdir({parent:?}))):\n\
13642 \x20 try:\n\
13643 \x20 act(); print(label, 'allowed')\n\
13644 \x20 except Exception as error:\n\
13645 \x20 print(label, type(error).__name__)\n",
13646 secret = secret.to_string_lossy(),
13647 parent = parent.to_string_lossy(),
13648 )
13649 }
13650
13651 fn execute(
13652 binary: &str,
13653 args: &[&str],
13654 accessible: &Path,
13655 policy: &CapabilityPolicy,
13656 enforcement: &ShellEnforcement,
13657 ) -> crate::shell::ShellResult {
13658 let result = crate::shell::execute_shell(
13659 binary,
13660 args,
13661 &[],
13662 &crate::workdir_fs::WorkdirRoot::open(accessible).unwrap(),
13663 policy,
13664 enforcement,
13665 )
13666 .unwrap();
13667 println!(
13668 "exit {}\n--- stdout\n{}--- stderr\n{}---",
13669 result.exit_code, result.stdout, result.stderr
13670 );
13671 result
13672 }
13673
13674 fn trust_store_policy(
13677 shell_allow: &[&str],
13678 network_allow: &[&str],
13679 work: &Path,
13680 ) -> CapabilityPolicy {
13681 let mut policy = CapabilityPolicy {
13682 shell_allow: shell_allow.iter().map(|name| name.to_string()).collect(),
13683 network_allow: network_allow.iter().map(|rule| rule.to_string()).collect(),
13684 ..CapabilityPolicy::default()
13685 };
13686 policy.shell_trust_store = crate::trust_store::derive_trust_store(
13687 &policy.shell_allow,
13688 &policy.network_allow,
13689 &[work],
13690 );
13691 println!("derived trust store: {:#?}", policy.shell_trust_store);
13692 policy
13693 }
13694
13695 fn host_ca_bundle() -> Option<PathBuf> {
13697 crate::trust_store::TRUST_STORE_CANDIDATES
13698 .iter()
13699 .filter(|candidate| !candidate.directory)
13700 .map(|candidate| PathBuf::from(candidate.path))
13701 .find(|path| std::fs::metadata(path).is_ok_and(|m| m.is_file()))
13702 }
13703
13704 #[test]
13707 fn a_scoped_curl_with_network_allow_completes_an_https_request() {
13708 let test = "a_scoped_curl_with_network_allow_completes_an_https_request";
13709 if find_on_path("curl").is_none() {
13710 eprintln!("SKIP — PROVES NOTHING: {test} needs curl on PATH");
13711 return;
13712 }
13713 let unsandboxed = std::process::Command::new("curl")
13714 .args([
13715 "-sS",
13716 "-o",
13717 "/dev/null",
13718 "-w",
13719 "%{http_code}",
13720 "--max-time",
13721 "15",
13722 ])
13723 .arg("https://example.com/")
13724 .output()
13725 .unwrap();
13726 if String::from_utf8_lossy(&unsandboxed.stdout) != "200" {
13727 eprintln!(
13728 "SKIP — PROVES NOTHING: {test} needs https://example.com/ to answer 200 outside \
13729 the sandbox; it gave {unsandboxed:?}"
13730 );
13731 return;
13732 }
13733 let (_base, work, _outside) = work_and_outside();
13734 let policy = trust_store_policy(&["curl"], &["example.com"], &work);
13735 assert!(policy.shell_interpreter_runtime.is_empty());
13736 assert!(
13737 !policy.shell_trust_store.is_empty(),
13738 "this host has a trust store the candidate table names"
13739 );
13740 let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
13741 return;
13742 };
13743
13744 let fetched = execute(
13745 "curl",
13746 &[
13747 "-sS",
13748 "-o",
13749 "/dev/null",
13750 "-w",
13751 "%{http_code}",
13752 "--max-time",
13753 "30",
13754 "https://example.com/",
13755 ],
13756 &work,
13757 &policy,
13758 &enforcement,
13759 );
13760 enforcement.end_session();
13761 assert_eq!(fetched.exit_code, 0, "{}", fetched.stderr);
13762 assert_eq!(fetched.stdout, "200");
13763 }
13764
13765 #[test]
13767 fn a_scoped_command_reads_the_trust_store_only_with_network_allow() {
13768 let test = "a_scoped_command_reads_the_trust_store_only_with_network_allow";
13769 assert!(
13770 crate::trust_store::derive_trust_store(&[], &["example.com".into()], &[]).is_empty()
13771 );
13772 let Some(bundle) = host_ca_bundle() else {
13773 eprintln!("SKIP — PROVES NOTHING: {test} needs a CA bundle the candidate table names");
13774 return;
13775 };
13776 let bundle_arg = bundle.to_str().unwrap();
13777 let (_base, work, _outside) = work_and_outside();
13778
13779 let offline = trust_store_policy(&["cat"], &[], &work);
13780 assert!(offline.shell_trust_store.is_empty());
13781 let online = trust_store_policy(&["cat"], &["127.0.0.1"], &work);
13782 assert!(!online.shell_trust_store.is_empty());
13783 let Some(enforcement) = scoped_landlock_enforcement(test, &offline, 1) else {
13784 return;
13785 };
13786 for derived in &online.shell_trust_store {
13787 assert!(
13788 !enforcement
13789 .landlock_grants
13790 .iter()
13791 .any(|grant| grant.path == derived.path),
13792 "{derived:?} granted without network.allow"
13793 );
13794 }
13795 let denied = execute("cat", &[bundle_arg], &work, &offline, &enforcement);
13796 enforcement.end_session();
13797 assert_ne!(denied.exit_code, 0);
13798 assert!(
13799 denied.stderr.contains("Permission denied"),
13800 "{}",
13801 denied.stderr
13802 );
13803
13804 let Some(enforcement) = scoped_landlock_enforcement(test, &online, 1) else {
13805 return;
13806 };
13807 let read = execute("cat", &[bundle_arg], &work, &online, &enforcement);
13808 enforcement.end_session();
13809 assert_eq!(read.exit_code, 0, "{}", read.stderr);
13810 assert!(
13811 read.stdout.starts_with("-----BEGIN CERTIFICATE-----"),
13812 "{}",
13813 &read.stdout[..read.stdout.len().min(200)]
13814 );
13815 }
13816
13817 #[test]
13820 fn a_scoped_command_reads_a_declared_need_and_nothing_beside_it() {
13821 use std::os::unix::fs::PermissionsExt;
13822 let test = "a_scoped_command_reads_a_declared_need_and_nothing_beside_it";
13823 let base = home_tempdir();
13825 let need = base.path().join("need");
13826 std::fs::create_dir_all(need.join("share")).unwrap();
13827 let library = need.join("lib.so.1");
13828 std::fs::write(&library, "#!/bin/sh\necho ran\n").unwrap();
13829 std::fs::set_permissions(&library, std::fs::Permissions::from_mode(0o755)).unwrap();
13830 std::fs::write(need.join("share/font.txt"), "font").unwrap();
13831 let secret = need.join("secret.txt");
13832 std::fs::write(&secret, "secret").unwrap();
13833 let (_work_base, work, _outside) = work_and_outside();
13834 let mut policy = allow_only(&["cat", "sh", "ls"]);
13835 policy.shell_declared_needs =
13836 super::tests::granted_needs(&[(library.clone(), false), (need.join("share"), true)]);
13837 let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
13838 return;
13839 };
13840 assert!(enforcement
13841 .landlock_grants
13842 .iter()
13843 .filter(|grant| grant.path.starts_with(&need))
13844 .all(|grant| !grant.executable));
13845
13846 let read = execute(
13847 "cat",
13848 &[library.to_str().unwrap()],
13849 &work,
13850 &policy,
13851 &enforcement,
13852 );
13853 let beside = execute(
13854 "cat",
13855 &[secret.to_str().unwrap()],
13856 &work,
13857 &policy,
13858 &enforcement,
13859 );
13860 let listed = execute(
13861 "ls",
13862 &[need.join("share").to_str().unwrap()],
13863 &work,
13864 &policy,
13865 &enforcement,
13866 );
13867 let ran = execute(
13868 "sh",
13869 &["-c", library.to_str().unwrap()],
13870 &work,
13871 &policy,
13872 &enforcement,
13873 );
13874 enforcement.end_session();
13875 assert_eq!(read.exit_code, 0, "{}", read.stderr);
13876 assert!(read.stdout.contains("echo ran"), "{}", read.stdout);
13877 assert_ne!(beside.exit_code, 0);
13878 assert!(
13879 beside.stderr.contains("Permission denied"),
13880 "{}",
13881 beside.stderr
13882 );
13883 assert_eq!(listed.exit_code, 0, "{}", listed.stderr);
13884 assert!(listed.stdout.contains("font.txt"), "{}", listed.stdout);
13885 assert_eq!(ran.exit_code, 126, "{}", ran.stderr);
13886 assert!(ran.stderr.contains("Permission denied"), "{}", ran.stderr);
13887 assert!(!ran.stdout.contains("ran"), "{}", ran.stdout);
13888 }
13889
13890 fn allow_only(shell_allow: &[&str]) -> CapabilityPolicy {
13894 CapabilityPolicy {
13895 shell_allow: shell_allow.iter().map(|name| name.to_string()).collect(),
13896 ..CapabilityPolicy::default()
13897 }
13898 }
13899
13900 fn sealed_landlock_enforcement(
13903 test: &str,
13904 policy: &CapabilityPolicy,
13905 ) -> Option<(ShellEnforcement, tempfile::TempDir)> {
13906 if crate::network_namespace::skip_without_egress_namespace(test) {
13907 return None;
13908 }
13909 let probe = HostProbe::probe();
13910 if probe.tier() != EnforcementTier::KernelSealed {
13911 eprintln!(
13912 "SKIP — PROVES NOTHING ABOUT SEALED ON THIS HOST: {test} needs a host that \
13913 reaches KernelSealed; detected {:?}",
13914 probe.tier()
13915 );
13916 return None;
13917 }
13918 let runtime = tempfile::tempdir().unwrap();
13919 let enforcement = ShellEnforcement::resolve_declared(
13920 policy,
13921 murmur_artifact::ContainmentClass::Sealed,
13922 probe,
13923 )
13924 .unwrap()
13925 .with_runtime_dir(test_runtime_dir(runtime.path()), None);
13926 assert_eq!(enforcement.tier, EnforcementTier::KernelSealed);
13927 Some((enforcement, runtime))
13928 }
13929
13930 fn mounts_private_proc(test: &str, enforcement: &ShellEnforcement) -> bool {
13933 match enforcement.installed_proc_view() {
13934 crate::proc_view::ProcView::Private => true,
13935 crate::proc_view::ProcView::Host(reason) => {
13936 eprintln!(
13937 "SKIP — PROVES NOTHING: {test} needs a host that mounts a private procfs; \
13938 {}",
13939 reason.describe()
13940 );
13941 false
13942 }
13943 }
13944 }
13945
13946 const OWN_PROC_SCRIPT: &str = "head -n 1 /proc/self/status; \
13949 head -n 1 /proc/thread-self/status; \
13950 head -n 1 /proc/stat; \
13951 n=0; while read -r line; do case $line in processor*) n=$((n+1));; esac; done \
13952 < /proc/cpuinfo; echo \"processors=$n\"; \
13953 head -n 1 /proc/meminfo; \
13954 while read -r _ point kind options _; do \
13955 if [ \"$point\" = /proc ]; then echo \"proc-mount: $kind $options\"; fi; \
13956 done < /proc/self/mounts";
13957
13958 fn assert_reads_own_proc(result: &crate::shell::ShellResult) {
13959 assert_eq!(result.exit_code, 0, "{}", result.stderr);
13960 assert!(
13961 !result.stderr.contains("Permission denied"),
13962 "{}",
13963 result.stderr
13964 );
13965 let lines: Vec<&str> = result.stdout.lines().collect();
13966 assert!(lines.len() >= 5, "{}", result.stdout);
13967 assert!(lines[0].starts_with("Name:"), "{}", result.stdout);
13968 assert!(lines[1].starts_with("Name:"), "{}", result.stdout);
13969 assert!(lines[2].starts_with("cpu "), "{}", result.stdout);
13970 let processors: u32 = lines[3]
13971 .strip_prefix("processors=")
13972 .and_then(|n| n.parse().ok())
13973 .unwrap_or_else(|| panic!("{}", result.stdout));
13974 assert!(processors > 0, "{}", result.stdout);
13975 assert!(lines[4].starts_with("MemTotal:"), "{}", result.stdout);
13976 }
13977
13978 #[test]
13981 fn a_scoped_command_reads_its_own_proc() {
13982 let test = "a_scoped_command_reads_its_own_proc";
13983 let (_base, work, _outside) = work_and_outside();
13984 let policy = allow_only(&["bash", "cat", "head"]);
13985 let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
13986 return;
13987 };
13988 if !mounts_private_proc(test, &enforcement) {
13989 return;
13990 }
13991 let result = run_bash_in(&work, OWN_PROC_SCRIPT, &policy, &enforcement);
13992 enforcement.end_session();
13993 assert_reads_own_proc(&result);
13994 }
13995
13996 struct HostSleeper(std::process::Child);
13998
13999 impl HostSleeper {
14000 fn start() -> Self {
14001 Self(
14002 std::process::Command::new("sleep")
14003 .arg("300")
14004 .spawn()
14005 .unwrap(),
14006 )
14007 }
14008
14009 fn pid(&self) -> u32 {
14010 self.0.id()
14011 }
14012 }
14013
14014 impl Drop for HostSleeper {
14015 fn drop(&mut self) {
14016 let _ = self.0.kill();
14017 let _ = self.0.wait();
14018 }
14019 }
14020
14021 fn host_process_script(pid: u32) -> String {
14023 format!("ls /proc; cat /proc/{pid}/cmdline; echo \"cat=$?\"")
14024 }
14025
14026 fn assert_sees_no_host_process(result: &crate::shell::ShellResult, host_pids: &[u32]) {
14027 assert_eq!(result.exit_code, 0, "{}", result.stderr);
14028 let entries: Vec<u32> = result
14029 .stdout
14030 .lines()
14031 .filter_map(|line| line.trim().parse().ok())
14032 .collect();
14033 assert!(!entries.is_empty(), "no process listed: {}", result.stdout);
14034 assert!(entries.len() <= 3, "{entries:?}");
14036 for pid in host_pids {
14037 assert!(!entries.contains(pid), "host pid {pid} listed: {entries:?}");
14038 }
14039 assert!(
14040 result.stderr.contains("No such file or directory"),
14041 "{}",
14042 result.stderr
14043 );
14044 assert!(result.stdout.contains("cat=1"), "{}", result.stdout);
14045 }
14046
14047 #[test]
14051 fn a_scoped_command_cannot_see_host_processes() {
14052 let test = "a_scoped_command_cannot_see_host_processes";
14053 let (_base, work, _outside) = work_and_outside();
14054 let policy = allow_only(&["bash", "ls", "cat"]);
14055 let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
14056 return;
14057 };
14058 if !mounts_private_proc(test, &enforcement) {
14059 return;
14060 }
14061 let sleeper = HostSleeper::start();
14062 let result = run_bash_in(
14063 &work,
14064 &host_process_script(sleeper.pid()),
14065 &policy,
14066 &enforcement,
14067 );
14068 enforcement.end_session();
14069 assert_sees_no_host_process(&result, &[sleeper.pid(), std::process::id()]);
14070 }
14071
14072 const NODE_OPENSSL_CONFIG: &str = "/etc/ssl/openssl.cnf";
14074
14075 #[test]
14078 fn a_scoped_node_counts_cpus_and_resolves_localhost() {
14079 let test = "a_scoped_node_counts_cpus_and_resolves_localhost";
14080 if resolve_exec_allowlist(&["node".to_string()]).is_empty() {
14081 eprintln!("SKIP — PROVES NOTHING: {test} needs node on PATH");
14082 return;
14083 }
14084 let (_base, work, _outside) = work_and_outside();
14085 let mut policy = allow_only(&["node"]);
14086 policy.resources.memory_bytes = 64 * 1024 * 1024 * 1024;
14088 if Path::new(NODE_OPENSSL_CONFIG).is_file() {
14091 policy.shell_interpreter_runtime = vec![murmur_artifact::InterpreterRuntimeGrant {
14092 binary: "node".to_string(),
14093 dirs: vec![murmur_artifact::InterpreterRuntimeDir {
14094 path: NODE_OPENSSL_CONFIG.to_string(),
14095 list_dir: false,
14096 }],
14097 }];
14098 }
14099 let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
14100 return;
14101 };
14102 if !mounts_private_proc(test, &enforcement) {
14103 return;
14104 }
14105 let result = execute(
14106 "node",
14107 &[
14108 "-e",
14109 "console.log('cpus=' + require('os').cpus().length); \
14110 require('dns').lookup('localhost', (error, address) => \
14111 console.log(error ? 'lookup=' + error.code : 'lookup=' + address));",
14112 ],
14113 &work,
14114 &policy,
14115 &enforcement,
14116 );
14117 enforcement.end_session();
14118 assert_eq!(result.exit_code, 0, "{}", result.stderr);
14119 let cpus: u32 = result
14120 .stdout
14121 .lines()
14122 .find_map(|line| line.strip_prefix("cpus="))
14123 .and_then(|n| n.parse().ok())
14124 .unwrap_or_else(|| panic!("{}", result.stdout));
14125 assert!(cpus > 0, "{}", result.stdout);
14126 let address = result
14127 .stdout
14128 .lines()
14129 .find_map(|line| line.strip_prefix("lookup="))
14130 .unwrap_or_else(|| panic!("{}", result.stdout));
14131 assert!(
14132 address == "127.0.0.1" || address == "::1",
14133 "{}",
14134 result.stdout
14135 );
14136 }
14137
14138 #[test]
14141 fn a_scoped_command_resolves_localhost_through_etc_hosts() {
14142 let test = "a_scoped_command_resolves_localhost_through_etc_hosts";
14143 let (_base, work, _outside) = work_and_outside();
14144 let policy = allow_only(&["bash", "cat", "getent"]);
14145 let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
14146 return;
14147 };
14148 let result = run_bash_in(
14149 &work,
14150 "getent hosts localhost; echo \"getent=$?\"; \
14151 cat /etc/hosts > /dev/null; echo \"hosts=$?\"; \
14152 cat /etc/nsswitch.conf > /dev/null; echo \"nsswitch=$?\"; \
14153 cat /etc/passwd > /dev/null; echo \"passwd=$?\"; \
14154 cat /etc/resolv.conf > /dev/null; echo \"resolv=$?\"",
14155 &policy,
14156 &enforcement,
14157 );
14158 enforcement.end_session();
14159 let stdout = &result.stdout;
14160 assert!(
14161 stdout
14162 .lines()
14163 .any(|line| line.starts_with("127.0.0.1") || line.starts_with("::1")),
14164 "{stdout}"
14165 );
14166 for read in ["getent=0", "hosts=0", "nsswitch=0"] {
14167 assert!(stdout.lines().any(|line| line == read), "{read}: {stdout}");
14168 }
14169 for denied in ["passwd=1", "resolv=1"] {
14170 assert!(
14171 stdout.lines().any(|line| line == denied),
14172 "{denied}: {stdout}"
14173 );
14174 }
14175 assert!(
14176 result.stderr.contains("/etc/passwd: Permission denied"),
14177 "{}",
14178 result.stderr
14179 );
14180 assert!(
14181 result
14182 .stderr
14183 .contains("/etc/resolv.conf: Permission denied"),
14184 "{}",
14185 result.stderr
14186 );
14187 }
14188
14189 #[test]
14192 fn a_command_under_its_relay_keeps_its_exit_status_and_signal() {
14193 let test = "a_command_under_its_relay_keeps_its_exit_status_and_signal";
14194 let (_base, work, _outside) = work_and_outside();
14195 let policy = allow_only(&["bash"]);
14196 let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
14197 return;
14198 };
14199 let exited = run_bash_in(&work, "exit 7", &policy, &enforcement);
14200 let killed = run_bash_in(&work, "kill -KILL $$", &policy, &enforcement);
14201 let parent = run_bash_in(&work, "echo \"ppid=$PPID pid=$$\"", &policy, &enforcement);
14202 enforcement.end_session();
14203 assert_eq!(exited.exit_code, 7);
14204 assert_eq!(killed.exit_code, 128 + libc::SIGKILL);
14205 assert!(
14206 parent.stdout.starts_with("ppid=0 pid="),
14207 "{}",
14208 parent.stdout
14209 );
14210 let pid: i32 = parent
14211 .stdout
14212 .trim()
14213 .rsplit('=')
14214 .next()
14215 .unwrap()
14216 .parse()
14217 .unwrap();
14218 assert!(pid > 1 && pid < 100, "a namespace pid: {}", parent.stdout);
14219 }
14220
14221 fn host_pid(session: &ShellEnforcement, namespace_pid: i32) -> i32 {
14228 let join = session
14229 .session_netns
14230 .establish(
14231 &session.egress_tcp_ports,
14232 crate::egress_proxy::EgressPolicy::new(
14233 session.network_allow_rules.clone(),
14234 session.network_allow_ips.clone(),
14235 ),
14236 session.observed_held_ports(),
14237 )
14238 .expect("the session's namespace is established");
14239 let pid_ns = std::fs::read_link(format!("/proc/self/fd/{}", join.pid_ns_fd()))
14240 .expect("the session's PID namespace handle is readable");
14241 let wanted = namespace_pid.to_string();
14242 for _ in 0..100 {
14243 let found = std::fs::read_dir("/proc")
14244 .expect("/proc is readable")
14245 .filter_map(Result::ok)
14246 .filter_map(|entry| entry.file_name().to_str()?.parse::<i32>().ok())
14247 .find(|pid| {
14248 std::fs::read_link(format!("/proc/{pid}/ns/pid"))
14249 .is_ok_and(|namespace| namespace == pid_ns)
14250 && std::fs::read_to_string(format!("/proc/{pid}/status")).is_ok_and(
14251 |status| {
14252 status
14253 .lines()
14254 .find_map(|line| line.strip_prefix("NSpid:"))
14255 .and_then(|fields| fields.split_whitespace().last())
14256 == Some(wanted.as_str())
14257 },
14258 )
14259 });
14260 if let Some(pid) = found {
14261 return pid;
14262 }
14263 std::thread::sleep(std::time::Duration::from_millis(20));
14264 }
14265 panic!("no process of this session is pid {namespace_pid} in its namespace");
14266 }
14267
14268 #[test]
14271 fn a_backgrounded_process_survives_into_the_next_command_and_ends_with_the_session() {
14272 let test =
14273 "a_backgrounded_process_survives_into_the_next_command_and_ends_with_the_session";
14274 let (_base, work, _outside) = work_and_outside();
14275 let policy = allow_only(&["bash", "sleep"]);
14276 let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
14277 return;
14278 };
14279 let started = run_bash_in(
14280 &work,
14281 "sleep 300 >/dev/null 2>&1 & echo $! > sleeper.pid",
14282 &policy,
14283 &enforcement,
14284 );
14285 assert_eq!(started.exit_code, 0, "{}", started.stderr);
14286 let namespace_pid = pid_in(&work, "sleeper.pid");
14287 let sleeper = host_pid(&enforcement, namespace_pid);
14288 let holder = host_pid(&enforcement, 1);
14289 assert_ne!(sleeper, namespace_pid, "the pid file holds a namespace pid");
14290 let seen = run_bash_in(
14291 &work,
14292 "kill -0 \"$(< sleeper.pid)\" && echo alive",
14293 &policy,
14294 &enforcement,
14295 );
14296 assert_eq!(seen.stdout.trim(), "alive", "{}", seen.stderr);
14297 assert!(process_exists(sleeper));
14298 enforcement.end_session();
14299 let wait = SESSION_END_KILL_WAIT + std::time::Duration::from_secs(3);
14300 assert!(
14301 process_gone_within(holder, wait),
14302 "holder {holder} outlived the session"
14303 );
14304 assert!(
14305 process_gone_within(sleeper, wait),
14306 "sleeper {sleeper} outlived the session"
14307 );
14308 }
14309
14310 #[test]
14313 fn a_dropped_session_ends_its_holder_and_its_processes() {
14314 let test = "a_dropped_session_ends_its_holder_and_its_processes";
14315 let (_base, work, _outside) = work_and_outside();
14316 let policy = allow_only(&["bash", "sleep"]);
14317 let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
14318 return;
14319 };
14320 let started = run_bash_in(
14321 &work,
14322 "sleep 300 >/dev/null 2>&1 & echo $! > sleeper.pid",
14323 &policy,
14324 &enforcement,
14325 );
14326 assert_eq!(started.exit_code, 0, "{}", started.stderr);
14327 let sleeper = host_pid(&enforcement, pid_in(&work, "sleeper.pid"));
14328 let holder = host_pid(&enforcement, 1);
14329 drop(enforcement);
14330 assert!(
14331 process_gone_within(holder, SESSION_END_KILL_WAIT),
14332 "holder {holder} outlived its dropped session"
14333 );
14334 assert!(
14335 process_gone_within(sleeper, SESSION_END_KILL_WAIT),
14336 "sleeper {sleeper} outlived its dropped session"
14337 );
14338 }
14339
14340 #[test]
14343 fn killing_the_relay_ends_the_command() {
14344 use std::os::unix::process::{CommandExt, ExitStatusExt};
14345 let test = "killing_the_relay_ends_the_command";
14346 let (_base, work, _outside) = work_and_outside();
14347 let policy = allow_only(&["bash", "sleep"]);
14348 let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
14349 return;
14350 };
14351 let bash = resolve_exec_allowlist(&["bash".to_string()])
14352 .into_iter()
14353 .next()
14354 .unwrap();
14355 let mut command = std::process::Command::new(bash);
14356 command
14357 .args(["-c", "echo $$ > command.pid; exec sleep 300"])
14358 .current_dir(&work)
14359 .process_group(0);
14360 let _handle = prepare_enforcement(&mut command, &enforcement, &work).unwrap();
14361 let mut relay = command.spawn().unwrap();
14362 let pid_file = work.join("command.pid");
14363 for _ in 0..200 {
14364 if std::fs::read_to_string(&pid_file).is_ok_and(|text| text.ends_with('\n')) {
14365 break;
14366 }
14367 std::thread::sleep(std::time::Duration::from_millis(20));
14368 }
14369 let command_pid = host_pid(&enforcement, pid_in(&work, "command.pid"));
14370 assert_ne!(
14371 command_pid,
14372 relay.id() as i32,
14373 "the command is not its relay"
14374 );
14375 relay.kill().unwrap();
14376 let status = relay.wait().unwrap();
14377 assert_eq!(status.signal(), Some(libc::SIGKILL));
14378 assert!(
14379 process_gone_within(command_pid, std::time::Duration::from_secs(5)),
14380 "command {command_pid} outlived its relay"
14381 );
14382 enforcement.end_session();
14383 }
14384
14385 #[test]
14389 fn a_process_group_sigterm_reaches_the_command_past_its_relay() {
14390 use std::os::unix::process::{CommandExt, ExitStatusExt};
14391 let test = "a_process_group_sigterm_reaches_the_command_past_its_relay";
14392 let (_base, work, _outside) = work_and_outside();
14393 let policy = allow_only(&["bash", "sleep"]);
14394 let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
14395 return;
14396 };
14397 let bash = resolve_exec_allowlist(&["bash".to_string()])
14398 .into_iter()
14399 .next()
14400 .unwrap();
14401 let mut command = std::process::Command::new(bash);
14402 command
14403 .args(["-c", "echo $$ > command.pid; exec sleep 300"])
14404 .current_dir(&work)
14405 .process_group(0);
14406 let _handle = prepare_enforcement(&mut command, &enforcement, &work).unwrap();
14407 let mut relay = command.spawn().unwrap();
14408 let pid_file = work.join("command.pid");
14409 for _ in 0..200 {
14410 if std::fs::read_to_string(&pid_file).is_ok_and(|text| text.ends_with('\n')) {
14411 break;
14412 }
14413 std::thread::sleep(std::time::Duration::from_millis(20));
14414 }
14415 let command_pid = host_pid(&enforcement, pid_in(&work, "command.pid"));
14416 let sent_at = std::time::Instant::now();
14417 assert!(crate::formation_launch::signal_group(
14418 i64::from(relay.id()),
14419 libc::SIGTERM
14420 ));
14421 let status = relay.wait().unwrap();
14422 assert_eq!(status.signal(), Some(libc::SIGTERM), "{status:?}");
14423 assert!(
14424 sent_at.elapsed() < std::time::Duration::from_secs(5),
14425 "the command took {:?} to end",
14426 sent_at.elapsed()
14427 );
14428 assert!(
14429 process_gone_within(command_pid, std::time::Duration::from_secs(5)),
14430 "command {command_pid} outlived the group SIGTERM"
14431 );
14432 enforcement.end_session();
14433 }
14434
14435 #[test]
14438 fn a_sealed_command_reads_its_own_proc_and_sees_no_host_process() {
14439 let test = "a_sealed_command_reads_its_own_proc_and_sees_no_host_process";
14440 let (_base, work, _outside) = work_and_outside();
14441 let policy = allow_only(&["bash", "cat", "head", "ls"]);
14442 let Some((enforcement, _runtime)) = sealed_landlock_enforcement(test, &policy) else {
14443 return;
14444 };
14445 if !mounts_private_proc(test, &enforcement) {
14446 return;
14447 }
14448 let own = run_bash_in(&work, OWN_PROC_SCRIPT, &policy, &enforcement);
14449 let sleeper = HostSleeper::start();
14450 let host = run_bash_in(
14451 &work,
14452 &host_process_script(sleeper.pid()),
14453 &policy,
14454 &enforcement,
14455 );
14456 enforcement.end_session();
14457 assert_reads_own_proc(&own);
14458 assert_sees_no_host_process(&host, &[sleeper.pid(), std::process::id()]);
14459 }
14460
14461 #[test]
14464 fn a_host_proc_view_keeps_proc_denied_and_says_why() {
14465 let test = "a_host_proc_view_keeps_proc_denied_and_says_why";
14466 let (_base, work, _outside) = work_and_outside();
14467 let policy = allow_only(&["bash", "cat", "ls"]);
14468 let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
14469 return;
14470 };
14471 let refused =
14472 crate::proc_view::ProcView::Host(crate::proc_view::HostProcReason::MountRefused(
14473 "mounting proc over /proc failed with EPERM".to_string(),
14474 ));
14475 let enforcement = enforcement.with_proc_view(refused.clone());
14476 assert_eq!(enforcement.installed_proc_view(), refused);
14477 let result = run_bash_in(
14478 &work,
14479 "cat /proc/self/status; echo \"cat=$?\"; ls /proc/1 >/dev/null; echo \"ls=$?\"",
14480 &policy,
14481 &enforcement,
14482 );
14483 enforcement.end_session();
14484 assert!(result.stdout.contains("cat=1"), "{}", result.stdout);
14485 assert!(
14486 result
14487 .stderr
14488 .contains("/proc/self/status: Permission denied"),
14489 "{}",
14490 result.stderr
14491 );
14492 let report = crate::proc_view::process_view_report(&refused);
14493 assert!(!report.private);
14494 assert!(report
14495 .not_private_because
14496 .is_some_and(|reason| reason.contains("failed with EPERM")),);
14497 }
14498
14499 #[test]
14502 fn a_private_proc_mount_that_fails_at_spawn_fails_the_spawn() {
14503 let test = "a_private_proc_mount_that_fails_at_spawn_fails_the_spawn";
14504 let (_base, work, _outside) = work_and_outside();
14505 let policy = allow_only(&["bash"]);
14506 let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
14507 return;
14508 };
14509 if !mounts_private_proc(test, &enforcement) {
14510 return;
14511 }
14512 let _armed = ForcePrivateProcFailureGuard::new();
14513 let error = crate::shell::execute_shell(
14514 "bash",
14515 &["-c", "echo ran > ran.txt"],
14516 &[],
14517 &crate::workdir_fs::WorkdirRoot::open(&work).unwrap(),
14518 &policy,
14519 &enforcement,
14520 )
14521 .expect_err("a failed private /proc mount must fail the spawn");
14522 enforcement.end_session();
14523 let error = error.to_string();
14524 assert!(
14525 error.contains(crate::proc_view::PRIVATE_PROC_FAILURE_PREFIX),
14526 "{error}"
14527 );
14528 assert!(
14529 error.contains("mounting proc over /proc failed: EPERM"),
14530 "{error}"
14531 );
14532 assert!(!work.join("ran.txt").exists());
14533 }
14534
14535 #[test]
14538 fn scoped_name_resolution_grants_are_a_read_only_subset_of_sealed_etc() {
14539 for path in SCOPED_NAME_RESOLUTION_PATHS {
14540 assert!(
14541 crate::sealed::SEALED_ETC_PATHS
14542 .iter()
14543 .any(|entry| entry.path == *path && !entry.directory),
14544 "{path} is not a SEALED_ETC_PATHS file"
14545 );
14546 }
14547 let grants = resolve_scoped_name_resolution_landlock_grants(EnforcementTier::KernelFull);
14548 assert_eq!(
14549 grants
14550 .iter()
14551 .map(|grant| grant.path.clone())
14552 .collect::<Vec<_>>(),
14553 vec![
14554 PathBuf::from("/etc/hosts"),
14555 PathBuf::from("/etc/nsswitch.conf")
14556 ]
14557 );
14558 assert!(grants
14559 .iter()
14560 .all(|grant| !grant.list_dir && !grant.executable));
14561 for tier in [
14562 EnforcementTier::KernelSealed,
14563 EnforcementTier::KernelSeccompOnly,
14564 EnforcementTier::EnvironmentOnly,
14565 EnforcementTier::Unconfined,
14566 ] {
14567 assert!(resolve_scoped_name_resolution_landlock_grants(tier).is_empty());
14568 }
14569 }
14570
14571 #[test]
14574 fn a_creator_refused_the_pid_namespace_is_refused_with_e_cap_005() {
14575 let test = "a_creator_refused_the_pid_namespace_is_refused_with_e_cap_005";
14576 let (_base, work, _outside) = work_and_outside();
14577 let policy = allow_only(&["bash"]);
14578 let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
14579 return;
14580 };
14581 let _armed = crate::network_namespace::ForcePidNamespaceFailure::arm();
14582 let error = crate::shell::execute_shell(
14583 "bash",
14584 &["-c", "echo ran > ran.txt"],
14585 &[],
14586 &crate::workdir_fs::WorkdirRoot::open(&work).unwrap(),
14587 &policy,
14588 &enforcement,
14589 )
14590 .expect_err("a session without its PID namespace must not run");
14591 enforcement.end_session();
14592 let error = error.to_string();
14593 assert!(error.contains("E-CAP-005"), "{error}");
14594 assert!(
14595 error
14596 .contains("unshare(CLONE_NEWUSER | CLONE_NEWNET | CLONE_NEWPID) failed with EPERM"),
14597 "{error}"
14598 );
14599 assert!(error.contains("CLONE_NEWPID"), "{error}");
14600 assert!(!work.join("ran.txt").exists());
14601 }
14602
14603 #[test]
14606 fn the_trust_store_grant_reaches_nothing_beyond_the_trust_store() {
14607 let test = "the_trust_store_grant_reaches_nothing_beyond_the_trust_store";
14608 let (_base, work, _outside) = work_and_outside();
14609 let policy = trust_store_policy(&["cat", "ls", "bash"], &["127.0.0.1"], &work);
14610 for entry in &policy.shell_trust_store {
14611 for broad in ["/", "/etc", "/etc/ssl", "/etc/pki", "/etc/pki/tls"] {
14612 assert_ne!(entry.path, Path::new(broad));
14613 }
14614 assert!(
14615 !entry.path.components().any(|c| c.as_os_str() == "private"),
14616 "{entry:?}"
14617 );
14618 }
14619 assert!(resolve_trust_store_grants(&policy.shell_trust_store)
14620 .iter()
14621 .all(|grant| !grant.executable));
14622 if std::fs::metadata("/etc/ssl/certs").map_or(true, |m| !m.is_dir()) {
14623 eprintln!("SKIP — PROVES NOTHING: {test} needs /etc/ssl/certs on this host");
14624 return;
14625 }
14626 let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
14627 return;
14628 };
14629
14630 let listed = execute("ls", &["/etc/ssl/certs"], &work, &policy, &enforcement);
14631 assert_eq!(listed.exit_code, 0, "{}", listed.stderr);
14632 let above = execute("ls", &["/etc/ssl"], &work, &policy, &enforcement);
14633 assert_ne!(above.exit_code, 0);
14634 assert!(
14635 above.stderr.contains("Permission denied"),
14636 "{}",
14637 above.stderr
14638 );
14639 if std::fs::metadata("/etc/ssl/openssl.cnf").is_ok() {
14640 let config = execute(
14641 "cat",
14642 &["/etc/ssl/openssl.cnf"],
14643 &work,
14644 &policy,
14645 &enforcement,
14646 );
14647 assert_ne!(config.exit_code, 0);
14648 assert!(
14649 config.stderr.contains("Permission denied"),
14650 "{}",
14651 config.stderr
14652 );
14653 }
14654 let private = execute("ls", &["/etc/ssl/private"], &work, &policy, &enforcement);
14655 assert_ne!(private.exit_code, 0);
14656 let probe = Path::new("/etc/ssl/certs/murmur-probe");
14657 let written = execute(
14658 "bash",
14659 &["-c", "echo x > /etc/ssl/certs/murmur-probe"],
14660 &work,
14661 &policy,
14662 &enforcement,
14663 );
14664 enforcement.end_session();
14665 assert_ne!(written.exit_code, 0);
14666 assert!(std::fs::symlink_metadata(probe).is_err());
14667 }
14668
14669 #[test]
14672 fn a_scoped_command_cannot_write_the_host_facts_cache() {
14673 let test = "a_scoped_command_cannot_write_the_host_facts_cache";
14674 let home = home_tempdir();
14675 let workdir = home_tempdir();
14676 let cache = home.path().join(".murmur").join(
14677 crate::murmur_home::HomeEntryKind::HostFacts
14678 .file_name()
14679 .unwrap(),
14680 );
14681 crate::murmur_home::hold_private_dir(&home.path().join(".murmur")).unwrap();
14682 crate::murmur_home::hold_private_dir(&cache).unwrap();
14683 let policy = coreutils_policy(&[]);
14684 let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
14685 return;
14686 };
14687 let probe = cache.join("probe");
14688 let written = run_bash_in(
14689 workdir.path(),
14690 &format!("printf x > '{}'", probe.display()),
14691 &policy,
14692 &enforcement,
14693 );
14694 enforcement.end_session();
14695 assert_ne!(written.exit_code, 0);
14696 assert!(
14697 written.stderr.contains("Permission denied"),
14698 "{}",
14699 written.stderr
14700 );
14701 assert!(std::fs::symlink_metadata(&probe).is_err());
14702 }
14703
14704 #[test]
14705 fn resolve_trust_store_grants_reads_and_lists_but_never_executes() {
14706 let paths = [
14707 crate::trust_store::TrustStorePath {
14708 path: PathBuf::from("/etc/ssl/certs"),
14709 directory: true,
14710 },
14711 crate::trust_store::TrustStorePath {
14712 path: PathBuf::from("/etc/pki/tls/cacert.pem"),
14713 directory: false,
14714 },
14715 ];
14716 assert_eq!(
14717 resolve_trust_store_grants(&paths),
14718 vec![
14719 LandlockGrant {
14720 path: PathBuf::from("/etc/ssl/certs"),
14721 list_dir: true,
14722 executable: false,
14723 },
14724 LandlockGrant {
14725 path: PathBuf::from("/etc/pki/tls/cacert.pem"),
14726 list_dir: false,
14727 executable: false,
14728 },
14729 ]
14730 );
14731 assert!(resolve_trust_store_grants(&[]).is_empty());
14732 }
14733
14734 #[test]
14737 fn shell_enforcement_resolve_adds_the_trust_store() {
14738 use murmur_artifact::ContainmentClass;
14739 let store = vec![
14740 crate::trust_store::TrustStorePath {
14741 path: PathBuf::from("/murmur-test/certs"),
14742 directory: true,
14743 },
14744 crate::trust_store::TrustStorePath {
14745 path: PathBuf::from("/murmur-test/bundle.pem"),
14746 directory: false,
14747 },
14748 ];
14749 let expected = resolve_trust_store_grants(&store);
14750 let with_store = CapabilityPolicy {
14751 shell_trust_store: store.clone(),
14752 ..CapabilityPolicy::default()
14753 };
14754 let without = CapabilityPolicy::default();
14755 for (tier, declared) in [
14756 (EnforcementTier::KernelFull, ContainmentClass::Scoped),
14757 (EnforcementTier::KernelSealed, ContainmentClass::Sealed),
14758 ] {
14759 let decision =
14760 crate::containment::ContainmentDecision::decide(tier, declared, false, false);
14761 let granted = |policy: &CapabilityPolicy| {
14762 let enforcement =
14763 ShellEnforcement::resolve(policy, &decision, HostProbe::probe()).unwrap();
14764 assert_eq!(enforcement.tier, tier);
14765 enforcement.landlock_grants
14766 };
14767 let with_grants = granted(&with_store);
14768 let without_grants = granted(&without);
14769 for grant in &expected {
14770 assert_eq!(
14771 with_grants.iter().filter(|g| *g == grant).count(),
14772 1,
14773 "{tier:?}: {grant:?}"
14774 );
14775 }
14776 assert!(without_grants
14777 .iter()
14778 .all(|grant| !grant.path.starts_with("/murmur-test")));
14779 assert_eq!(with_grants.len(), without_grants.len() + expected.len());
14780 let sealed_etc = resolve_sealed_etc_landlock_grants(tier);
14781 assert_eq!(sealed_etc.is_empty(), tier != EnforcementTier::KernelSealed);
14782 for grant in &sealed_etc {
14783 assert!(with_grants.contains(grant), "{tier:?}: {grant:?}");
14784 }
14785 }
14786 }
14787
14788 #[test]
14792 fn a_scoped_python3_imports_its_stdlib_and_nothing_outside_it() {
14793 let test = "a_scoped_python3_imports_its_stdlib_and_nothing_outside_it";
14794 if find_on_path("python3").is_none() {
14795 println!("[SKIP] {test}: no python3 on PATH");
14796 return;
14797 }
14798 let (_base, work, outside) = work_and_outside();
14799 let policy = derived_interpreter_policy("python3", &work);
14800 let stdlib = assert_derives_stdlib_and_sqlite(&policy);
14801 let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
14802 return;
14803 };
14804
14805 let imported = execute(
14806 "python3",
14807 &["-c", "import json, sqlite3, venv; print('imported')"],
14808 &work,
14809 &policy,
14810 &enforcement,
14811 );
14812 assert_eq!(imported.exit_code, 0, "{}", imported.stderr);
14813 assert_eq!(imported.stdout.trim(), "imported");
14814
14815 let secret = outside.join("secret.txt");
14816 std::fs::write(&secret, "secret").unwrap();
14817 let probe = python_probe_outside(&secret, stdlib.parent().unwrap());
14818 let denied = execute("python3", &["-c", &probe], &work, &policy, &enforcement);
14819 assert_eq!(denied.exit_code, 0, "{}", denied.stderr);
14820 assert_eq!(
14821 denied.stdout.lines().collect::<Vec<_>>(),
14822 vec!["open PermissionError", "listdir PermissionError"]
14823 );
14824 }
14825
14826 #[test]
14829 fn a_sealed_python3_imports_its_stdlib_and_nothing_outside_it() {
14830 let test = "a_sealed_python3_imports_its_stdlib_and_nothing_outside_it";
14831 if crate::network_namespace::skip_without_egress_namespace(test) {
14832 return;
14833 }
14834 if find_on_path("python3").is_none() {
14835 println!("[SKIP] {test}: no python3 on PATH");
14836 return;
14837 }
14838 let probe = HostProbe::probe();
14839 if probe.tier() != EnforcementTier::KernelSealed {
14840 eprintln!(
14841 "SKIP — PROVES NOTHING ABOUT THE SEALED ROOT ON THIS HOST: {test} needs a host \
14842 that reaches KernelSealed; detected {:?}. No composed root is built in this run.",
14843 probe.tier()
14844 );
14845 return;
14846 }
14847 let (_base, work, outside) = work_and_outside();
14848 let policy = derived_interpreter_policy("python3", &work);
14849 assert_derives_stdlib_and_sqlite(&policy);
14850 let runtime = tempfile::tempdir().unwrap();
14851 let enforcement = ShellEnforcement::resolve_declared(
14852 &policy,
14853 murmur_artifact::ContainmentClass::Sealed,
14854 probe,
14855 )
14856 .expect("a sealed-capable host resolves sealed enforcement")
14857 .with_runtime_dir(test_runtime_dir(runtime.path()), None);
14858 assert_eq!(enforcement.tier, EnforcementTier::KernelSealed);
14859
14860 let imported = execute(
14861 "python3",
14862 &["-c", "import json, sqlite3, venv; print('imported')"],
14863 &work,
14864 &policy,
14865 &enforcement,
14866 );
14867 assert_eq!(imported.exit_code, 0, "{}", imported.stderr);
14868 assert_eq!(imported.stdout.trim(), "imported");
14869
14870 let secret = outside.join("secret.txt");
14871 std::fs::write(&secret, "secret").unwrap();
14872 let program = format!(
14873 "try:\n open({:?}).read(); print('allowed')\nexcept Exception as error:\n \
14874 print(type(error).__name__)\n",
14875 secret.to_string_lossy()
14876 );
14877 let denied = execute("python3", &["-c", &program], &work, &policy, &enforcement);
14878 assert_eq!(denied.exit_code, 0, "{}", denied.stderr);
14879 let outcome = denied.stdout.trim();
14880 assert!(
14881 outcome == "PermissionError" || outcome == "FileNotFoundError",
14882 "{outcome}"
14883 );
14884 }
14885
14886 #[test]
14889 fn a_scoped_perl_loads_posix_from_its_inc() {
14890 let test = "a_scoped_perl_loads_posix_from_its_inc";
14891 if find_on_path("perl").is_none() {
14892 println!("[SKIP] {test}: no perl on PATH");
14893 return;
14894 }
14895 let (_base, work, _outside) = work_and_outside();
14896 let policy = derived_interpreter_policy("perl", &work);
14897 assert!(
14898 !policy.shell_derived_interpreter_runtime.is_empty(),
14899 "perl was not recognised or reported nothing"
14900 );
14901 let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
14902 return;
14903 };
14904 let result = execute(
14905 "perl",
14906 &["-MPOSIX", "-e", "print qq{loaded\n}"],
14907 &work,
14908 &policy,
14909 &enforcement,
14910 );
14911 assert_eq!(result.exit_code, 0, "{}", result.stderr);
14912 assert_eq!(result.stdout.trim(), "loaded");
14913 }
14914
14915 #[test]
14920 fn host_path_landlock_rights_are_read_list_or_the_workdir_set_without_execute() {
14921 use landlock::AccessFs;
14922 assert_eq!(
14923 linux_enforce::host_path_access_rights(false),
14924 AccessFs::ReadFile | AccessFs::ReadDir
14925 );
14926 assert_eq!(
14927 linux_enforce::host_path_access_rights(true),
14928 linux_enforce::workdir_access_rights(false)
14929 );
14930 for read_write in [false, true] {
14931 assert!(!linux_enforce::host_path_access_rights(read_write).contains(AccessFs::Execute));
14932 }
14933 }
14934
14935 struct HostPathDirs {
14938 _base: tempfile::TempDir,
14939 ro: PathBuf,
14940 rw: PathBuf,
14941 outside: PathBuf,
14942 }
14943
14944 impl HostPathDirs {
14945 fn new() -> Self {
14946 let base = home_tempdir();
14947 let root = std::fs::canonicalize(base.path()).unwrap();
14948 let (ro, rw, outside) = (root.join("ro"), root.join("rw"), root.join("outside"));
14949 for dir in [&ro, &rw, &outside] {
14950 std::fs::create_dir(dir).unwrap();
14951 }
14952 std::fs::write(ro.join("in.txt"), "lent\n").unwrap();
14953 std::fs::write(outside.join("secret.txt"), "secret\n").unwrap();
14954 Self {
14955 _base: base,
14956 ro,
14957 rw,
14958 outside,
14959 }
14960 }
14961
14962 fn host_paths(&self) -> Vec<crate::host_paths::HostPath> {
14963 use crate::host_paths::{HostPath, HostPathAccess};
14964 vec![
14965 HostPath {
14966 declared: self.ro.display().to_string(),
14967 path: self.ro.clone(),
14968 access: HostPathAccess::Read,
14969 credential_locations: Vec::new(),
14970 },
14971 HostPath {
14972 declared: self.rw.display().to_string(),
14973 path: self.rw.clone(),
14974 access: HostPathAccess::ReadWrite,
14975 credential_locations: Vec::new(),
14976 },
14977 ]
14978 }
14979
14980 fn assert_reach(&self, enforcement: &ShellEnforcement, policy: &CapabilityPolicy) {
14983 let workdir = tempfile::tempdir().unwrap();
14984 let read = run_bash_in(
14985 workdir.path(),
14986 &format!(
14987 "read -r line < '{}/in.txt' && echo \"$line\"",
14988 self.ro.display()
14989 ),
14990 policy,
14991 enforcement,
14992 );
14993 assert_eq!(read.exit_code, 0, "{}", read.stderr);
14994 assert!(
14995 read.stdout.lines().any(|line| line == "lent"),
14996 "{}",
14997 read.stdout
14998 );
14999
15000 let write_ro = run_bash_in(
15001 workdir.path(),
15002 &format!("echo x > '{}/new'", self.ro.display()),
15003 policy,
15004 enforcement,
15005 );
15006 assert_ne!(write_ro.exit_code, 0);
15007 assert!(
15008 write_ro.stderr.contains("Permission denied")
15009 || write_ro.stderr.contains("Read-only file system"),
15010 "{}",
15011 write_ro.stderr
15012 );
15013 assert!(!self.ro.join("new").exists());
15014
15015 let write_rw = run_bash_in(
15016 workdir.path(),
15017 &format!("echo written > '{}/new'", self.rw.display()),
15018 policy,
15019 enforcement,
15020 );
15021 assert_eq!(write_rw.exit_code, 0, "{}", write_rw.stderr);
15022 assert_eq!(
15023 std::fs::read_to_string(self.rw.join("new")).unwrap(),
15024 "written\n"
15025 );
15026
15027 let outside = run_bash_in(
15028 workdir.path(),
15029 &format!("read -r line < '{}/secret.txt'", self.outside.display()),
15030 policy,
15031 enforcement,
15032 );
15033 assert_ne!(outside.exit_code, 0, "outside/secret.txt was read");
15034 }
15035 }
15036
15037 fn host_path_enforcement(
15040 test: &str,
15041 policy: &CapabilityPolicy,
15042 class: murmur_artifact::ContainmentClass,
15043 tier: EnforcementTier,
15044 ) -> Option<(ShellEnforcement, tempfile::TempDir)> {
15045 if crate::network_namespace::skip_without_egress_namespace(test) {
15046 return None;
15047 }
15048 let probe = HostProbe::probe();
15049 let reaches = match tier {
15050 EnforcementTier::KernelSealed => probe.tier() == EnforcementTier::KernelSealed,
15051 _ => matches!(
15052 probe.tier(),
15053 EnforcementTier::KernelFull | EnforcementTier::KernelSealed
15054 ),
15055 };
15056 if !reaches {
15057 eprintln!(
15058 "SKIP — PROVES NOTHING ON THIS HOST: {test} needs {tier:?}; detected {:?}",
15059 probe.tier()
15060 );
15061 return None;
15062 }
15063 let runtime = home_tempdir();
15064 let enforcement = ShellEnforcement::resolve_declared(policy, class, probe)
15065 .expect("the enforcement resolves")
15066 .with_runtime_dir(test_runtime_dir(runtime.path()), None);
15067 assert_eq!(enforcement.tier, tier);
15068 eprintln!("{test}: tier {tier:?}");
15069 Some((enforcement, runtime))
15070 }
15071
15072 fn resolve_on_tier(
15075 policy: &CapabilityPolicy,
15076 tier: EnforcementTier,
15077 class: murmur_artifact::ContainmentClass,
15078 ) -> Result<ShellEnforcement, String> {
15079 let decision = crate::containment::ContainmentDecision::decide(tier, class, false, false);
15080 let probe = HostProbe::from_parts(
15081 None,
15082 crate::namespace_probe::NamespaceReading {
15083 network: Err(crate::network_namespace::NamespaceAttemptError::Creator(
15084 "not probed".to_string(),
15085 )),
15086 mount: crate::namespace_probe::MountAttempt::NotAttempted,
15087 sealed_root: crate::namespace_probe::MountAttempt::NotAttempted,
15088 private_proc: crate::namespace_probe::MountAttempt::NotAttempted,
15089 userns_grant: crate::sealed::UsernsGrant::Withheld,
15090 attachment: crate::sealed::ProfileAttachment::NotReported,
15091 profile_gap: crate::sealed::ProfileGap::Undetermined,
15092 },
15093 );
15094 ShellEnforcement::resolve(policy, &decision, probe)
15095 }
15096
15097 #[test]
15098 fn host_paths_on_kernel_full_read_write_and_outside() {
15099 const TEST: &str = "host_paths_on_kernel_full_read_write_and_outside";
15100 let dirs = HostPathDirs::new();
15101 let policy = CapabilityPolicy {
15102 shell_allow: vec!["bash".to_string()],
15103 host_paths: dirs.host_paths(),
15104 ..CapabilityPolicy::default()
15105 };
15106 let Some((enforcement, _runtime)) = host_path_enforcement(
15107 TEST,
15108 &policy,
15109 murmur_artifact::ContainmentClass::Scoped,
15110 EnforcementTier::KernelFull,
15111 ) else {
15112 return;
15113 };
15114 dirs.assert_reach(&enforcement, &policy);
15115 }
15116
15117 #[test]
15118 fn host_paths_on_kernel_sealed_are_bound_at_their_own_path() {
15119 const TEST: &str = "host_paths_on_kernel_sealed_are_bound_at_their_own_path";
15120 let dirs = HostPathDirs::new();
15121 let policy = CapabilityPolicy {
15122 shell_allow: vec!["bash".to_string()],
15123 host_paths: dirs.host_paths(),
15124 ..CapabilityPolicy::default()
15125 };
15126 let Some((enforcement, _runtime)) = host_path_enforcement(
15127 TEST,
15128 &policy,
15129 murmur_artifact::ContainmentClass::Sealed,
15130 EnforcementTier::KernelSealed,
15131 ) else {
15132 return;
15133 };
15134 dirs.assert_reach(&enforcement, &policy);
15135 let workdir = tempfile::tempdir().unwrap();
15137 let absent = run_bash_in(
15138 workdir.path(),
15139 &format!(
15140 "[ -e '{}/secret.txt' ] && echo present || echo absent",
15141 dirs.outside.display()
15142 ),
15143 &policy,
15144 &enforcement,
15145 );
15146 assert!(
15147 absent.stdout.lines().any(|line| line == "absent"),
15148 "{}",
15149 absent.stdout
15150 );
15151 }
15152
15153 #[test]
15157 fn a_read_write_host_path_over_an_execute_root_is_refused_scoped_and_protected_sealed() {
15158 const TEST: &str =
15159 "a_read_write_host_path_over_an_execute_root_is_refused_scoped_and_protected_sealed";
15160 let base = home_tempdir();
15161 let tools = std::fs::canonicalize(base.path()).unwrap().join("tools");
15162 let bin = tools.join("bin");
15163 std::fs::create_dir_all(&bin).unwrap();
15164 std::fs::write(bin.join("tool"), "#!/bin/bash\necho tool ran\n").unwrap();
15165 let policy = CapabilityPolicy {
15166 shell_allow: vec!["bash".to_string()],
15167 shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
15168 binary: "bash".to_string(),
15169 dirs: vec![murmur_artifact::InterpreterRuntimeDir {
15170 path: bin.display().to_string(),
15171 list_dir: true,
15172 }],
15173 }],
15174 host_paths: vec![crate::host_paths::HostPath {
15175 declared: tools.display().to_string(),
15176 path: tools.clone(),
15177 access: crate::host_paths::HostPathAccess::ReadWrite,
15178 credential_locations: Vec::new(),
15179 }],
15180 ..CapabilityPolicy::default()
15181 };
15182 let error = resolve_on_tier(
15183 &policy,
15184 EnforcementTier::KernelFull,
15185 murmur_artifact::ContainmentClass::Scoped,
15186 )
15187 .expect_err("scoped refuses a read-write host path over an execute root");
15188 assert!(error.contains(&bin.display().to_string()), "{error}");
15189
15190 let Some((enforcement, _runtime)) = host_path_enforcement(
15191 TEST,
15192 &policy,
15193 murmur_artifact::ContainmentClass::Sealed,
15194 EnforcementTier::KernelSealed,
15195 ) else {
15196 return;
15197 };
15198 assert_eq!(enforcement.host_path_read_only_binds, vec![bin.clone()]);
15199 let workdir = tempfile::tempdir().unwrap();
15200 let rewrite = run_bash_in(
15201 workdir.path(),
15202 &format!("echo x > '{}/tool'", bin.display()),
15203 &policy,
15204 &enforcement,
15205 );
15206 assert_ne!(rewrite.exit_code, 0);
15207 assert!(
15208 rewrite.stderr.contains("Read-only file system"),
15209 "{}",
15210 rewrite.stderr
15211 );
15212 let add = run_bash_in(
15213 workdir.path(),
15214 &format!("echo x > '{}/new'", bin.display()),
15215 &policy,
15216 &enforcement,
15217 );
15218 assert_ne!(add.exit_code, 0);
15219 assert!(
15220 add.stderr.contains("Read-only file system"),
15221 "{}",
15222 add.stderr
15223 );
15224 assert_eq!(
15225 std::fs::read_to_string(bin.join("tool")).unwrap(),
15226 "#!/bin/bash\necho tool ran\n"
15227 );
15228 assert!(!bin.join("new").exists());
15229 let elsewhere = run_bash_in(
15230 workdir.path(),
15231 &format!("echo kept > '{}/notes'", tools.display()),
15232 &policy,
15233 &enforcement,
15234 );
15235 assert_eq!(elsewhere.exit_code, 0, "{}", elsewhere.stderr);
15236 assert_eq!(
15237 std::fs::read_to_string(tools.join("notes")).unwrap(),
15238 "kept\n"
15239 );
15240 }
15241
15242 #[test]
15244 fn a_vanished_host_path_fails_the_spawn_naming_it() {
15245 let base = home_tempdir();
15246 let gone = std::fs::canonicalize(base.path()).unwrap().join("gone");
15247 std::fs::create_dir(&gone).unwrap();
15248 let host_paths = vec![crate::host_paths::HostPath {
15249 declared: gone.display().to_string(),
15250 path: gone.clone(),
15251 access: crate::host_paths::HostPathAccess::Read,
15252 credential_locations: Vec::new(),
15253 }];
15254 std::fs::remove_dir(&gone).unwrap();
15255 let workdir = tempfile::tempdir().unwrap();
15256 for tier in [EnforcementTier::KernelFull, EnforcementTier::KernelSealed] {
15257 let enforcement = ShellEnforcement {
15258 tier,
15259 host_paths: host_paths.clone(),
15260 ..host_bounding_base().with_test_session_dirs()
15261 };
15262 let mut command = std::process::Command::new("bash");
15263 let Err(error) = prepare_enforcement(&mut command, &enforcement, workdir.path()) else {
15264 panic!("{tier:?}: a vanished host path must fail the spawn");
15265 };
15266 assert!(
15267 error.contains(&gone.display().to_string()),
15268 "{tier:?}: {error}"
15269 );
15270 }
15271 }
15272
15273 #[test]
15275 fn no_host_paths_add_nothing_to_the_enforcement() {
15276 let policy = CapabilityPolicy {
15277 shell_allow: vec!["bash".to_string()],
15278 ..CapabilityPolicy::default()
15279 };
15280 for tier in [EnforcementTier::KernelFull, EnforcementTier::KernelSealed] {
15281 let class = if tier == EnforcementTier::KernelSealed {
15282 murmur_artifact::ContainmentClass::Sealed
15283 } else {
15284 murmur_artifact::ContainmentClass::Scoped
15285 };
15286 let enforcement = resolve_on_tier(&policy, tier, class).unwrap();
15287 assert_eq!(enforcement.tier, tier);
15288 assert!(enforcement.host_paths.is_empty());
15289 assert!(enforcement.host_path_read_only_binds.is_empty());
15290 }
15291 }
15292}
15293
15294#[cfg(all(test, target_os = "linux"))]
15296mod guest_worker_enforcement_tests {
15297 use super::*;
15298
15299 fn session(tier: EnforcementTier) -> ShellEnforcement {
15300 let mut enforcement = ShellEnforcement::environment_only().with_test_session_dirs();
15301 enforcement.tier = tier;
15302 enforcement.workdir_exec = true;
15303 enforcement.landlock_grants = vec![LandlockGrant {
15304 path: PathBuf::from("/usr/bin/bash"),
15305 list_dir: false,
15306 executable: true,
15307 }];
15308 enforcement.sealed_bind_dirs = vec![PathBuf::from("/usr/bin")];
15309 enforcement.host_paths = vec![crate::host_paths::HostPath {
15310 declared: "~/lent".to_string(),
15311 path: PathBuf::from("/srv/lent"),
15312 access: crate::host_paths::HostPathAccess::Read,
15313 credential_locations: Vec::new(),
15314 }];
15315 enforcement
15316 }
15317
15318 #[test]
15322 fn a_worker_enforcement_is_its_grant_closure_without_execute() {
15323 let image = std::env::current_exe().unwrap().canonicalize().unwrap();
15324 let state = PathBuf::from("/srv/state/notes");
15325 for tier in [EnforcementTier::KernelFull, EnforcementTier::KernelSealed] {
15326 let session = session(tier);
15327 let tools = session.for_guest_worker(
15328 &WorkerGrantClosure {
15329 read_write: vec![state.clone()],
15330 runtime_dir: true,
15331 host_paths: true,
15332 },
15333 &image,
15334 );
15335 assert_eq!(tools.tier, tier);
15336 assert!(!tools.workdir_exec);
15337 assert!(tools.sealed_bind_dirs.is_empty());
15338 assert!(tools
15339 .landlock_grants
15340 .iter()
15341 .any(|grant| grant.path == image));
15342 for grant in &tools.landlock_grants {
15343 assert!(
15344 resolve_landlock_grants(std::slice::from_ref(&image)).contains(&grant.path),
15345 "{tier:?}: only the worker image's closure is granted: {grant:?}"
15346 );
15347 }
15348 assert!(!tools
15349 .landlock_grants
15350 .iter()
15351 .any(|grant| grant.path == Path::new("/usr/bin/bash")));
15352 let roots = tools
15353 .runtime_dir
15354 .as_ref()
15355 .expect("the tools worker reads it");
15356 assert!(roots.records.is_none(), "{tier:?}: no records directory");
15357 assert_eq!(roots.temp.is_some(), tier == EnforcementTier::KernelSealed);
15358 let host: Vec<_> = tools
15359 .host_paths
15360 .iter()
15361 .map(|path| (path.path.clone(), path.access))
15362 .collect();
15363 assert_eq!(
15364 host,
15365 vec![
15366 (
15367 PathBuf::from("/srv/lent"),
15368 crate::host_paths::HostPathAccess::Read
15369 ),
15370 (state.clone(), crate::host_paths::HostPathAccess::ReadWrite),
15371 ]
15372 );
15373 assert_eq!(tools.resource_limits.memory_bytes, u64::MAX);
15374 assert_eq!(tools.resource_limits.max_processes, u64::MAX);
15375 assert_eq!(tools.resource_limits.cpu_seconds, None);
15376 assert!(tools.cgroup_scope.is_none() && tools.runtime_dir_owner.is_none());
15377 assert!(tools.fd_hygiene_fallback && !session.fd_hygiene_fallback);
15378
15379 let driver = session.for_guest_worker(&WorkerGrantClosure::default(), &image);
15380 assert!(driver.host_paths.is_empty(), "{tier:?}");
15381 assert_eq!(
15382 driver.runtime_dir.is_some(),
15383 tier == EnforcementTier::KernelSealed,
15384 "{tier:?}: only a composed root needs the runtime directory without a grant"
15385 );
15386 assert!(Arc::ptr_eq(&driver.session_netns, &session.session_netns));
15387 }
15388 }
15389}