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 resolve_landlock_grants_outside(exec_allow_paths, search_dirs, &[])
1042}
1043
1044pub(crate) fn resolve_landlock_grants_outside(
1048 exec_allow_paths: &[PathBuf],
1049 search_dirs: &[PathBuf],
1050 excluded: &[PathBuf],
1051) -> Vec<PathBuf> {
1052 let Ok(grants) = walk_dependency_closure(
1053 exec_allow_paths,
1054 search_dirs,
1055 excluded,
1056 |path| -> Result<_, std::convert::Infallible> { Ok(read_elf_dependencies(path)) },
1057 );
1058 grants
1059}
1060
1061fn read_elf_dependencies(path: &Path) -> Option<ElfDependencies> {
1064 parse_elf_dependencies(&ElfFile::open_host(path).ok()?)
1065}
1066
1067pub(crate) fn walk_dependency_closure<E>(
1071 exec_allow_paths: &[PathBuf],
1072 search_dirs: &[PathBuf],
1073 excluded: &[PathBuf],
1074 mut dependencies: impl FnMut(&Path) -> Result<Option<ElfDependencies>, E>,
1075) -> Result<Vec<PathBuf>, E> {
1076 let mut grants: std::collections::BTreeSet<PathBuf> = std::collections::BTreeSet::new();
1077 let mut visited: std::collections::BTreeSet<PathBuf> = std::collections::BTreeSet::new();
1078 let mut queue: std::collections::VecDeque<PathBuf> = std::collections::VecDeque::new();
1079
1080 for path in exec_allow_paths {
1081 if let Ok(canonical) = std::fs::canonicalize(path) {
1086 queue.push_back(canonical);
1087 }
1088 }
1089
1090 while let Some(path) = queue.pop_front() {
1091 if excluded.iter().any(|root| path.starts_with(root)) {
1092 continue;
1093 }
1094 if !visited.insert(path.clone()) {
1095 continue;
1096 }
1097 grants.insert(path.clone());
1100
1101 let Some(deps) = dependencies(&path)? else {
1102 continue;
1103 };
1104 let origin_dir = path.parent().unwrap_or_else(|| Path::new("/"));
1105
1106 if let Some(interp) = &deps.interp {
1107 if let Ok(canonical) = std::fs::canonicalize(interp) {
1108 queue.push_back(canonical);
1109 }
1110 }
1111 for soname in &deps.needed {
1112 if let Some(resolved) = resolve_soname(soname, origin_dir, &deps.runpaths, search_dirs)
1113 {
1114 queue.push_back(resolved);
1115 }
1116 }
1117 }
1118
1119 Ok(grants.into_iter().collect())
1120}
1121
1122#[derive(Debug, Clone, PartialEq, Eq)]
1163pub(crate) struct LandlockGrant {
1164 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1165 pub(crate) path: PathBuf,
1166 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1167 pub(crate) list_dir: bool,
1168 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1169 pub(crate) executable: bool,
1170 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1176 pub(crate) pinned: Option<FileIdentity>,
1177}
1178
1179#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1181pub(crate) struct FileIdentity {
1182 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1183 dev: u64,
1184 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1185 ino: u64,
1186}
1187
1188impl FileIdentity {
1189 #[cfg(unix)]
1190 pub(crate) fn of(metadata: &std::fs::Metadata) -> FileIdentity {
1191 use std::os::unix::fs::MetadataExt;
1192 FileIdentity {
1193 dev: metadata.dev(),
1194 ino: metadata.ino(),
1195 }
1196 }
1197}
1198
1199impl LandlockGrant {
1200 fn non_listable_files(paths: Vec<PathBuf>) -> Vec<LandlockGrant> {
1204 paths
1205 .into_iter()
1206 .map(|path| LandlockGrant {
1207 path,
1208 list_dir: false,
1209 executable: true,
1210 pinned: None,
1211 })
1212 .collect()
1213 }
1214}
1215
1216pub(crate) fn resolve_interpreter_runtime_grants(
1227 grants: &[InterpreterRuntimeGrant],
1228) -> Vec<LandlockGrant> {
1229 grants
1230 .iter()
1231 .flat_map(|grant| {
1232 grant.dirs.iter().map(|dir| LandlockGrant {
1233 path: PathBuf::from(&dir.path),
1234 list_dir: dir.list_dir,
1235 executable: true,
1238 pinned: None,
1239 })
1240 })
1241 .collect()
1242}
1243
1244pub(crate) fn resolve_derived_interpreter_grants(
1253 derived: &[crate::interpreter_runtime::DerivedInterpreterRuntime],
1254) -> Vec<LandlockGrant> {
1255 let read_only = |path: &Path, list_dir: bool| LandlockGrant {
1256 path: path.to_path_buf(),
1257 list_dir,
1258 executable: false,
1259 pinned: None,
1260 };
1261 derived
1262 .iter()
1263 .flat_map(|runtime| {
1264 runtime
1265 .entries()
1266 .map(|(path, is_dir)| read_only(path, is_dir))
1267 .chain(runtime.libraries.iter().map(|path| read_only(path, false)))
1268 .collect::<Vec<_>>()
1269 })
1270 .collect()
1271}
1272
1273pub(crate) fn resolve_trust_store_grants(
1285 paths: &[crate::trust_store::TrustStorePath],
1286) -> Vec<LandlockGrant> {
1287 paths
1288 .iter()
1289 .map(|entry| LandlockGrant {
1290 path: entry.path.clone(),
1291 list_dir: entry.directory,
1292 executable: false,
1293 pinned: None,
1294 })
1295 .collect()
1296}
1297
1298pub(crate) fn resolve_sealed_bind_dirs(
1317 exec_allow_paths: &[PathBuf],
1318 policy: &CapabilityPolicy,
1319) -> Vec<PathBuf> {
1320 let mut dirs: Vec<PathBuf> = Vec::new();
1321 let candidates = exec_allow_paths
1322 .iter()
1323 .filter_map(|binary| binary.parent().map(Path::to_path_buf))
1324 .chain(
1325 policy
1326 .shell_interpreter_runtime
1327 .iter()
1328 .flat_map(|grant| grant.dirs.iter().map(|dir| PathBuf::from(&dir.path))),
1329 )
1330 .chain(
1331 policy
1332 .shell_derived_interpreter_runtime
1333 .iter()
1334 .flat_map(|runtime| {
1335 runtime
1336 .entries()
1337 .filter(|(_, is_dir)| *is_dir)
1338 .map(|(path, _)| path.to_path_buf())
1339 .chain(
1340 runtime
1341 .libraries
1342 .iter()
1343 .filter_map(|library| library.parent().map(Path::to_path_buf)),
1344 )
1345 .collect::<Vec<_>>()
1346 }),
1347 );
1348
1349 for candidate in candidates {
1350 push_sealed_bind_dir(&mut dirs, candidate);
1351 }
1352 dirs
1353}
1354
1355pub(crate) fn host_path_execute_roots(
1361 policy: &CapabilityPolicy,
1362) -> Vec<crate::host_paths::ExecuteRoot> {
1363 let exec_allow_paths = resolve_exec_allowlist(&policy.shell_allow);
1364 execute_roots_from(&resolve_landlock_grants(&exec_allow_paths), policy)
1365}
1366
1367fn execute_roots_from(
1369 closure: &[PathBuf],
1370 policy: &CapabilityPolicy,
1371) -> Vec<crate::host_paths::ExecuteRoot> {
1372 let mut roots: Vec<crate::host_paths::ExecuteRoot> = Vec::new();
1373 let mut push = |dir: PathBuf, because: PathBuf| {
1374 let dir = std::fs::canonicalize(&dir).unwrap_or(dir);
1375 if dir.is_absolute() && !roots.iter().any(|root| root.dir == dir) {
1376 roots.push(crate::host_paths::ExecuteRoot { dir, because });
1377 }
1378 };
1379 for file in closure {
1380 if let Some(parent) = file.parent() {
1381 push(parent.to_path_buf(), file.clone());
1382 }
1383 }
1384 for grant in &policy.shell_interpreter_runtime {
1385 for dir in &grant.dirs {
1386 push(PathBuf::from(&dir.path), PathBuf::from(&dir.path));
1387 }
1388 }
1389 for dir in resolve_staged_runtime_dirs(policy) {
1390 push(dir.clone(), dir);
1391 }
1392 roots
1393}
1394
1395pub(crate) fn host_path_mechanism(tier: EnforcementTier) -> crate::host_paths::HostPathMechanism {
1397 match tier {
1398 EnforcementTier::KernelSealed => crate::host_paths::HostPathMechanism::Bind,
1399 EnforcementTier::KernelFull => crate::host_paths::HostPathMechanism::Landlock,
1400 EnforcementTier::KernelSeccompOnly
1401 | EnforcementTier::EnvironmentOnly
1402 | EnforcementTier::Unconfined => crate::host_paths::HostPathMechanism::None,
1403 }
1404}
1405
1406fn push_sealed_bind_dir(dirs: &mut Vec<PathBuf>, candidate: PathBuf) {
1410 if !candidate.is_absolute() {
1411 return;
1412 }
1413 if crate::sealed::SEALED_RUNTIME_PATHS
1414 .iter()
1415 .any(|root| candidate.starts_with(root))
1416 {
1417 return;
1418 }
1419 if dirs.contains(&candidate) {
1420 return;
1421 }
1422 dirs.push(candidate);
1423}
1424
1425const SHEBANG_READ_LIMIT: u64 = 256;
1429
1430pub(crate) fn shebang_interpreter(image: &[u8]) -> Option<PathBuf> {
1438 let head = &image[..image.len().min(SHEBANG_READ_LIMIT as usize)];
1439 let line = crate::reachability::shebang_line_of(head)?;
1440 let interpreter = PathBuf::from(line.split_whitespace().next()?);
1441 interpreter.is_absolute().then_some(interpreter)
1442}
1443
1444#[derive(Debug, Clone, PartialEq, Eq)]
1455pub(crate) struct NativeEntrypoint {
1456 path: PathBuf,
1457 pinned: Option<FileIdentity>,
1458 interpreter: Option<PathBuf>,
1459 closure: Vec<PathBuf>,
1460}
1461
1462impl NativeEntrypoint {
1463 pub(crate) fn derive(path: &Path, image: &[u8], accessible_workdir: &Path) -> NativeEntrypoint {
1466 let search_dirs: Vec<PathBuf> = DEFAULT_LIBRARY_SEARCH_DIRS
1467 .iter()
1468 .map(PathBuf::from)
1469 .collect();
1470 Self::derive_in(path, image, accessible_workdir, &search_dirs)
1471 }
1472
1473 fn derive_in(
1474 path: &Path,
1475 image: &[u8],
1476 accessible_workdir: &Path,
1477 search_dirs: &[PathBuf],
1478 ) -> NativeEntrypoint {
1479 let interpreter = shebang_interpreter(image);
1480 let mut seeds: Vec<PathBuf> = interpreter.iter().cloned().collect();
1481 if let Some(deps) = parse_elf_dependencies(image) {
1482 let absolute = |entry: &&String| Path::new(entry.as_str()).is_absolute();
1489 seeds.extend(deps.interp.iter().filter(absolute).map(PathBuf::from));
1490 let runpaths: Vec<String> = deps
1491 .runpaths
1492 .iter()
1493 .filter(|entry| absolute(entry) && !entry.contains("ORIGIN"))
1494 .cloned()
1495 .collect();
1496 seeds.extend(
1497 deps.needed
1498 .iter()
1499 .filter(|soname| !soname.contains('/') || absolute(soname))
1500 .filter_map(|soname| {
1501 resolve_soname(soname, Path::new("/"), &runpaths, search_dirs)
1502 }),
1503 );
1504 }
1505 let excluded = std::fs::canonicalize(accessible_workdir)
1506 .unwrap_or_else(|_| accessible_workdir.to_path_buf());
1507 NativeEntrypoint {
1508 path: path.to_path_buf(),
1509 pinned: pinned_identity(path),
1510 interpreter,
1511 closure: resolve_landlock_grants_outside(&seeds, search_dirs, &[excluded]),
1512 }
1513 }
1514
1515 pub(crate) fn path(&self) -> &Path {
1517 &self.path
1518 }
1519}
1520
1521#[cfg(unix)]
1524fn pinned_identity(path: &Path) -> Option<FileIdentity> {
1525 std::fs::symlink_metadata(path)
1526 .ok()
1527 .filter(std::fs::Metadata::is_file)
1528 .map(|meta| FileIdentity::of(&meta))
1529}
1530
1531#[cfg(not(unix))]
1532fn pinned_identity(_path: &Path) -> Option<FileIdentity> {
1533 None
1534}
1535
1536pub(crate) fn resolve_staged_runtime_dirs(policy: &CapabilityPolicy) -> Vec<PathBuf> {
1557 let mut dirs: Vec<PathBuf> = Vec::new();
1558 for grant in &policy.shell_staged_runtime {
1559 let path = PathBuf::from(&grant.source_path);
1560 if !path.is_absolute() || dirs.contains(&path) {
1561 continue;
1562 }
1563 dirs.push(path);
1564 }
1565 dirs
1566}
1567
1568pub(crate) fn resolve_staged_runtime_landlock_grants(
1591 policy: &CapabilityPolicy,
1592) -> Vec<LandlockGrant> {
1593 resolve_staged_runtime_dirs(policy)
1594 .into_iter()
1595 .map(|path| LandlockGrant {
1596 path,
1597 list_dir: true,
1598 executable: true,
1599 pinned: None,
1600 })
1601 .collect()
1602}
1603
1604pub(crate) fn resolve_sealed_runtime_landlock_grants(tier: EnforcementTier) -> Vec<LandlockGrant> {
1629 if tier != EnforcementTier::KernelSealed {
1630 return Vec::new();
1631 }
1632 crate::sealed::SEALED_RUNTIME_PATHS
1633 .iter()
1634 .map(|path| LandlockGrant {
1635 path: PathBuf::from(path),
1636 list_dir: true,
1637 executable: false,
1638 pinned: None,
1639 })
1640 .collect()
1641}
1642
1643pub(crate) fn resolve_sealed_etc_landlock_grants(tier: EnforcementTier) -> Vec<LandlockGrant> {
1681 if tier != EnforcementTier::KernelSealed {
1682 return Vec::new();
1683 }
1684 crate::sealed::SEALED_ETC_PATHS
1685 .iter()
1686 .map(|entry| LandlockGrant {
1687 path: PathBuf::from(entry.path),
1688 list_dir: entry.directory,
1689 executable: false,
1690 pinned: None,
1691 })
1692 .collect()
1693}
1694
1695#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1726#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1727pub(crate) struct CapsuleDeviceGrant {
1728 pub(crate) path: &'static str,
1729 pub(crate) writable: bool,
1730}
1731
1732#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1771pub(crate) const CAPSULE_DEVICE_GRANTS: &[CapsuleDeviceGrant] = &[
1772 CapsuleDeviceGrant {
1773 path: "/dev/null",
1774 writable: true,
1775 },
1776 CapsuleDeviceGrant {
1777 path: "/dev/zero",
1778 writable: false,
1779 },
1780 CapsuleDeviceGrant {
1781 path: "/dev/urandom",
1782 writable: false,
1783 },
1784];
1785
1786#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1790pub(crate) fn network_ip_allowed(ip: IpAddr, network_allow_ips: &[IpAddr]) -> bool {
1791 network_allow_ips.contains(&ip)
1792}
1793
1794const LINUX_AF_UNIX: i32 = 1;
1803const LINUX_AF_INET: i32 = 2;
1804const LINUX_AF_INET6: i32 = 10;
1805const LINUX_AF_NETLINK: i32 = 16;
1806const LINUX_AF_PACKET: i32 = 17;
1807
1808#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1840pub(crate) fn denied_socket_domains(unix_sockets_allowed: bool) -> Vec<i32> {
1841 let mut denied = vec![LINUX_AF_NETLINK, LINUX_AF_PACKET];
1842 if !unix_sockets_allowed {
1843 denied.push(LINUX_AF_UNIX);
1844 }
1845 denied
1846}
1847
1848#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1866pub(crate) fn allowed_socket_domains(unix_sockets_allowed: bool) -> Vec<i32> {
1867 let mut allowed = vec![LINUX_AF_INET_DOMAIN, LINUX_AF_INET6_DOMAIN];
1868 if unix_sockets_allowed {
1869 allowed.push(LINUX_AF_UNIX);
1870 }
1871 allowed
1872}
1873
1874#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1880const LINUX_AF_INET_DOMAIN: i32 = LINUX_AF_INET;
1881#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1882const LINUX_AF_INET6_DOMAIN: i32 = LINUX_AF_INET6;
1883
1884#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1915pub(crate) const SECCOMP_SYSCALL_ALLOWLIST: &[&str] = &[
1916 "execve",
1920 "execveat",
1921 "clone",
1928 "clone3",
1929 "fork",
1930 "vfork",
1931 "exit",
1932 "exit_group",
1933 "wait4",
1934 "waitid",
1935 "kill",
1936 "tkill",
1937 "tgkill",
1938 "rt_sigaction",
1939 "rt_sigprocmask",
1940 "rt_sigreturn",
1941 "rt_sigsuspend",
1942 "rt_sigpending",
1943 "rt_sigtimedwait",
1944 "rt_sigqueueinfo",
1945 "rt_tgsigqueueinfo",
1946 "sigaltstack",
1947 "restart_syscall",
1950 "pause",
1951 "set_tid_address",
1952 "set_robust_list",
1953 "get_robust_list",
1954 "rseq",
1956 "prctl",
1957 "arch_prctl",
1958 "getpid",
1959 "getppid",
1960 "gettid",
1961 "getpgrp",
1962 "getpgid",
1963 "setpgid",
1964 "getsid",
1965 "setsid",
1966 "getpriority",
1967 "setpriority",
1968 "ioprio_get",
1969 "ioprio_set",
1970 "sched_yield",
1971 "sched_getaffinity",
1972 "sched_setaffinity",
1973 "sched_getparam",
1974 "sched_setparam",
1975 "sched_getscheduler",
1976 "sched_setscheduler",
1977 "sched_getattr",
1978 "sched_setattr",
1979 "sched_get_priority_max",
1980 "sched_get_priority_min",
1981 "sched_rr_get_interval",
1982 "getcpu",
1983 "capget",
1984 "capset",
1985 "prlimit64",
1986 "getrlimit",
1987 "setrlimit",
1988 "getrusage",
1989 "getitimer",
1990 "setitimer",
1991 "times",
1992 "uname",
1993 "sysinfo",
1994 "pidfd_open",
1996 "pidfd_send_signal",
1997 "mmap",
1999 "munmap",
2000 "mprotect",
2001 "mremap",
2002 "madvise",
2003 "brk",
2004 "mlock",
2005 "mlock2",
2006 "munlock",
2007 "mlockall",
2008 "munlockall",
2009 "membarrier",
2010 "msync",
2011 "mincore",
2012 "memfd_create",
2013 "pkey_alloc",
2014 "pkey_free",
2015 "pkey_mprotect",
2016 "shmget",
2017 "shmat",
2018 "shmdt",
2019 "shmctl",
2020 "open",
2022 "openat",
2023 "openat2",
2024 "creat",
2025 "close",
2026 "close_range",
2027 "read",
2028 "write",
2029 "pread64",
2030 "pwrite64",
2031 "readv",
2032 "writev",
2033 "preadv",
2034 "pwritev",
2035 "preadv2",
2036 "pwritev2",
2037 "lseek",
2038 "dup",
2039 "dup2",
2040 "dup3",
2041 "fcntl",
2042 "flock",
2043 "fsync",
2044 "fdatasync",
2045 "sync",
2046 "syncfs",
2047 "sync_file_range",
2048 "fadvise64",
2049 "readahead",
2050 "truncate",
2051 "ftruncate",
2052 "fallocate",
2053 "stat",
2054 "fstat",
2055 "lstat",
2056 "newfstatat",
2057 "statx",
2058 "access",
2059 "faccessat",
2060 "faccessat2",
2061 "getdents",
2062 "getdents64",
2063 "getcwd",
2064 "chdir",
2065 "fchdir",
2066 "mkdir",
2067 "mkdirat",
2068 "rmdir",
2069 "unlink",
2070 "unlinkat",
2071 "rename",
2072 "renameat",
2073 "renameat2",
2074 "link",
2075 "linkat",
2076 "symlink",
2077 "symlinkat",
2078 "readlink",
2079 "readlinkat",
2080 "chmod",
2081 "fchmod",
2082 "fchmodat",
2083 "fchmodat2",
2086 "chown",
2087 "fchown",
2088 "fchownat",
2089 "lchown",
2090 "umask",
2091 "utime",
2092 "utimes",
2093 "utimensat",
2094 "futimesat",
2095 "mknod",
2099 "mknodat",
2100 "sendfile",
2101 "copy_file_range",
2102 "splice",
2103 "tee",
2104 "vmsplice",
2105 "ioctl",
2106 "poll",
2107 "ppoll",
2108 "select",
2109 "pselect6",
2110 "epoll_create",
2111 "epoll_create1",
2112 "epoll_ctl",
2113 "epoll_wait",
2114 "epoll_pwait",
2115 "epoll_pwait2",
2116 "eventfd",
2117 "eventfd2",
2118 "pipe",
2119 "pipe2",
2120 "inotify_init",
2121 "inotify_init1",
2122 "inotify_add_watch",
2123 "inotify_rm_watch",
2124 "signalfd",
2125 "signalfd4",
2126 "timerfd_create",
2127 "timerfd_settime",
2128 "timerfd_gettime",
2129 "getxattr",
2130 "lgetxattr",
2131 "fgetxattr",
2132 "setxattr",
2133 "lsetxattr",
2134 "fsetxattr",
2135 "listxattr",
2136 "llistxattr",
2137 "flistxattr",
2138 "removexattr",
2139 "lremovexattr",
2140 "fremovexattr",
2141 "statfs",
2142 "fstatfs",
2143 "name_to_handle_at",
2146 "io_setup",
2150 "io_destroy",
2151 "io_submit",
2152 "io_cancel",
2153 "io_getevents",
2154 "connect",
2161 "sendto",
2162 "socketpair",
2163 "bind",
2164 "listen",
2165 "accept",
2166 "accept4",
2167 "getsockname",
2168 "getpeername",
2169 "getsockopt",
2170 "setsockopt",
2171 "shutdown",
2172 "sendmsg",
2173 "sendmmsg",
2174 "recvfrom",
2175 "recvmsg",
2176 "recvmmsg",
2177 "clock_gettime",
2179 "clock_getres",
2180 "clock_nanosleep",
2181 "nanosleep",
2182 "gettimeofday",
2183 "time",
2184 "timer_create",
2185 "timer_settime",
2186 "timer_gettime",
2187 "timer_getoverrun",
2188 "timer_delete",
2189 "alarm",
2190 "msgget",
2192 "msgsnd",
2193 "msgrcv",
2194 "msgctl",
2195 "semget",
2196 "semop",
2197 "semctl",
2198 "semtimedop",
2199 "mq_open",
2200 "mq_unlink",
2201 "mq_timedsend",
2202 "mq_timedreceive",
2203 "mq_notify",
2204 "mq_getsetattr",
2205 "getrandom",
2207 "futex",
2208 "futex_waitv",
2209 "getuid",
2210 "geteuid",
2211 "getgid",
2212 "getegid",
2213 "setuid",
2214 "setgid",
2215 "setreuid",
2216 "setregid",
2217 "setresuid",
2218 "setresgid",
2219 "getresuid",
2220 "getresgid",
2221 "getgroups",
2222 "setgroups",
2223 "setfsuid",
2224 "setfsgid",
2225];
2226
2227#[allow(dead_code)]
2256pub(crate) const SECCOMP_MUST_STAY_DENIED: &[&str] = &[
2257 "io_uring_setup",
2259 "io_uring_enter",
2260 "io_uring_register",
2261 "bpf",
2262 "userfaultfd",
2263 "perf_event_open",
2264 "process_vm_readv",
2265 "process_vm_writev",
2266 "open_by_handle_at",
2267 "keyctl",
2268 "add_key",
2269 "request_key",
2270 "ptrace",
2271 "mount",
2273 "umount2",
2274 "pivot_root",
2275 "chroot",
2276 "unshare",
2277 "setns",
2278 "init_module",
2279 "finit_module",
2280 "delete_module",
2281 "kexec_load",
2282 "kexec_file_load",
2283 "reboot",
2284 "swapon",
2285 "swapoff",
2286 "acct",
2287 "quotactl",
2288 "nfsservctl",
2289 "iopl",
2290 "ioperm",
2291 "vm86",
2292 "vm86old",
2293 "_sysctl",
2294 "sysfs",
2295 "uselib",
2296 "create_module",
2297 "get_kernel_syms",
2298 "query_module",
2299 "lookup_dcookie",
2300 "clock_adjtime",
2301 "clock_settime",
2302 "settimeofday",
2303 "stime",
2304 "adjtimex",
2305 "set_mempolicy",
2306 "get_mempolicy",
2307 "mbind",
2308 "migrate_pages",
2309 "move_pages",
2310 "fanotify_init",
2311 "fanotify_mark",
2312 "seccomp",
2314 "personality",
2315 "kcmp",
2316];
2317
2318#[allow(dead_code)]
2329pub(crate) const SECCOMP_FILTER_INSTRUCTION_BUDGET: usize = 512;
2330
2331#[allow(dead_code)]
2336pub(crate) const BPF_MAXINSNS_CEILING: usize = 4096;
2337
2338#[allow(dead_code)]
2344pub(crate) const SECCOMP_MAX_INSNS_PER_PATH: usize = 32768;
2345
2346#[derive(Debug, Clone)]
2348pub(crate) struct ShellEnforcement {
2349 pub(crate) tier: EnforcementTier,
2350 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2358 pub(crate) network_allow_ips: Vec<IpAddr>,
2359 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2367 pub(crate) network_allow_rules: Vec<crate::network_policy::NetworkAllowRule>,
2368 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2373 pub(crate) egress_tcp_ports: Vec<u16>,
2374 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2381 pub(crate) unix_sockets_allowed: bool,
2382 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2392 pub(crate) workdir_exec: bool,
2393 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2401 pub(crate) landlock_abi: Option<u8>,
2402 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2438 pub(crate) landlock_grants: Vec<LandlockGrant>,
2439 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2447 pub(crate) sealed_bind_dirs: Vec<PathBuf>,
2448 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2459 pub(crate) staged_runtime_dirs: Vec<PathBuf>,
2460 pub(crate) resource_limits: crate::resources::HostResourceLimits,
2466 pub(crate) declared_resources: murmur_artifact::ResourceCapabilities,
2470 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 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2515 pub(crate) native_state_dir: Option<PathBuf>,
2516 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2523 pub(crate) host_paths: Vec<crate::host_paths::HostPath>,
2524 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
2529 pub(crate) host_path_read_only_binds: Vec<PathBuf>,
2530}
2531
2532impl ShellEnforcement {
2533 pub(crate) fn resolve(
2548 policy: &CapabilityPolicy,
2549 decision: &crate::containment::ContainmentDecision,
2550 probe: HostProbe,
2551 ) -> Result<Self, String> {
2552 let tier = decision.applied_tier();
2553 let network_allow_ips = resolve_network_allowlist_ips(&policy.network_allow)?;
2554 let network_allow_rules =
2555 crate::network_policy::parse_network_allow_rules(&policy.network_allow)
2556 .map_err(|error| error.to_string())?;
2557 let egress_tcp_ports = crate::egress_proxy::egress_listen_ports(&network_allow_rules);
2558 let exec_allow_paths = resolve_exec_allowlist(&policy.shell_allow);
2559 let closure = resolve_landlock_grants(&exec_allow_paths);
2565 let host_path_read_only_binds = crate::host_paths::check_execute_roots(
2570 &policy.host_paths,
2571 &execute_roots_from(&closure, policy),
2572 host_path_mechanism(tier),
2573 )
2574 .map_err(|refusal| refusal.to_string())?;
2575 let mut landlock_grants = LandlockGrant::non_listable_files(closure);
2576 landlock_grants.extend(resolve_interpreter_runtime_grants(
2577 &policy.shell_interpreter_runtime,
2578 ));
2579 landlock_grants.extend(resolve_derived_interpreter_grants(
2580 &policy.shell_derived_interpreter_runtime,
2581 ));
2582 landlock_grants.extend(resolve_trust_store_grants(&policy.shell_trust_store));
2586 landlock_grants.extend(resolve_staged_runtime_landlock_grants(policy));
2590 landlock_grants.extend(resolve_sealed_runtime_landlock_grants(tier));
2596 landlock_grants.extend(resolve_sealed_etc_landlock_grants(tier));
2601 let sealed_bind_dirs = resolve_sealed_bind_dirs(&exec_allow_paths, policy);
2602 let staged_runtime_dirs = resolve_staged_runtime_dirs(policy);
2603 Ok(Self {
2604 tier,
2605 network_allow_ips,
2606 network_allow_rules,
2607 egress_tcp_ports,
2608 unix_sockets_allowed: policy.unix_sockets_allowed,
2609 workdir_exec: policy.workdir_exec_allowed,
2610 landlock_abi: ruleset_landlock_abi(probe.landlock_kernel_abi(), tier),
2611 landlock_grants,
2612 sealed_bind_dirs,
2613 staged_runtime_dirs,
2614 resource_limits: policy.resources,
2615 declared_resources: policy.declared_resources,
2616 cgroup_scope: None,
2617 workdir_guard: None,
2618 session_netns: Arc::new(crate::network_namespace::SessionNetns::new()),
2619 runtime_dir: None,
2620 runtime_dir_owner: None,
2621 temp_mapping: crate::temp_dir::tier_temp_mapping(tier),
2622 native_state_dir: None,
2623 host_paths: policy.host_paths.clone(),
2624 host_path_read_only_binds,
2625 })
2626 }
2627
2628 #[cfg(test)]
2631 pub(crate) fn resolve_declared(
2632 policy: &CapabilityPolicy,
2633 declared: murmur_artifact::ContainmentClass,
2634 probe: HostProbe,
2635 ) -> Result<Self, String> {
2636 let decision = crate::containment::ContainmentDecision::decide(
2637 probe.tier(),
2638 declared,
2639 policy.workdir_exec_allowed,
2640 false,
2641 );
2642 Self::resolve(policy, &decision, probe)
2643 }
2644
2645 pub(crate) fn with_runtime_dir(
2648 mut self,
2649 roots: crate::runtime_dir::RuntimeDirRoots,
2650 owner: Option<crate::runtime_dir::SharedRuntimeDir>,
2651 ) -> Self {
2652 self.runtime_dir = Some(roots);
2653 self.runtime_dir_owner = owner;
2654 self
2655 }
2656
2657 pub(crate) fn runtime_dir_path(&self) -> Option<&Path> {
2659 self.runtime_dir.as_ref().map(|roots| roots.runtime.path())
2660 }
2661
2662 pub(crate) fn records_root(&self) -> Option<&crate::workdir_fs::WorkdirRoot> {
2664 self.runtime_dir
2665 .as_ref()
2666 .and_then(|roots| roots.records.as_ref())
2667 }
2668
2669 pub(crate) fn records_dir_path(&self) -> Option<&Path> {
2672 self.records_root()
2673 .map(crate::workdir_fs::WorkdirRoot::path)
2674 }
2675
2676 pub(crate) fn temp_dir_path(&self) -> Option<&Path> {
2679 self.runtime_dir
2680 .as_ref()
2681 .and_then(|roots| roots.temp.as_ref())
2682 .map(crate::workdir_fs::WorkdirRoot::path)
2683 }
2684
2685 pub(crate) fn home_root(&self) -> Option<&crate::workdir_fs::WorkdirRoot> {
2688 self.runtime_dir
2689 .as_ref()
2690 .and_then(|roots| roots.home.as_ref())
2691 }
2692
2693 pub(crate) fn home_dir_path(&self) -> Option<&Path> {
2696 self.home_root().map(crate::workdir_fs::WorkdirRoot::path)
2697 }
2698
2699 pub(crate) fn with_temp_mapping(mut self, mapping: crate::temp_dir::TempMapping) -> Self {
2702 self.temp_mapping = mapping;
2703 self
2704 }
2705
2706 pub(crate) fn installed_temp_mapping(&self) -> crate::temp_dir::TempMapping {
2711 use crate::temp_dir::TempMapping;
2712 match (self.tier, self.temp_mapping) {
2713 (
2714 EnforcementTier::KernelFull | EnforcementTier::KernelSeccompOnly,
2715 TempMapping::MountNamespace,
2716 ) => TempMapping::MountNamespace,
2717 (
2718 EnforcementTier::KernelFull | EnforcementTier::KernelSeccompOnly,
2719 TempMapping::NotMapped(reason),
2720 ) => TempMapping::NotMapped(reason),
2721 (tier, _) => crate::temp_dir::tier_temp_mapping(tier),
2722 }
2723 }
2724
2725 pub(crate) fn tmpdir_value(&self) -> Option<String> {
2729 self.temp_dir_path()
2730 .map(|folder| self.installed_temp_mapping().tmpdir_value(folder))
2731 }
2732
2733 pub(crate) fn remove_runtime_dir(&self) {
2736 if let Some(owner) = &self.runtime_dir_owner {
2737 owner.remove();
2738 }
2739 }
2740
2741 pub(crate) fn with_host_bounding(
2750 mut self,
2751 cgroup_scope: Option<Arc<crate::cgroup::CgroupScope>>,
2752 workdir_guard: Option<Arc<crate::resources::WorkdirGuard>>,
2753 ) -> Self {
2754 self.cgroup_scope = cgroup_scope;
2755 self.workdir_guard = workdir_guard;
2756 self
2757 }
2758
2759 pub(crate) fn nproc_bound(&self) -> crate::resources::NprocBound {
2768 if self
2769 .cgroup_scope
2770 .as_ref()
2771 .is_some_and(|scope| scope.places_subprocesses())
2772 {
2773 crate::resources::NprocBound::CgroupPidsMax
2774 } else {
2775 crate::resources::NprocBound::Headroom {
2776 baseline: crate::resources::uid_task_count().unwrap_or(0),
2777 }
2778 }
2779 }
2780
2781 pub(crate) fn workdir_breach(&self) -> Option<crate::resources::WorkdirBreach> {
2783 self.workdir_guard.as_ref().and_then(|guard| guard.breach())
2784 }
2785
2786 pub(crate) fn check_workdir_budget(&self) -> Result<(), String> {
2792 match self.workdir_breach() {
2793 Some(breach) => Err(crate::resources::workdir_breach_message(breach)),
2794 None => Ok(()),
2795 }
2796 }
2797
2798 #[cfg_attr(not(test), allow(dead_code))]
2804 pub(crate) fn environment_only() -> Self {
2805 Self {
2806 tier: EnforcementTier::EnvironmentOnly,
2807 network_allow_ips: Vec::new(),
2808 network_allow_rules: Vec::new(),
2809 egress_tcp_ports: Vec::new(),
2810 unix_sockets_allowed: false,
2813 workdir_exec: false,
2816 landlock_abi: None,
2819 landlock_grants: Vec::new(),
2820 sealed_bind_dirs: Vec::new(),
2821 staged_runtime_dirs: Vec::new(),
2822 resource_limits: crate::resources::HostResourceLimits::default(),
2825 declared_resources: murmur_artifact::ResourceCapabilities::default(),
2826 cgroup_scope: None,
2827 workdir_guard: None,
2828 session_netns: Arc::new(crate::network_namespace::SessionNetns::new()),
2829 runtime_dir: None,
2830 runtime_dir_owner: None,
2831 temp_mapping: crate::temp_dir::tier_temp_mapping(EnforcementTier::EnvironmentOnly),
2832 native_state_dir: None,
2833 host_paths: Vec::new(),
2834 host_path_read_only_binds: Vec::new(),
2835 }
2836 }
2837
2838 #[cfg(test)]
2842 pub(crate) fn with_test_session_dirs(self) -> Self {
2843 let base = tempfile::tempdir().unwrap().keep();
2844 self.with_runtime_dir(test_runtime_dir(&base), None)
2845 }
2846
2847 pub(crate) fn with_session_netns(
2852 mut self,
2853 session_netns: Arc<crate::network_namespace::SessionNetns>,
2854 ) -> Self {
2855 self.session_netns = session_netns;
2856 self
2857 }
2858
2859 pub(crate) fn for_native_entrypoint(
2881 &self,
2882 entrypoint: &NativeEntrypoint,
2883 state_dir: Option<&Path>,
2884 ) -> ShellEnforcement {
2885 let mut enforcement = self.clone();
2886 enforcement.native_state_dir = state_dir.map(Path::to_path_buf);
2887 if let Some(pinned) = entrypoint.pinned {
2888 enforcement.landlock_grants.push(LandlockGrant {
2889 path: entrypoint.path.clone(),
2890 list_dir: false,
2891 executable: true,
2892 pinned: Some(pinned),
2893 });
2894 }
2895 enforcement
2896 .landlock_grants
2897 .extend(LandlockGrant::non_listable_files(
2898 entrypoint.closure.clone(),
2899 ));
2900 if self.tier == EnforcementTier::KernelSealed {
2901 if let Some(dir) = entrypoint.interpreter.as_deref().and_then(Path::parent) {
2902 push_sealed_bind_dir(&mut enforcement.sealed_bind_dirs, dir.to_path_buf());
2903 let inside_read_write = self.host_paths.iter().any(|host_path| {
2906 host_path.access == crate::host_paths::HostPathAccess::ReadWrite
2907 && dir.starts_with(&host_path.path)
2908 });
2909 if inside_read_write
2910 && !enforcement
2911 .host_path_read_only_binds
2912 .iter()
2913 .any(|bound| bound == dir)
2914 {
2915 enforcement
2916 .host_path_read_only_binds
2917 .push(dir.to_path_buf());
2918 }
2919 }
2920 }
2921 enforcement
2922 }
2923
2924 pub(crate) fn end_session(&self) {
2932 self.kill_session_processes();
2933 self.session_netns.end();
2934 }
2935
2936 pub(crate) fn kill_session_processes(&self) {
2942 let Some(scope) = self.cgroup_scope.as_ref() else {
2943 return;
2944 };
2945 if let Err(error) = scope.kill_all() {
2946 crate::runtime_err!(
2947 "[capsule-runtime] warning: could not kill the processes in cgroup scope {}: \
2948 {error}",
2949 scope.path().display()
2950 );
2951 }
2952 if !scope.wait_until_empty(SESSION_END_KILL_WAIT) {
2953 crate::runtime_err!(
2954 "[capsule-runtime] warning: cgroup scope {} still held processes {} ms after \
2955 they were killed",
2956 scope.path().display(),
2957 SESSION_END_KILL_WAIT.as_millis()
2958 );
2959 }
2960 }
2961}
2962
2963pub(crate) const SESSION_END_KILL_WAIT: std::time::Duration = std::time::Duration::from_secs(2);
2965
2966pub(crate) struct EndSessionOnDrop(ShellEnforcement);
2972
2973impl EndSessionOnDrop {
2974 pub(crate) fn new(enforcement: ShellEnforcement) -> Self {
2975 Self(enforcement)
2976 }
2977}
2978
2979impl Drop for EndSessionOnDrop {
2980 fn drop(&mut self) {
2981 self.0.end_session();
2982 }
2983}
2984
2985const KERNEL_ENFORCEMENT_NOTICE: &str = "capabilities.shell.allow is non-empty and this host \
3004resolved to a Linux kernel-enforcement tier (Landlock/seccomp). Landlock now grants a narrow, \
3005derived read+execute scope outside the workdir (the allowlisted binaries, their loader, and their \
3006shared libraries — nothing writable), read-only access to the directories an allowlisted \
3007CPython or Perl interpreter reports on its own import path, read-only access to the host's TLS \
3008trust store when capabilities.network.allow is also non-empty, each host_paths entry in \
3009~/.murmur/config.yaml at its own access, never executable, and no other directory granted \
3010wholesale, plus a fixed device set \
3011every capsule gets unconditionally: /dev/null read+write (with the session's temporary and home \
3012folders and any read-write host_paths entry, the only writable paths outside the workdir), \
3013/dev/zero and /dev/urandom read-only, and no other device — /dev/random, /dev/full, /dev/tty and \
3014raw block devices stay denied. The workdir's own grant also \
3015withholds device-node and unix-socket creation; seccomp refuses socket(AF_UNIX) outright unless \
3016capabilities.network.unix_sockets is declared, and always refuses AF_NETLINK/AF_PACKET, so a \
3017capsule cannot reach a host daemon socket such as /var/run/docker.sock; the seccomp filter now \
3018defaults to deny and permits only a fixed syscall allowlist modelled on the OCI/Docker default \
3019profile, so io_uring, bpf, userfaultfd, perf_event_open, ptrace and similar are refused outright; \
3020and the forked shell child \
3021drops every Linux capability and sets no_new_privs before execve. Paths outside the workdir are \
3022denied, not absent: stat, access and readlink on an ungranted path still succeed.";
3023
3024const KERNEL_SEALED_ENFORCEMENT_NOTICE: &str = "capabilities.shell.allow is non-empty and this \
3028host resolved to the sealed kernel-enforcement tier: the subprocess tree runs in a private mount \
3029namespace pivoted onto a composed root (host runtime bind-mounted read-only, the session workdir, \
3030its temporary folder at /tmp, its home folder at $HOME and any read-write host_paths entry the only \
3031writable paths, each host_paths entry in ~/.murmur/config.yaml bound at its own path, a private \
3032/dev tmpfs carrying the OCI default device set), with Landlock \
3033and seccomp still installed inside it as defence in depth. Paths outside that root are absent \
3034rather than denied — with one documented exception: /proc is a bind of the host's, because this \
3035tier creates no PID namespace and mounting a private procfs unprivileged needs one. Landlock \
3036refuses reading host /proc content at both scoped and sealed, so another process's command line, \
3037environment and memory maps cannot be read; what still reaches outside the capsule is stat(2) on \
3038/proc/<pid>, which shows that a process exists, its owner and its start time, and kill(2), which \
3039delivers a signal to any process of the same user.";
3040
3041const SECCOMP_ONLY_WARNING: &str = "capabilities.shell.allow is non-empty and this Linux kernel \
3042lacks Landlock (kernel <5.13) — filesystem access outside the capsule workdir is not \
3043kernel-enforced at all, and the seccomp exec/network enforcement that would apply has not been \
3044verified on real Linux hardware. The fixed capsule device set the Full tier applies (/dev/null \
3045read+write, /dev/zero and /dev/urandom read-only, every other device denied) is a Landlock \
3046mechanism, so it does not apply here either: on this tier every device under /dev is reachable \
3047exactly as it would be without any sandbox. Seccomp also refuses socket(AF_UNIX) unless \
3048capabilities.network.unix_sockets is declared, and always refuses AF_NETLINK/AF_PACKET — that \
3049rule needs no Landlock and so applies identically on this tier, but it has not been verified \
3050either. The same is true of the default-deny syscall allowlist (modelled on the OCI/Docker default \
3051profile), which refuses io_uring, bpf, userfaultfd, perf_event_open, ptrace and similar on this \
3052tier too. The forked shell child still drops every Linux capability and \
3053sets no_new_privs before execve on this tier, independently of Landlock, but that has not been \
3054verified either. Treat shell subprocess isolation as experimental on this host.";
3055
3056const ENVIRONMENT_ONLY_WARNING: &str = "capabilities.shell.allow is non-empty but this \
3057platform has no kernel-level subprocess sandbox (Landlock/seccomp are Linux-only) — \
3058enforcement is environment-only (synthetic HOME + credential env-stripping). This is \
3059permanent on this platform.";
3060
3061pub(crate) fn tier_warning(
3075 tier: EnforcementTier,
3076 shell_allow_is_empty: bool,
3077) -> Option<(&'static str, &'static str)> {
3078 if shell_allow_is_empty {
3079 return None;
3080 }
3081 match tier {
3082 EnforcementTier::KernelSealed => Some((W_SEC_005, KERNEL_SEALED_ENFORCEMENT_NOTICE)),
3083 EnforcementTier::KernelFull => Some((W_SEC_005, KERNEL_ENFORCEMENT_NOTICE)),
3084 EnforcementTier::KernelSeccompOnly => Some((W_SEC_002, SECCOMP_ONLY_WARNING)),
3085 EnforcementTier::EnvironmentOnly => Some((W_SEC_001, ENVIRONMENT_ONLY_WARNING)),
3086 EnforcementTier::Unconfined => None,
3088 }
3089}
3090
3091const NO_AGGREGATE_BOUNDING_WARNING: &str = "this capsule can spawn native subprocesses, but \
3092this platform has no cgroup v2 (Linux-only), so no aggregate bound exists across the subprocess \
3093tree: nothing caps its total memory, task count or CPU. Per-process rlimits from \
3094capabilities.resources still apply (hard, not soft), but RLIMIT_NPROC is a per-uid ceiling and \
3095does NOT stop a fork bomb of distinct, short-lived processes — only a cgroup's pids.max does. \
3096This is permanent on this platform, not a placeholder for a future slice.";
3097
3098pub(crate) fn aggregate_bounding_warning(
3107 is_linux: bool,
3108 requires_bounding: bool,
3109 has_scope: bool,
3110) -> Option<(&'static str, &'static str)> {
3111 if is_linux || !requires_bounding || has_scope {
3112 return None;
3113 }
3114 Some((W_SEC_010, NO_AGGREGATE_BOUNDING_WARNING))
3115}
3116
3117pub(crate) fn warn_for_missing_aggregate_bounding(
3119 workdir: &crate::workdir_fs::WorkdirRoot,
3120 requires_bounding: bool,
3121 has_scope: bool,
3122) {
3123 let is_linux = cfg!(target_os = "linux");
3124 if let Some((code, message)) =
3125 aggregate_bounding_warning(is_linux, requires_bounding, has_scope)
3126 {
3127 let link = security_warning_link(code);
3128 crate::runtime_err!("[capsule-runtime] warning[{code}]: {message} ({link})");
3129 crate::agent::append_bootstrap_log(
3130 workdir,
3131 &format!("[capability-policy] warning[{code}]: {message} ({link})"),
3132 );
3133 }
3134}
3135
3136pub(crate) fn warn_for_enforcement_tier(
3138 tier: EnforcementTier,
3139 workdir: &crate::workdir_fs::WorkdirRoot,
3140 policy: &CapabilityPolicy,
3141) {
3142 if let Some((code, message)) = tier_warning(tier, policy.shell_allow.is_empty()) {
3143 let link = security_warning_link(code);
3144 crate::runtime_err!("[capsule-runtime] warning[{code}]: {message} ({link})");
3145 crate::agent::append_bootstrap_log(
3146 workdir,
3147 &format!("[capability-policy] warning[{code}]: {message} ({link})"),
3148 );
3149 }
3150 if tier == EnforcementTier::EnvironmentOnly {
3151 crate::runtime::warn_if_bash_network_bypass(workdir, policy);
3152 }
3153}
3154
3155#[derive(Debug)]
3162pub(crate) enum SupervisorHandle {
3163 Noop,
3164 #[cfg(target_os = "linux")]
3165 Linux {
3166 diag_read: std::os::fd::OwnedFd,
3173 },
3174}
3175
3176impl SupervisorHandle {
3177 pub(crate) fn read_diagnostic(&self) -> Option<String> {
3190 match self {
3191 SupervisorHandle::Noop => None,
3192 #[cfg(target_os = "linux")]
3193 SupervisorHandle::Linux { diag_read, .. } => {
3194 use std::os::fd::AsRawFd;
3195 linux_enforce::read_diagnostic_pipe(diag_read.as_raw_fd())
3196 }
3197 }
3198 }
3199}
3200
3201#[cfg(test)]
3205thread_local! {
3206 pub(crate) static FORCE_PREPARE_FAILURE: std::cell::Cell<bool> =
3207 const { std::cell::Cell::new(false) };
3208}
3209
3210#[cfg(test)]
3211fn forced_prepare_failure() -> Result<(), String> {
3212 if FORCE_PREPARE_FAILURE.with(|flag| flag.get()) {
3213 return Err("sandbox: kernel enforcement setup failed (forced by test seam)".to_string());
3214 }
3215 Ok(())
3216}
3217
3218#[cfg(all(test, target_os = "linux"))]
3227thread_local! {
3228 pub(crate) static FORCE_NO_NEW_PRIVS_FAILURE: std::cell::Cell<bool> =
3229 const { std::cell::Cell::new(false) };
3230 pub(crate) static FORCE_LANDLOCK_FAILURE: std::cell::Cell<bool> =
3231 const { std::cell::Cell::new(false) };
3232 pub(crate) static FORCE_SEALED_ROOT_FAILURE: std::cell::Cell<bool> =
3238 const { std::cell::Cell::new(false) };
3239 pub(crate) static FORCE_TMP_MAP_FAILURE: std::cell::Cell<bool> =
3242 const { std::cell::Cell::new(false) };
3243}
3244
3245#[cfg(all(test, target_os = "linux"))]
3247pub(crate) struct ForceTmpMapFailureGuard;
3248
3249#[cfg(all(test, target_os = "linux"))]
3250impl ForceTmpMapFailureGuard {
3251 pub(crate) fn new() -> Self {
3252 FORCE_TMP_MAP_FAILURE.with(|flag| flag.set(true));
3253 Self
3254 }
3255}
3256
3257#[cfg(all(test, target_os = "linux"))]
3258impl Drop for ForceTmpMapFailureGuard {
3259 fn drop(&mut self) {
3260 FORCE_TMP_MAP_FAILURE.with(|flag| flag.set(false));
3261 }
3262}
3263
3264#[cfg(all(test, target_os = "linux"))]
3268pub(crate) struct ForceSealedRootFailureGuard;
3269
3270#[cfg(all(test, target_os = "linux"))]
3271impl ForceSealedRootFailureGuard {
3272 pub(crate) fn new() -> Self {
3273 FORCE_SEALED_ROOT_FAILURE.with(|flag| flag.set(true));
3274 Self
3275 }
3276}
3277
3278#[cfg(all(test, target_os = "linux"))]
3279impl Drop for ForceSealedRootFailureGuard {
3280 fn drop(&mut self) {
3281 FORCE_SEALED_ROOT_FAILURE.with(|flag| flag.set(false));
3282 }
3283}
3284
3285#[cfg(all(test, target_os = "linux"))]
3291pub(crate) fn sealed_test_enforcement() -> ShellEnforcement {
3292 let base = tempfile::tempdir().unwrap().keep();
3294 ShellEnforcement {
3295 tier: EnforcementTier::KernelSealed,
3296 ..ShellEnforcement::environment_only().with_test_session_dirs()
3297 }
3298 .with_runtime_dir(test_runtime_dir(&base), None)
3299}
3300
3301#[cfg(test)]
3304pub(crate) fn test_runtime_dir(base: &Path) -> crate::runtime_dir::RuntimeDirRoots {
3305 let open = |sub: &str| {
3306 let path = base.join(sub);
3307 std::fs::create_dir_all(&path).unwrap();
3308 crate::workdir_fs::WorkdirRoot::open(&path).unwrap()
3309 };
3310 crate::runtime_dir::RuntimeDirRoots::new(
3311 open(crate::runtime_dir::RUNTIME_SUBDIR),
3312 open(crate::runtime_dir::SEALED_ETC_SUBDIR),
3313 open(crate::runtime_dir::HARNESS_SUBDIR),
3314 )
3315 .with_records(open(crate::records_dir::RECORDS_DIR_NAME))
3316 .with_temp(open(&format!(
3317 "{}/{}",
3318 crate::records_dir::RECORDS_DIR_NAME,
3319 crate::temp_dir::SESSION_TEMP_DIR_NAME
3320 )))
3321 .with_home(open(&format!(
3322 "{}/{}",
3323 crate::records_dir::RECORDS_DIR_NAME,
3324 crate::records_dir::HOME_SUBDIR
3325 )))
3326}
3327
3328#[cfg(unix)]
3341pub(crate) fn attach_process_limits(
3342 command: &mut std::process::Command,
3343 enforcement: &ShellEnforcement,
3344) {
3345 use std::os::unix::process::CommandExt;
3346
3347 if enforcement.tier == EnforcementTier::Unconfined {
3348 attach_unconfined_limits(command, enforcement);
3349 return;
3350 }
3351
3352 let limits = enforcement.resource_limits;
3353 let nproc = enforcement.nproc_bound();
3354 let cgroup_scope = enforcement.cgroup_scope.clone();
3355
3356 #[allow(unsafe_code)]
3361 unsafe {
3362 command.pre_exec(move || {
3363 crate::resources::apply_hard_rlimits(&limits, nproc)?;
3364 if let Some(scope) = cgroup_scope.as_ref() {
3365 scope.join_current_process()?;
3366 }
3367 Ok(())
3368 });
3369 }
3370}
3371
3372#[cfg(unix)]
3379fn attach_unconfined_limits(command: &mut std::process::Command, enforcement: &ShellEnforcement) {
3380 use std::os::unix::process::CommandExt;
3381
3382 let declared = enforcement.declared_resources;
3383 let nproc = enforcement.nproc_bound();
3384 let cgroup_scope = enforcement.cgroup_scope.clone();
3385
3386 #[allow(unsafe_code)]
3391 unsafe {
3392 command.pre_exec(move || {
3393 crate::resources::apply_declared_rlimits(&declared, nproc)?;
3394 if let Some(scope) = cgroup_scope.as_ref() {
3395 scope.join_current_process()?;
3396 }
3397 #[cfg(target_os = "linux")]
3398 linux_enforce::mark_inherited_fds_cloexec()?;
3399 Ok(())
3400 });
3401 }
3402}
3403
3404#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
3422pub(crate) const FD_HYGIENE_FIRST_FD: u32 = 3;
3423
3424#[cfg(not(target_os = "linux"))]
3431pub(crate) fn prepare_enforcement(
3432 command: &mut std::process::Command,
3433 enforcement: &ShellEnforcement,
3434 _accessible_workdir: &Path,
3435) -> Result<SupervisorHandle, String> {
3436 #[cfg(test)]
3437 forced_prepare_failure()?;
3438
3439 debug_assert!(
3440 matches!(
3441 enforcement.tier,
3442 EnforcementTier::EnvironmentOnly | EnforcementTier::Unconfined
3443 ),
3444 "non-Linux targets must always resolve to EnforcementTier::EnvironmentOnly or Unconfined"
3445 );
3446 attach_process_limits(command, enforcement);
3447 Ok(SupervisorHandle::Noop)
3448}
3449
3450#[cfg(target_os = "linux")]
3456#[allow(unsafe_code)]
3457pub(crate) fn prepare_enforcement(
3458 command: &mut std::process::Command,
3459 enforcement: &ShellEnforcement,
3460 accessible_workdir: &Path,
3461) -> Result<SupervisorHandle, String> {
3462 use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
3463 use std::os::unix::process::CommandExt;
3464
3465 #[cfg(test)]
3466 forced_prepare_failure()?;
3467
3468 if matches!(
3469 enforcement.tier,
3470 EnforcementTier::EnvironmentOnly | EnforcementTier::Unconfined
3471 ) {
3472 attach_process_limits(command, enforcement);
3476 return Ok(SupervisorHandle::Noop);
3477 }
3478
3479 let temp_dir_fd = enforcement
3483 .runtime_dir
3484 .as_ref()
3485 .and_then(|roots| roots.temp.as_ref())
3486 .map(|temp| temp.path_fd().map(Arc::new))
3487 .transpose()
3488 .map_err(|error| {
3489 format!("sandbox: failed to open the session's temporary folder: {error}")
3490 })?;
3491 let home_dir_fd = enforcement
3494 .home_root()
3495 .map(|home| home.path_fd().map(Arc::new))
3496 .transpose()
3497 .map_err(|error| format!("sandbox: failed to open the session's home folder: {error}"))?;
3498 let state_dir_fd = enforcement
3502 .native_state_dir
3503 .as_deref()
3504 .filter(|_| {
3505 matches!(
3506 enforcement.tier,
3507 EnforcementTier::KernelFull | EnforcementTier::KernelSealed
3508 )
3509 })
3510 .map(|path| linux_enforce::open_state_dir(path).map(|fd| (Arc::new(fd), path)))
3511 .transpose()?;
3512 let host_path_fds: Vec<HostPathFd> = if matches!(
3516 enforcement.tier,
3517 EnforcementTier::KernelFull | EnforcementTier::KernelSealed
3518 ) {
3519 enforcement
3520 .host_paths
3521 .iter()
3522 .map(|host_path| {
3523 linux_enforce::open_host_path(&host_path.path).map(|fd| HostPathFd {
3524 fd: Arc::new(fd),
3525 path: host_path.path.clone(),
3526 read_write: host_path.access == crate::host_paths::HostPathAccess::ReadWrite,
3527 })
3528 })
3529 .collect::<Result<_, _>>()?
3530 } else {
3531 Vec::new()
3532 };
3533
3534 let sealed_sources = if enforcement.tier == EnforcementTier::KernelSealed {
3540 Some(SealedSourceFds::open(
3541 enforcement,
3542 temp_dir_fd.as_ref(),
3543 home_dir_fd.as_ref(),
3544 state_dir_fd
3545 .as_ref()
3546 .map(|(fd, path)| (Arc::clone(fd), path.to_path_buf())),
3547 )?)
3548 } else {
3549 None
3550 };
3551 let runtime_dir_fd = match &sealed_sources {
3553 Some(sources) => Some(Arc::clone(&sources.runtime_dir)),
3554 None if enforcement.tier == EnforcementTier::KernelFull => enforcement
3555 .runtime_dir
3556 .as_ref()
3557 .map(|roots| roots.runtime.path_fd().map(Arc::new))
3558 .transpose()
3559 .map_err(|error| {
3560 format!(
3561 "sandbox: failed to open the runtime directory for Landlock scoping: {error}"
3562 )
3563 })?,
3564 None => None,
3565 };
3566 let records_dir_fd = match &sealed_sources {
3569 Some(sources) => sources.records_dir.as_ref().map(|(fd, _)| Arc::clone(fd)),
3570 None if enforcement.tier == EnforcementTier::KernelFull => enforcement
3571 .runtime_dir
3572 .as_ref()
3573 .and_then(|roots| roots.records.as_ref())
3574 .map(|records| records.path_fd().map(Arc::new))
3575 .transpose()
3576 .map_err(|error| {
3577 format!(
3578 "sandbox: failed to open the records directory for Landlock scoping: {error}"
3579 )
3580 })?,
3581 None => None,
3582 };
3583
3584 let tmp_map_spec = match (&temp_dir_fd, enforcement.installed_temp_mapping()) {
3586 (Some(folder_fd), crate::temp_dir::TempMapping::MountNamespace) => Some(
3587 build_tmp_map_spec(enforcement, folder_fd, accessible_workdir)?,
3588 ),
3589 _ => None,
3590 };
3591
3592 let landlock_fds = if matches!(
3599 enforcement.tier,
3600 EnforcementTier::KernelFull | EnforcementTier::KernelSealed
3601 ) {
3602 Some(linux_enforce::open_landlock_fds(
3603 linux_enforce::LandlockFdRequest {
3604 accessible_workdir,
3605 landlock_grants: &enforcement.landlock_grants,
3606 device_grants: &landlock_device_grants(enforcement.tier),
3607 workdir_exec: enforcement.workdir_exec,
3608 abi: enforcement.landlock_abi,
3609 sealed: sealed_sources.as_ref(),
3610 temp_dir_fd,
3611 home_dir_fd,
3612 state_dir_fd: state_dir_fd.map(|(fd, _)| fd),
3613 runtime_dir_fd,
3614 records_dir_fd,
3615 host_path_fds: host_path_fds
3616 .iter()
3617 .map(|host_path| (Arc::clone(&host_path.fd), host_path.read_write))
3618 .collect(),
3619 },
3620 )?)
3621 } else {
3622 None
3623 };
3624
3625 let sealed_spec = match &sealed_sources {
3631 Some(sources) => Some(build_sealed_root(
3632 accessible_workdir,
3633 sources,
3634 &enforcement.sealed_bind_dirs,
3635 &enforcement.staged_runtime_dirs,
3636 &host_path_fds,
3637 &enforcement.host_path_read_only_binds,
3638 )?),
3639 None => None,
3640 };
3641
3642 let mut diag_fds = [0i32; 2];
3648 let rc = unsafe { libc::pipe2(diag_fds.as_mut_ptr(), libc::O_CLOEXEC) };
3651 if rc != 0 {
3652 return Err(format!(
3653 "sandbox: pipe2() for enforcement diagnostics failed: {}",
3654 std::io::Error::last_os_error()
3655 ));
3656 }
3657 let (diag_read, diag_write) = unsafe {
3660 (
3661 OwnedFd::from_raw_fd(diag_fds[0]),
3662 OwnedFd::from_raw_fd(diag_fds[1]),
3663 )
3664 };
3665
3666 let netns = enforcement.session_netns.establish(
3673 &enforcement.egress_tcp_ports,
3674 crate::egress_proxy::EgressPolicy::new(
3675 enforcement.network_allow_rules.clone(),
3676 enforcement.network_allow_ips.clone(),
3677 ),
3678 )?;
3679
3680 let tier = enforcement.tier;
3681 let unix_sockets_allowed = enforcement.unix_sockets_allowed;
3684 let resource_limits = enforcement.resource_limits;
3689 let nproc = enforcement.nproc_bound();
3690 let cgroup_scope = enforcement.cgroup_scope.clone();
3691
3692 unsafe {
3712 command.pre_exec(move || {
3713 if let Err(error) = crate::resources::apply_nproc_bound(&resource_limits, nproc) {
3718 linux_enforce::write_diagnostic(diag_write.as_raw_fd(), &error.to_string());
3719 return Err(error);
3720 }
3721 if let Some(scope) = cgroup_scope.as_ref() {
3722 if let Err(error) = scope.join_current_process() {
3723 linux_enforce::write_diagnostic(diag_write.as_raw_fd(), &error.to_string());
3724 return Err(error);
3725 }
3726 }
3727
3728 let (user_ns_fd, net_ns_fd) = netns.raw_fds();
3729 if let Err(error) = linux_enforce::child_install_enforcement(
3730 tier,
3731 user_ns_fd,
3732 net_ns_fd,
3733 tmp_map_spec.as_ref(),
3734 sealed_spec.as_ref(),
3735 landlock_fds.as_ref(),
3736 unix_sockets_allowed,
3737 ) {
3738 linux_enforce::write_diagnostic(diag_write.as_raw_fd(), &error.to_string());
3739 return Err(error);
3740 }
3741
3742 if let Err(error) = crate::resources::apply_per_process_rlimits(&resource_limits) {
3754 linux_enforce::write_diagnostic(diag_write.as_raw_fd(), &error.to_string());
3755 return Err(error);
3756 }
3757 Ok(())
3758 });
3759 }
3760
3761 Ok(SupervisorHandle::Linux { diag_read })
3762}
3763
3764#[cfg(target_os = "linux")]
3776fn landlock_device_grants(tier: EnforcementTier) -> Vec<CapsuleDeviceGrant> {
3777 match tier {
3778 EnforcementTier::KernelSealed => crate::sealed::SEALED_DEVICE_NODES
3779 .iter()
3780 .map(|device| CapsuleDeviceGrant {
3781 path: device.path,
3782 writable: device.writable,
3783 })
3784 .collect(),
3785 _ => CAPSULE_DEVICE_GRANTS.to_vec(),
3786 }
3787}
3788
3789#[cfg(target_os = "linux")]
3801fn build_tmp_map_spec(
3802 enforcement: &ShellEnforcement,
3803 folder_fd: &std::os::fd::OwnedFd,
3804 accessible_workdir: &Path,
3805) -> Result<crate::temp_dir::TmpMapSpec, String> {
3806 let folder = enforcement.temp_dir_path().ok_or_else(|| {
3807 format!(
3808 "{} this enforcement holds none",
3809 crate::temp_dir::TMP_MAP_FAILURE_PREFIX
3810 )
3811 })?;
3812 let identity = crate::workdir_fs::fd_identity(folder_fd).map_err(|errno| {
3813 format!(
3814 "{} fstat of the temporary folder failed: {errno}",
3815 crate::temp_dir::TMP_MAP_FAILURE_PREFIX
3816 )
3817 })?;
3818 let canonical_tmp = crate::temp_dir::canonical_tmp();
3819 let read_only = [
3820 enforcement.runtime_dir_path(),
3821 enforcement.records_dir_path(),
3822 ]
3823 .into_iter()
3824 .flatten()
3825 .chain(
3826 enforcement
3827 .landlock_grants
3828 .iter()
3829 .map(|grant| grant.path.as_path()),
3830 );
3831 let reexposed = crate::temp_dir::reexposed_paths(
3832 &canonical_tmp,
3833 std::iter::once(accessible_workdir)
3834 .chain(enforcement.home_dir_path())
3835 .chain(enforcement.native_state_dir.as_deref()),
3836 read_only,
3837 );
3838 crate::temp_dir::TmpMapSpec::new(
3839 folder,
3840 identity,
3841 &canonical_tmp,
3842 &reexposed,
3843 accessible_workdir,
3844 )
3845}
3846
3847#[cfg(target_os = "linux")]
3863fn ensure_sealed_identity_files(
3864 sealed_etc_root: &crate::workdir_fs::WorkdirRoot,
3865 home_root: &crate::workdir_fs::WorkdirRoot,
3866) -> Result<Vec<PathBuf>, String> {
3867 let (uid, gid) = home_root.owner().map_err(|error| {
3870 format!(
3871 "sealed: failed to stat the session's home folder for the capsule's uid/gid: {error}"
3872 )
3873 })?;
3874 let home = home_root.path().to_string_lossy().into_owned();
3877 let identity = crate::sealed::SealedAccountIdentity {
3878 uid,
3879 gid,
3880 home: &home,
3881 };
3882
3883 let mut written = Vec::new();
3884 for entry in crate::sealed::SEALED_ETC_PATHS {
3885 let Some(file) = entry.synthetic else {
3886 continue;
3887 };
3888 sealed_etc_root
3889 .overwrite_in_place(
3890 file.file_name(),
3891 file.render(&identity)?.as_bytes(),
3892 crate::workdir_fs::DEFAULT_FILE_MODE,
3893 )
3894 .map_err(|error| {
3895 format!(
3896 "sealed: failed to write the synthetic {}: {error}",
3897 entry.path
3898 )
3899 })?;
3900 written.push(crate::sealed::synthetic_etc_source(
3901 sealed_etc_root.path(),
3902 file,
3903 ));
3904 }
3905 Ok(written)
3906}
3907
3908#[cfg(target_os = "linux")]
3911pub(crate) struct HostPathFd {
3912 fd: Arc<std::os::fd::OwnedFd>,
3913 path: PathBuf,
3914 read_write: bool,
3915}
3916
3917#[cfg(target_os = "linux")]
3920pub(crate) struct SealedSourceFds {
3921 tmp_store: Arc<std::os::fd::OwnedFd>,
3924 tmp_store_path: PathBuf,
3926 home_dir: (Arc<std::os::fd::OwnedFd>, PathBuf),
3929 state_dir: Option<(Arc<std::os::fd::OwnedFd>, PathBuf)>,
3933 runtime_dir: Arc<std::os::fd::OwnedFd>,
3935 runtime_dir_path: PathBuf,
3938 records_dir: Option<(Arc<std::os::fd::OwnedFd>, PathBuf)>,
3942 etc_dir: PathBuf,
3944 identity_files: Vec<PathBuf>,
3946}
3947
3948#[cfg(target_os = "linux")]
3949impl SealedSourceFds {
3950 fn open(
3956 enforcement: &ShellEnforcement,
3957 temp_dir_fd: Option<&Arc<std::os::fd::OwnedFd>>,
3958 home_dir_fd: Option<&Arc<std::os::fd::OwnedFd>>,
3959 state_dir: Option<(Arc<std::os::fd::OwnedFd>, PathBuf)>,
3960 ) -> Result<Self, String> {
3961 let roots = enforcement.runtime_dir.as_ref().ok_or_else(|| {
3962 "sealed: a composed root needs the session's runtime directory, and this \
3963 enforcement holds none"
3964 .to_string()
3965 })?;
3966 let (Some(temp), Some(tmp_store)) = (roots.temp.as_ref(), temp_dir_fd) else {
3967 return Err(
3968 "sealed: a composed root binds the session's temporary folder at /tmp, \
3969 and this enforcement holds none"
3970 .to_string(),
3971 );
3972 };
3973 let tmp_store = Arc::clone(tmp_store);
3974 let (Some(home), Some(home_fd)) = (roots.home.as_ref(), home_dir_fd) else {
3975 return Err(crate::shell::NO_HOME_FOLDER_REFUSAL.to_string());
3976 };
3977 let home_dir = (Arc::clone(home_fd), home.path().to_path_buf());
3978 let identity_files = ensure_sealed_identity_files(&roots.sealed_etc, home)?;
3979 let runtime_dir = Arc::new(roots.runtime.path_fd().map_err(|error| {
3980 format!("sealed: failed to open the runtime directory for its bind: {error}")
3981 })?);
3982 let records_dir = roots
3983 .records
3984 .as_ref()
3985 .map(|records| {
3986 records
3987 .path_fd()
3988 .map(|fd| (Arc::new(fd), records.path().to_path_buf()))
3989 .map_err(|error| {
3990 format!(
3991 "sealed: failed to open the records directory for its bind: {error}"
3992 )
3993 })
3994 })
3995 .transpose()?;
3996 Ok(Self {
3997 tmp_store,
3998 tmp_store_path: temp.path().to_path_buf(),
3999 home_dir,
4000 state_dir,
4001 runtime_dir,
4002 runtime_dir_path: roots.runtime.path().to_path_buf(),
4003 records_dir,
4004 etc_dir: roots.sealed_etc.path().to_path_buf(),
4005 identity_files,
4006 })
4007 }
4008
4009 fn identity_of(fd: &std::os::fd::OwnedFd) -> Result<(u64, u64), String> {
4011 crate::workdir_fs::fd_identity(fd)
4012 .map_err(|errno| format!("sealed: fstat of a bind source failed: {errno}"))
4013 }
4014}
4015
4016#[cfg(target_os = "linux")]
4032fn build_sealed_root(
4033 accessible_workdir: &Path,
4034 sources: &SealedSourceFds,
4035 extra_read_only: &[PathBuf],
4036 staged_runtime_read_only: &[PathBuf],
4037 host_paths: &[HostPathFd],
4038 host_path_read_only: &[PathBuf],
4039) -> Result<crate::sealed::SealedRootSpec, String> {
4040 use crate::sealed;
4041
4042 if !accessible_workdir.is_absolute() {
4046 return Err(format!(
4047 "sealed: capsule workdir {} is not absolute; a composed root reproduces the workdir at \
4048 its own absolute path",
4049 accessible_workdir.display()
4050 ));
4051 }
4052
4053 let host_path_binds = host_paths
4054 .iter()
4055 .map(|host_path| {
4056 SealedSourceFds::identity_of(&host_path.fd).map(|identity| sealed::HostPathBind {
4057 path: host_path.path.clone(),
4058 identity,
4059 read_only: !host_path.read_write,
4060 })
4061 })
4062 .collect::<Result<Vec<_>, _>>()?;
4063 let host_path_dirs: Vec<PathBuf> = host_paths
4064 .iter()
4065 .map(|host_path| host_path.path.clone())
4066 .collect();
4067 let base = sealed::choose_root_base(
4068 accessible_workdir,
4069 &host_path_dirs,
4070 sealed::SEALED_ROOT_BASE_CANDIDATES,
4071 |path| path.is_dir(),
4072 )
4073 .ok_or_else(|| {
4074 format!(
4075 "sealed: no usable base directory for the composed root; none of {:?} exists outside \
4076 the session workdir's own path and every host path's",
4077 sealed::SEALED_ROOT_BASE_CANDIDATES
4078 )
4079 })?;
4080
4081 let plan = sealed::plan_composed_root(
4082 accessible_workdir,
4083 &sealed::SealedBindSources {
4084 tmp_store: (
4085 &sources.tmp_store_path,
4086 SealedSourceFds::identity_of(&sources.tmp_store)?,
4087 ),
4088 etc_dir: &sources.etc_dir,
4089 runtime_dir: Some((
4090 &sources.runtime_dir_path,
4091 SealedSourceFds::identity_of(&sources.runtime_dir)?,
4092 )),
4093 records_dir: sources
4094 .records_dir
4095 .as_ref()
4096 .map(|(fd, path)| {
4097 SealedSourceFds::identity_of(fd).map(|identity| (path.as_path(), identity))
4098 })
4099 .transpose()?,
4100 home_dir: Some((
4101 &sources.home_dir.1,
4102 SealedSourceFds::identity_of(&sources.home_dir.0)?,
4103 )),
4104 state_dir: sources
4105 .state_dir
4106 .as_ref()
4107 .map(|(fd, path)| {
4108 SealedSourceFds::identity_of(fd).map(|identity| (path.as_path(), identity))
4109 })
4110 .transpose()?,
4111 host_paths: &host_path_binds,
4112 host_path_read_only,
4113 },
4114 &base,
4115 extra_read_only,
4116 staged_runtime_read_only,
4117 &sealed::RealHostLayout,
4118 );
4119 sealed::build_sealed_root_spec(&plan)
4120}
4121
4122#[cfg_attr(target_os = "linux", allow(unsafe_code))]
4130#[cfg(target_os = "linux")]
4131mod linux_enforce {
4132 use std::io;
4133 use std::os::fd::{AsFd, OwnedFd, RawFd};
4134 use std::path::Path;
4135
4136 use landlock::{make_bitflags, AccessFs, BitFlags};
4137
4138 use super::{EnforcementTier, LandlockGrant};
4139
4140 const MAX_DIAG_LEN: usize = 1024;
4144
4145 pub(super) struct OpenLandlockGrant {
4150 fd: OwnedFd,
4151 list_dir: bool,
4152 executable: bool,
4153 }
4154
4155 pub(super) struct OpenDeviceGrant {
4162 fd: OwnedFd,
4163 writable: bool,
4164 }
4165
4166 pub(super) struct LandlockChildFds {
4173 workdir_fd: OwnedFd,
4174 workdir_exec: bool,
4178 abi: Option<u8>,
4182 temp_dir_fd: Option<std::sync::Arc<OwnedFd>>,
4193 home_dir_fd: Option<std::sync::Arc<OwnedFd>>,
4198 state_dir_fd: Option<std::sync::Arc<OwnedFd>>,
4203 sealed_identity_fds: Vec<OwnedFd>,
4216 runtime_dir_fd: Option<std::sync::Arc<OwnedFd>>,
4221 records_dir_fd: Option<std::sync::Arc<OwnedFd>>,
4226 host_path_fds: Vec<(std::sync::Arc<OwnedFd>, bool)>,
4229 grants: Vec<OpenLandlockGrant>,
4230 devices: Vec<OpenDeviceGrant>,
4231 }
4232
4233 impl LandlockChildFds {
4234 #[cfg(test)]
4237 pub(super) fn temp_dir_fd(&self) -> Option<&OwnedFd> {
4238 self.temp_dir_fd.as_deref()
4239 }
4240
4241 #[cfg(test)]
4243 pub(super) fn home_dir_fd(&self) -> Option<&OwnedFd> {
4244 self.home_dir_fd.as_deref()
4245 }
4246
4247 #[cfg(test)]
4249 pub(super) fn state_dir_fd(&self) -> Option<&OwnedFd> {
4250 self.state_dir_fd.as_deref()
4251 }
4252
4253 #[cfg(test)]
4255 pub(super) fn runtime_dir_fd(&self) -> Option<&OwnedFd> {
4256 self.runtime_dir_fd.as_deref()
4257 }
4258
4259 #[cfg(test)]
4261 pub(super) fn records_dir_fd(&self) -> Option<&OwnedFd> {
4262 self.records_dir_fd.as_deref()
4263 }
4264
4265 #[cfg(test)]
4268 pub(super) fn sealed_identity_fd_count(&self) -> usize {
4269 self.sealed_identity_fds.len()
4270 }
4271
4272 #[cfg(test)]
4274 pub(super) fn grant_count(&self) -> usize {
4275 self.grants.len()
4276 }
4277 }
4278
4279 pub(super) const WORKDIR_ACCESS_RIGHTS_NO_EXEC: BitFlags<AccessFs> = make_bitflags!(AccessFs::{
4338 WriteFile
4339 | ReadFile
4340 | ReadDir
4341 | RemoveDir
4342 | RemoveFile
4343 | MakeDir
4344 | MakeReg
4345 | MakeFifo
4346 | MakeSym
4347 | Refer
4348 | Truncate
4349 });
4350
4351 pub(super) fn workdir_access_rights(workdir_exec: bool) -> BitFlags<AccessFs> {
4359 if workdir_exec {
4360 WORKDIR_ACCESS_RIGHTS_NO_EXEC | AccessFs::Execute
4361 } else {
4362 WORKDIR_ACCESS_RIGHTS_NO_EXEC
4363 }
4364 }
4365
4366 pub(super) fn host_path_access_rights(read_write: bool) -> BitFlags<AccessFs> {
4371 if read_write {
4372 workdir_access_rights(false)
4373 } else {
4374 AccessFs::ReadFile | AccessFs::ReadDir
4375 }
4376 }
4377
4378 pub(super) fn kernel_landlock_abi() -> Option<u8> {
4385 const LANDLOCK_CREATE_RULESET_VERSION: u32 = 1;
4386 let version = unsafe {
4389 libc::syscall(
4390 libc::SYS_landlock_create_ruleset,
4391 std::ptr::null::<libc::c_void>(),
4392 0usize,
4393 LANDLOCK_CREATE_RULESET_VERSION,
4394 )
4395 };
4396 if version <= 0 {
4397 return None;
4398 }
4399 Some(u8::try_from(version).unwrap_or(u8::MAX))
4400 }
4401
4402 pub(super) fn probe_landlock_full_access() -> Option<bool> {
4419 use landlock::{
4420 Access, AccessFs, CompatLevel, Compatible, PathBeneath, PathFd, Ruleset, RulesetAttr,
4421 RulesetCreatedAttr, RulesetStatus, ABI,
4422 };
4423
4424 const PROBE_FULLY_ENFORCED: i32 = 0;
4427 const PROBE_PARTIAL: i32 = 1;
4428 const PROBE_FAILED: i32 = 2;
4429
4430 let abi = ABI::V1;
4434 let access_all = AccessFs::from_all(abi);
4435
4436 let root_fd = PathFd::new("/").ok()?;
4437
4438 let ruleset = Ruleset::default()
4439 .set_compatibility(CompatLevel::BestEffort)
4440 .handle_access(access_all)
4441 .ok()?
4442 .create()
4443 .ok()?
4444 .add_rule(PathBeneath::new(root_fd, access_all))
4445 .ok()?;
4446
4447 let pid = unsafe { libc::fork() };
4452 if pid < 0 {
4453 return None;
4454 }
4455 if pid == 0 {
4456 let code = match ruleset.restrict_self() {
4457 Ok(status) => {
4458 if matches!(status.ruleset, RulesetStatus::FullyEnforced) {
4459 PROBE_FULLY_ENFORCED
4460 } else {
4461 PROBE_PARTIAL
4462 }
4463 }
4464 Err(_) => PROBE_FAILED,
4465 };
4466 unsafe { libc::_exit(code) }
4469 }
4470
4471 let mut wait_status: libc::c_int = 0;
4472 let waited = unsafe { libc::waitpid(pid, &mut wait_status, 0) };
4474 if waited < 0 || !libc::WIFEXITED(wait_status) {
4475 return None;
4476 }
4477 match libc::WEXITSTATUS(wait_status) {
4478 PROBE_FULLY_ENFORCED => Some(true),
4479 PROBE_PARTIAL => Some(false),
4480 _ => None,
4483 }
4484 }
4485
4486 pub(super) fn child_install_enforcement(
4495 tier: EnforcementTier,
4496 user_ns_fd: RawFd,
4497 net_ns_fd: RawFd,
4498 tmp_map_spec: Option<&crate::temp_dir::TmpMapSpec>,
4499 sealed_spec: Option<&crate::sealed::SealedRootSpec>,
4500 landlock_fds: Option<&LandlockChildFds>,
4501 unix_sockets_allowed: bool,
4502 ) -> io::Result<()> {
4503 unsafe { crate::network_namespace::join_session_netns(user_ns_fd, net_ns_fd) }.map_err(
4525 |error| {
4526 io::Error::other(format!(
4527 "sandbox: joining this session's network namespace failed: {error}"
4528 ))
4529 },
4530 )?;
4531
4532 if let Some(spec) = tmp_map_spec {
4538 #[cfg(test)]
4539 if super::FORCE_TMP_MAP_FAILURE.with(|flag| flag.get()) {
4540 return Err(io::Error::other(format!(
4541 "{} (forced by test seam)",
4542 spec.describe(crate::temp_dir::TmpMapFailure::forced(libc::EPERM))
4543 )));
4544 }
4545 unsafe { crate::temp_dir::map_private_tmp(spec) }
4550 .map_err(|failure| io::Error::other(spec.describe(failure)))?;
4551 }
4552
4553 if tier == EnforcementTier::KernelSealed {
4564 if let Some(spec) = sealed_spec {
4565 #[cfg(test)]
4566 if super::FORCE_SEALED_ROOT_FAILURE.with(|flag| flag.get()) {
4567 return Err(io::Error::other(format!(
4568 "{} unshare(CLONE_NEWUSER|CLONE_NEWNS) failed (forced by test seam)",
4569 crate::sealed::SEALED_ROOT_FAILURE_PREFIX
4570 )));
4571 }
4572 crate::sealed::construct_composed_root(spec)
4573 .map_err(|failure| io::Error::other(spec.describe(failure)))?;
4574 }
4575 }
4576
4577 mark_inherited_fds_cloexec()?;
4592
4593 drop_all_capabilities()?;
4601
4602 install_seccomp_filter(unix_sockets_allowed)?;
4608
4609 if matches!(
4610 tier,
4611 EnforcementTier::KernelFull | EnforcementTier::KernelSealed
4612 ) {
4613 if let Some(fds) = landlock_fds {
4614 apply_landlock_scope(fds).map_err(io::Error::other)?;
4615 }
4616 }
4617
4618 Ok(())
4619 }
4620
4621 pub(super) fn mark_inherited_fds_cloexec() -> io::Result<()> {
4645 let rc = unsafe {
4651 libc::syscall(
4652 libc::SYS_close_range,
4653 super::FD_HYGIENE_FIRST_FD,
4654 libc::c_uint::MAX,
4655 libc::CLOSE_RANGE_CLOEXEC,
4656 )
4657 };
4658 if rc != 0 {
4659 return Err(io::Error::last_os_error());
4660 }
4661 Ok(())
4662 }
4663
4664 const LINUX_CAPABILITY_VERSION_3: u32 = 0x2008_0522;
4669
4670 const CAP_LAST_CAP_FALLBACK: u32 = 63;
4675
4676 #[repr(C)]
4684 #[allow(dead_code)]
4685 struct CapUserHeader {
4686 version: u32,
4687 pid: libc::c_int,
4688 }
4689
4690 #[repr(C)]
4695 #[derive(Clone, Copy)]
4696 #[allow(dead_code)]
4697 struct CapUserData {
4698 effective: u32,
4699 permitted: u32,
4700 inheritable: u32,
4701 }
4702
4703 fn drop_all_capabilities() -> io::Result<()> {
4739 drop_capability_bounding_set()?;
4740 clear_capability_sets()?;
4741 set_no_new_privs()
4742 }
4743
4744 #[allow(unsafe_code)]
4754 fn drop_capability_bounding_set() -> io::Result<()> {
4755 let zero: libc::c_ulong = 0;
4756
4757 for cap in 0..=kernel_last_cap() {
4758 let cap_arg = libc::c_ulong::from(cap);
4759 let rc = unsafe { libc::prctl(libc::PR_CAPBSET_DROP, cap_arg, zero, zero, zero) };
4762 if rc == 0 {
4763 continue;
4764 }
4765
4766 let error = io::Error::last_os_error();
4767 match error.raw_os_error() {
4768 Some(libc::EINVAL) | Some(libc::EPERM) => continue,
4769 _ => return Err(error),
4770 }
4771 }
4772
4773 Ok(())
4774 }
4775
4776 #[allow(unsafe_code)]
4780 fn clear_capability_sets() -> io::Result<()> {
4781 let header = CapUserHeader {
4782 version: LINUX_CAPABILITY_VERSION_3,
4783 pid: 0,
4786 };
4787 let data = [CapUserData {
4788 effective: 0,
4789 permitted: 0,
4790 inheritable: 0,
4791 }; 2];
4792
4793 let rc = unsafe {
4797 libc::syscall(
4798 libc::SYS_capset,
4799 &header as *const CapUserHeader,
4800 data.as_ptr(),
4801 )
4802 };
4803 if rc != 0 {
4804 return Err(io::Error::last_os_error());
4805 }
4806
4807 Ok(())
4808 }
4809
4810 #[allow(unsafe_code)]
4817 fn set_no_new_privs() -> io::Result<()> {
4818 #[cfg(test)]
4819 if super::FORCE_NO_NEW_PRIVS_FAILURE.with(|flag| flag.get()) {
4820 return Err(io::Error::other(
4821 "sandbox: no_new_privs (prctl PR_SET_NO_NEW_PRIVS, 1) failed (forced by test seam)",
4822 ));
4823 }
4824 let enable: libc::c_ulong = 1;
4828 let zero: libc::c_ulong = 0;
4829
4830 let rc = unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, enable, zero, zero, zero) };
4833 if rc != 0 {
4834 return Err(io::Error::last_os_error());
4835 }
4836
4837 Ok(())
4838 }
4839
4840 #[allow(unsafe_code)]
4846 fn kernel_last_cap() -> u32 {
4847 const PATH: &[u8] = b"/proc/sys/kernel/cap_last_cap\0";
4848
4849 let fd = unsafe {
4852 libc::open(
4853 PATH.as_ptr() as *const libc::c_char,
4854 libc::O_RDONLY | libc::O_CLOEXEC,
4855 )
4856 };
4857 if fd < 0 {
4858 return CAP_LAST_CAP_FALLBACK;
4859 }
4860
4861 let mut buf = [0u8; 16];
4862 let read = unsafe { libc::read(fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) };
4865 unsafe { libc::close(fd) };
4867
4868 if read <= 0 {
4869 return CAP_LAST_CAP_FALLBACK;
4870 }
4871
4872 let parsed = std::str::from_utf8(&buf[..read as usize])
4873 .ok()
4874 .and_then(|text| text.trim().parse::<u32>().ok());
4875
4876 match parsed {
4877 Some(last) if last <= CAP_LAST_CAP_FALLBACK => last,
4878 _ => CAP_LAST_CAP_FALLBACK,
4879 }
4880 }
4881
4882 const LANDLOCK_SYSCALLS: [(&str, i32); 3] = [
4891 ("landlock_create_ruleset", 444),
4892 ("landlock_add_rule", 445),
4893 ("landlock_restrict_self", 446),
4894 ];
4895
4896 fn build_seccomp_filter(
4920 unix_sockets_allowed: bool,
4921 ) -> io::Result<libseccomp::ScmpFilterContext> {
4922 let mut filter =
4929 libseccomp::ScmpFilterContext::new(libseccomp::ScmpAction::Errno(libc::EPERM))
4930 .map_err(to_io_err)?;
4931
4932 let _ = filter.set_ctl_log(true);
4951
4952 let socket_syscall = libseccomp::ScmpSyscall::from_name("socket").map_err(to_io_err)?;
4972 for domain in super::denied_socket_domains(unix_sockets_allowed) {
4973 filter
4974 .add_rule_conditional(
4975 libseccomp::ScmpAction::Errno(libc::EACCES),
4976 socket_syscall,
4977 &[libseccomp::ScmpArgCompare::new(
4978 0,
4979 libseccomp::ScmpCompareOp::Equal,
4980 domain as u64,
4981 )],
4982 )
4983 .map_err(to_io_err)?;
4984 }
4985
4986 for domain in super::allowed_socket_domains(unix_sockets_allowed) {
5000 filter
5001 .add_rule_conditional(
5002 libseccomp::ScmpAction::Allow,
5003 socket_syscall,
5004 &[libseccomp::ScmpArgCompare::new(
5005 0,
5006 libseccomp::ScmpCompareOp::Equal,
5007 domain as u64,
5008 )],
5009 )
5010 .map_err(to_io_err)?;
5011 }
5012
5013 for name in super::SECCOMP_SYSCALL_ALLOWLIST {
5026 if let Ok(syscall) = libseccomp::ScmpSyscall::from_name(name) {
5027 let _ = filter.add_rule(libseccomp::ScmpAction::Allow, syscall);
5028 }
5029 }
5030
5031 for (name, fallback_nr) in LANDLOCK_SYSCALLS {
5044 let syscall = libseccomp::ScmpSyscall::from_name(name)
5045 .unwrap_or_else(|_| libseccomp::ScmpSyscall::from(fallback_nr));
5046 filter
5047 .add_rule(libseccomp::ScmpAction::Allow, syscall)
5048 .map_err(to_io_err)?;
5049 }
5050
5051 Ok(filter)
5052 }
5053
5054 fn install_seccomp_filter(unix_sockets_allowed: bool) -> io::Result<()> {
5058 build_seccomp_filter(unix_sockets_allowed)?
5062 .load()
5063 .map_err(to_io_err)
5064 }
5065
5066 fn to_io_err<E: std::fmt::Display>(error: E) -> io::Error {
5067 io::Error::other(error.to_string())
5068 }
5069
5070 #[cfg(test)]
5074 const SOCK_FILTER_BYTES: usize = 8;
5075
5076 #[cfg(test)]
5084 fn export_to_memfd<F>(export: F) -> io::Result<Vec<u8>>
5085 where
5086 F: FnOnce(&OwnedFd) -> Result<(), libseccomp::error::SeccompError>,
5087 {
5088 use std::io::{Read, Seek, SeekFrom};
5089 use std::os::fd::FromRawFd;
5090
5091 let raw = unsafe { libc::memfd_create(c"murmur-seccomp-export".as_ptr(), 0) };
5095 if raw < 0 {
5096 return Err(io::Error::last_os_error());
5097 }
5098 let memfd = unsafe { OwnedFd::from_raw_fd(raw) };
5101
5102 export(&memfd).map_err(to_io_err)?;
5103
5104 let mut file = std::fs::File::from(memfd);
5105 file.seek(SeekFrom::Start(0))?;
5106 let mut exported = Vec::new();
5107 file.read_to_end(&mut exported)?;
5108 Ok(exported)
5109 }
5110
5111 #[cfg(test)]
5123 fn seccomp_filter_instruction_count(unix_sockets_allowed: bool) -> io::Result<usize> {
5124 let filter = build_seccomp_filter(unix_sockets_allowed)?;
5125 let exported = export_to_memfd(|fd| filter.export_bpf(fd))?;
5126 Ok(exported.len() / SOCK_FILTER_BYTES)
5127 }
5128
5129 #[cfg(test)]
5134 fn seccomp_filter_pfc(unix_sockets_allowed: bool) -> io::Result<String> {
5135 let filter = build_seccomp_filter(unix_sockets_allowed)?;
5136 let exported = export_to_memfd(|fd| filter.export_pfc(fd))?;
5137 String::from_utf8(exported).map_err(to_io_err)
5138 }
5139
5140 fn apply_landlock_scope(fds: &LandlockChildFds) -> Result<(), String> {
5203 #[cfg(test)]
5204 if super::FORCE_LANDLOCK_FAILURE.with(|flag| flag.get()) {
5205 return Err("landlock: ruleset construction failed (forced by test seam)".to_string());
5206 }
5207
5208 use landlock::{
5209 Access, AccessFs, CompatLevel, Compatible, PathBeneath, Ruleset, RulesetAttr,
5210 RulesetCreatedAttr, ABI,
5211 };
5212
5213 let abi = fds.abi.map_or(ABI::V1, |abi| ABI::from(i32::from(abi)));
5214 let handled = AccessFs::from_all(abi);
5215 let read_execute = AccessFs::Execute | AccessFs::ReadFile;
5219 let read_execute_list = read_execute | AccessFs::ReadDir;
5221 let read_list = AccessFs::ReadFile | AccessFs::ReadDir;
5225 let read_only: BitFlags<AccessFs> = AccessFs::ReadFile.into();
5229 let device_read: BitFlags<AccessFs> = AccessFs::ReadFile.into();
5234 let device_read_write = device_read | AccessFs::WriteFile | AccessFs::Truncate;
5235
5236 let writable_rights = workdir_access_rights(fds.workdir_exec) & handled;
5243
5244 let mut ruleset = Ruleset::default()
5248 .set_compatibility(CompatLevel::HardRequirement)
5249 .handle_access(handled)
5250 .map_err(|error| format!("landlock: handle_access failed: {error}"))?
5251 .create()
5252 .map_err(|error| format!("landlock: ruleset create failed: {error}"))?
5253 .add_rule(PathBeneath::new(fds.workdir_fd.as_fd(), writable_rights))
5254 .map_err(|error| format!("landlock: add_rule failed: {error}"))?;
5255
5256 if let Some(temp_dir_fd) = &fds.temp_dir_fd {
5259 ruleset = ruleset
5260 .add_rule(PathBeneath::new(temp_dir_fd.as_fd(), writable_rights))
5261 .map_err(|error| {
5262 format!("landlock: add_rule for the session's temporary folder failed: {error}")
5263 })?;
5264 }
5265
5266 if let Some(home_dir_fd) = &fds.home_dir_fd {
5269 ruleset = ruleset
5270 .add_rule(PathBeneath::new(home_dir_fd.as_fd(), writable_rights))
5271 .map_err(|error| {
5272 format!("landlock: add_rule for the session's home folder failed: {error}")
5273 })?;
5274 }
5275
5276 if let Some(state_dir_fd) = &fds.state_dir_fd {
5279 ruleset = ruleset
5280 .add_rule(PathBeneath::new(
5281 state_dir_fd.as_fd(),
5282 workdir_access_rights(false) & handled,
5283 ))
5284 .map_err(|error| {
5285 format!("landlock: add_rule for the native tool's state store failed: {error}")
5286 })?;
5287 }
5288
5289 for (host_path_fd, read_write) in &fds.host_path_fds {
5293 ruleset = ruleset
5294 .add_rule(PathBeneath::new(
5295 host_path_fd.as_fd(),
5296 host_path_access_rights(*read_write) & handled,
5297 ))
5298 .map_err(|error| format!("landlock: add_rule for a host path failed: {error}"))?;
5299 }
5300
5301 if let Some(runtime_dir_fd) = &fds.runtime_dir_fd {
5304 ruleset = ruleset
5305 .add_rule(PathBeneath::new(
5306 runtime_dir_fd.as_fd(),
5307 read_list & handled,
5308 ))
5309 .map_err(|error| {
5310 format!("landlock: add_rule for the runtime directory failed: {error}")
5311 })?;
5312 }
5313
5314 if let Some(records_dir_fd) = &fds.records_dir_fd {
5317 ruleset = ruleset
5318 .add_rule(PathBeneath::new(
5319 records_dir_fd.as_fd(),
5320 read_list & handled,
5321 ))
5322 .map_err(|error| {
5323 format!("landlock: add_rule for the records directory failed: {error}")
5324 })?;
5325 }
5326
5327 for identity_fd in &fds.sealed_identity_fds {
5331 ruleset = ruleset
5332 .add_rule(PathBeneath::new(identity_fd.as_fd(), read_only & handled))
5333 .map_err(|error| {
5334 format!("landlock: add_rule for a synthetic /etc file failed: {error}")
5335 })?;
5336 }
5337
5338 for grant in &fds.grants {
5339 let access = match (grant.executable, grant.list_dir) {
5343 (true, true) => read_execute_list,
5344 (true, false) => read_execute,
5345 (false, true) => read_list,
5346 (false, false) => read_only,
5347 };
5348 ruleset = ruleset
5349 .add_rule(PathBeneath::new(grant.fd.as_fd(), access & handled))
5350 .map_err(|error| format!("landlock: add_rule for grant failed: {error}"))?;
5351 }
5352
5353 for device in &fds.devices {
5354 let access = if device.writable {
5358 device_read_write
5359 } else {
5360 device_read
5361 };
5362 ruleset = ruleset
5363 .add_rule(PathBeneath::new(device.fd.as_fd(), access & handled))
5364 .map_err(|error| format!("landlock: add_rule for device failed: {error}"))?;
5365 }
5366
5367 ruleset
5368 .restrict_self()
5369 .map_err(|error| format!("landlock: restrict_self failed: {error}"))?;
5370
5371 Ok(())
5372 }
5373
5374 pub(super) struct LandlockFdRequest<'a> {
5376 pub(super) accessible_workdir: &'a Path,
5379 pub(super) landlock_grants: &'a [LandlockGrant],
5382 pub(super) device_grants: &'a [super::CapsuleDeviceGrant],
5390 pub(super) workdir_exec: bool,
5392 pub(super) abi: Option<u8>,
5394 pub(super) sealed: Option<&'a super::SealedSourceFds>,
5396 pub(super) temp_dir_fd: Option<std::sync::Arc<OwnedFd>>,
5401 pub(super) home_dir_fd: Option<std::sync::Arc<OwnedFd>>,
5403 pub(super) state_dir_fd: Option<std::sync::Arc<OwnedFd>>,
5405 pub(super) runtime_dir_fd: Option<std::sync::Arc<OwnedFd>>,
5407 pub(super) records_dir_fd: Option<std::sync::Arc<OwnedFd>>,
5409 pub(super) host_path_fds: Vec<(std::sync::Arc<OwnedFd>, bool)>,
5412 }
5413
5414 impl<'a> LandlockFdRequest<'a> {
5415 #[cfg(test)]
5418 pub(super) fn workdir_only(accessible_workdir: &'a Path) -> Self {
5419 Self {
5420 accessible_workdir,
5421 landlock_grants: &[],
5422 device_grants: &[],
5423 workdir_exec: false,
5424 abi: None,
5425 sealed: None,
5426 temp_dir_fd: None,
5427 home_dir_fd: None,
5428 state_dir_fd: None,
5429 runtime_dir_fd: None,
5430 records_dir_fd: None,
5431 host_path_fds: Vec::new(),
5432 }
5433 }
5434 }
5435
5436 pub(super) fn open_landlock_fds(
5450 request: LandlockFdRequest<'_>,
5451 ) -> Result<LandlockChildFds, String> {
5452 let LandlockFdRequest {
5453 accessible_workdir,
5454 landlock_grants,
5455 device_grants,
5456 workdir_exec,
5457 abi,
5458 sealed,
5459 temp_dir_fd,
5460 home_dir_fd,
5461 state_dir_fd,
5462 runtime_dir_fd,
5463 records_dir_fd,
5464 host_path_fds,
5465 } = request;
5466 let workdir_fd = open_o_path(accessible_workdir)
5467 .map(OwnedFd::from)
5468 .map_err(|error| {
5469 format!(
5470 "sandbox: failed to open workdir {} for Landlock scoping: {error}",
5471 accessible_workdir.display()
5472 )
5473 })?;
5474
5475 let mut sealed_identity_fds = Vec::new();
5480 for path in sealed
5481 .map(|sources| sources.identity_files.as_slice())
5482 .unwrap_or_default()
5483 {
5484 let file = open_o_path(path).map_err(|error| {
5485 format!(
5486 "sandbox: failed to open the synthetic /etc file {} for Landlock scoping: \
5487 {error}",
5488 path.display()
5489 )
5490 })?;
5491 sealed_identity_fds.push(OwnedFd::from(file));
5492 }
5493
5494 let mut grants = Vec::with_capacity(landlock_grants.len());
5495 for grant in landlock_grants {
5496 match open_o_path(&grant.path) {
5497 Ok(file)
5498 if grant.pinned.is_some_and(|pinned| {
5499 file.metadata()
5500 .map(|meta| super::FileIdentity::of(&meta) != pinned)
5501 .unwrap_or(true)
5502 }) =>
5503 {
5504 continue
5505 }
5506 Ok(file) => grants.push(OpenLandlockGrant {
5507 list_dir: grant.list_dir
5517 && file.metadata().map(|meta| meta.is_dir()).unwrap_or(false),
5518 fd: OwnedFd::from(file),
5519 executable: grant.executable,
5520 }),
5521 Err(_) => continue,
5522 }
5523 }
5524
5525 let mut devices = Vec::with_capacity(device_grants.len());
5526 for device in device_grants {
5527 match open_o_path(Path::new(device.path)) {
5528 Ok(file) => devices.push(OpenDeviceGrant {
5529 fd: OwnedFd::from(file),
5530 writable: device.writable,
5531 }),
5532 Err(_) => continue,
5533 }
5534 }
5535
5536 Ok(LandlockChildFds {
5537 workdir_fd,
5538 workdir_exec,
5539 abi,
5540 temp_dir_fd,
5541 home_dir_fd,
5542 state_dir_fd,
5543 sealed_identity_fds,
5544 runtime_dir_fd,
5545 records_dir_fd,
5546 host_path_fds,
5547 grants,
5548 devices,
5549 })
5550 }
5551
5552 fn open_o_path(path: &Path) -> io::Result<std::fs::File> {
5561 use std::os::unix::fs::OpenOptionsExt;
5562 std::fs::OpenOptions::new()
5563 .read(true)
5564 .custom_flags(libc::O_PATH | libc::O_CLOEXEC)
5565 .open(path)
5566 }
5567
5568 pub(super) fn open_state_dir(path: &Path) -> Result<OwnedFd, String> {
5573 use std::os::unix::fs::OpenOptionsExt;
5574 std::fs::OpenOptions::new()
5575 .read(true)
5576 .custom_flags(libc::O_PATH | libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_DIRECTORY)
5577 .open(path)
5578 .map(OwnedFd::from)
5579 .map_err(|error| {
5580 format!(
5581 "sandbox: failed to open the native tool's state store {}: {error}",
5582 path.display()
5583 )
5584 })
5585 }
5586
5587 pub(super) fn open_host_path(path: &Path) -> Result<OwnedFd, String> {
5591 use std::os::unix::fs::OpenOptionsExt;
5592 std::fs::OpenOptions::new()
5593 .read(true)
5594 .custom_flags(libc::O_PATH | libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_DIRECTORY)
5595 .open(path)
5596 .map(OwnedFd::from)
5597 .map_err(|error| {
5598 format!(
5599 "sandbox: failed to open host path {} (host_paths in ~/.murmur/config.yaml): \
5600 {error}",
5601 path.display()
5602 )
5603 })
5604 }
5605
5606 #[allow(unsafe_code)]
5612 pub(super) fn write_diagnostic(fd: RawFd, message: &str) {
5613 let bytes = message.as_bytes();
5614 let len = bytes.len().min(MAX_DIAG_LEN);
5615 let mut written = 0;
5616 while written < len {
5617 let n = unsafe {
5620 libc::write(
5621 fd,
5622 bytes[written..len].as_ptr() as *const libc::c_void,
5623 len - written,
5624 )
5625 };
5626 if n < 0 {
5627 if io::Error::last_os_error().raw_os_error() == Some(libc::EINTR) {
5628 continue;
5629 }
5630 break;
5631 }
5632 if n == 0 {
5633 break;
5634 }
5635 written += n as usize;
5636 }
5637 }
5638
5639 #[allow(unsafe_code)]
5645 pub(super) fn read_diagnostic_pipe(fd: RawFd) -> Option<String> {
5646 let mut buf = Vec::new();
5647 let mut chunk = [0u8; 256];
5648 loop {
5649 let n = unsafe { libc::read(fd, chunk.as_mut_ptr() as *mut libc::c_void, chunk.len()) };
5652 if n < 0 {
5653 if io::Error::last_os_error().raw_os_error() == Some(libc::EINTR) {
5654 continue;
5655 }
5656 break;
5657 }
5658 if n == 0 {
5659 break;
5660 }
5661 buf.extend_from_slice(&chunk[..n as usize]);
5662 if buf.len() >= MAX_DIAG_LEN {
5663 break;
5664 }
5665 }
5666 if buf.is_empty() {
5667 None
5668 } else {
5669 Some(String::from_utf8_lossy(&buf).into_owned())
5670 }
5671 }
5672
5673 #[cfg(test)]
5684 #[allow(clippy::print_stdout, clippy::print_stderr)]
5685 mod seccomp_budget {
5686 use std::io;
5687 use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
5688
5689 use super::{
5692 build_seccomp_filter, export_to_memfd, seccomp_filter_pfc, to_io_err,
5693 LANDLOCK_SYSCALLS, SOCK_FILTER_BYTES,
5694 };
5695 use crate::sandbox::{
5696 allowed_socket_domains, denied_socket_domains, detect_enforcement_tier,
5697 EnforcementTier, BPF_MAXINSNS_CEILING, SECCOMP_FILTER_INSTRUCTION_BUDGET,
5698 SECCOMP_MAX_INSNS_PER_PATH, SECCOMP_SYSCALL_ALLOWLIST,
5699 };
5700
5701 const KERNEL_PER_FILTER_OVERHEAD_INSNS: usize = 4;
5712
5713 const PROBE_ERRNO_UNKNOWN: i32 = 254;
5717
5718 fn probe_filter(rules: usize) -> io::Result<(libseccomp::ScmpFilterContext, usize)> {
5733 let mut filter = libseccomp::ScmpFilterContext::new(libseccomp::ScmpAction::Allow)
5734 .map_err(to_io_err)?;
5735 filter.set_api_sysrawrc(true).map_err(to_io_err)?;
5736
5737 let syscall = libseccomp::ScmpSyscall::from_name("ptrace").map_err(to_io_err)?;
5738 for value in 0..rules {
5739 filter
5740 .add_rule_conditional(
5741 libseccomp::ScmpAction::Errno(libc::EPERM),
5742 syscall,
5743 &[libseccomp::ScmpArgCompare::new(
5744 0,
5745 libseccomp::ScmpCompareOp::Equal,
5746 value as u64,
5747 )],
5748 )
5749 .map_err(to_io_err)?;
5750 }
5751
5752 let exported = export_to_memfd(|fd| filter.export_bpf(fd))?;
5753 Ok((filter, exported.len() / SOCK_FILTER_BYTES))
5754 }
5755
5756 fn first_rule_count_above(instructions: usize) -> io::Result<usize> {
5761 let mut under = 1;
5762 let mut over = 8;
5763 while probe_filter(over)?.1 <= instructions {
5764 under = over;
5765 over *= 2;
5766 assert!(
5767 over <= 1 << 20,
5768 "no probe filter of up to {over} rules exceeded {instructions} instructions — \
5769 the probe is not growing the program the way this search assumes"
5770 );
5771 }
5772 while over - under > 1 {
5773 let mid = under + (over - under) / 2;
5774 if probe_filter(mid)?.1 <= instructions {
5775 under = mid;
5776 } else {
5777 over = mid;
5778 }
5779 }
5780 Ok(over)
5781 }
5782
5783 fn refusal_errno(error: &libseccomp::error::SeccompError) -> i32 {
5788 use libseccomp::error::SeccompErrno;
5789
5790 if let Some(raw) = error.sysrawrc() {
5791 return -raw;
5792 }
5793 match error.errno() {
5794 Some(SeccompErrno::EACCES) => libc::EACCES,
5795 Some(SeccompErrno::ECANCELED) => libc::ECANCELED,
5796 Some(SeccompErrno::EINVAL) => libc::EINVAL,
5797 Some(SeccompErrno::ENOMEM) => libc::ENOMEM,
5798 _ => PROBE_ERRNO_UNKNOWN,
5799 }
5800 }
5801
5802 fn load_in_child(filter: &libseccomp::ScmpFilterContext) -> Result<(), i32> {
5808 let pid = unsafe { libc::fork() };
5815 assert!(pid >= 0, "fork failed: {}", io::Error::last_os_error());
5816
5817 if pid == 0 {
5818 let code = match filter.load() {
5819 Ok(()) => 0,
5820 Err(error) => refusal_errno(&error),
5821 };
5822 unsafe { libc::_exit(code) }
5825 }
5826
5827 let mut status: libc::c_int = 0;
5828 let waited = unsafe { libc::waitpid(pid, &mut status, 0) };
5830 assert!(waited == pid, "waitpid: {}", io::Error::last_os_error());
5831 assert!(
5832 libc::WIFEXITED(status),
5833 "the load probe child did not exit normally (status {status})"
5834 );
5835 match libc::WEXITSTATUS(status) {
5836 0 => Ok(()),
5837 errno => Err(errno),
5838 }
5839 }
5840
5841 fn resolvable_allowlist_syscalls() -> io::Result<usize> {
5846 use std::collections::HashSet;
5847
5848 let mut scratch =
5849 libseccomp::ScmpFilterContext::new(libseccomp::ScmpAction::Errno(libc::EPERM))
5850 .map_err(to_io_err)?;
5851 let mut accepted = HashSet::new();
5852 for name in SECCOMP_SYSCALL_ALLOWLIST {
5853 if let Ok(syscall) = libseccomp::ScmpSyscall::from_name(name) {
5854 if scratch
5855 .add_rule(libseccomp::ScmpAction::Allow, syscall)
5856 .is_ok()
5857 {
5858 accepted.insert(i32::from(syscall));
5859 }
5860 }
5861 }
5862 Ok(accepted.len())
5863 }
5864
5865 fn skip_without_seccomp(what: &str) -> bool {
5868 let tier = detect_enforcement_tier();
5869 if tier == EnforcementTier::EnvironmentOnly {
5870 eprintln!(
5871 "SKIP — PROVES NOTHING ON THIS HOST: measuring {what} requires a host that \
5872 can load a seccomp filter at all, which is every tier above \
5873 EnforcementTier::EnvironmentOnly; detected {tier:?}"
5874 );
5875 return true;
5876 }
5877 false
5878 }
5879
5880 #[test]
5887 fn seccomp_filter_instruction_count() {
5888 for unix_sockets_allowed in [false, true] {
5889 let count = super::seccomp_filter_instruction_count(unix_sockets_allowed)
5890 .expect("export the built seccomp filter");
5891
5892 eprintln!(
5893 "seccomp filter instruction count: unix_sockets_allowed={unix_sockets_allowed} \
5894 -> {count} instructions; budget {SECCOMP_FILTER_INSTRUCTION_BUDGET}; kernel \
5895 per-filter ceiling {BPF_MAXINSNS_CEILING} ({:.1}x headroom); kernel \
5896 cumulative ceiling {SECCOMP_MAX_INSNS_PER_PATH} ({:.1}x headroom)",
5897 BPF_MAXINSNS_CEILING as f64 / count as f64,
5898 SECCOMP_MAX_INSNS_PER_PATH as f64 / count as f64,
5899 );
5900
5901 assert!(
5902 count > 0,
5903 "the exported filter is empty, so nothing was measured — \
5904 `export_bpf` wrote no `struct sock_filter` records at all"
5905 );
5906 assert!(
5907 count <= SECCOMP_FILTER_INSTRUCTION_BUDGET,
5908 "the child's seccomp filter compiles to {count} instructions with \
5909 unix_sockets_allowed={unix_sockets_allowed}, over this project's budget of \
5910 {SECCOMP_FILTER_INSTRUCTION_BUDGET} (the kernel refuses a single filter over \
5911 {BPF_MAXINSNS_CEILING} with EINVAL). Re-measure, decide whether the rule set \
5912 still earns its size, and raise SECCOMP_FILTER_INSTRUCTION_BUDGET \
5913 deliberately — updating \
5914 docs/content/reference/seccomp-filter-instruction-budget.md with the new \
5915 numbers — rather than letting the filter grow into a spawn failure"
5916 );
5917 }
5918 }
5919
5920 #[test]
5929 fn seccomp_filter_rule_shape() {
5930 let allowlist_syscalls =
5931 resolvable_allowlist_syscalls().expect("count the resolvable allowlist syscalls");
5932 assert!(
5933 allowlist_syscalls > 0,
5934 "no SECCOMP_SYSCALL_ALLOWLIST name resolved on this host, so this test would pass \
5935 for the wrong reason"
5936 );
5937
5938 for unix_sockets_allowed in [false, true] {
5939 let filter =
5940 build_seccomp_filter(unix_sockets_allowed).expect("build the seccomp filter");
5941 assert_eq!(
5942 filter.get_act_default().expect("read the default action"),
5943 libseccomp::ScmpAction::Errno(libc::EPERM),
5944 "the filter's default action is no longer a deny with EPERM, which is what \
5945 makes every syscall outside the allowlist unreachable"
5946 );
5947
5948 let pfc =
5949 seccomp_filter_pfc(unix_sockets_allowed).expect("export the filter's pfc");
5950 let denied = denied_socket_domains(unix_sockets_allowed);
5951 let allowed = allowed_socket_domains(unix_sockets_allowed);
5952
5953 let syscall_blocks = pfc.matches("# filter for syscall ").count();
5956 assert_eq!(
5957 syscall_blocks,
5958 1 + allowlist_syscalls + LANDLOCK_SYSCALLS.len(),
5959 "the built filter carries rules for {syscall_blocks} syscalls, not the \
5960 socket rule plus {allowlist_syscalls} allowlist syscalls plus {} Landlock \
5961 syscalls this code adds",
5962 LANDLOCK_SYSCALLS.len()
5963 );
5964
5965 let deny_actions = pfc
5966 .matches(&format!("action ERRNO({});", libc::EACCES))
5967 .count();
5968 assert_eq!(
5969 deny_actions,
5970 denied.len(),
5971 "the built filter carries {deny_actions} EACCES rules, not one per denied \
5972 socket domain ({denied:?}) — an unconditional Allow on `socket` would look \
5973 exactly like this, and would have discarded every domain denial with it"
5974 );
5975
5976 let allow_actions = pfc.matches("action ALLOW;").count();
5979 assert_eq!(
5980 allow_actions,
5981 allowed.len() + allowlist_syscalls + LANDLOCK_SYSCALLS.len(),
5982 "the built filter carries {allow_actions} Allow rules, not one per allowed \
5983 socket domain ({allowed:?}) plus one per allowlist syscall plus one per \
5984 Landlock syscall"
5985 );
5986
5987 for (name, _) in LANDLOCK_SYSCALLS {
5988 assert!(
5989 pfc.contains(&format!("\"{name}\"")),
5990 "{name} has no rule in the built filter, so `apply_landlock_scope` would \
5991 be refused by the filter installed just before it"
5992 );
5993 }
5994
5995 eprintln!(
5996 "seccomp filter rule shape: unix_sockets_allowed={unix_sockets_allowed} -> \
5997 default ERRNO(EPERM); {} socket EACCES rules; {} socket ALLOW rules; \
5998 {allowlist_syscalls} allowlist syscalls; {} Landlock syscalls",
5999 denied.len(),
6000 allowed.len(),
6001 LANDLOCK_SYSCALLS.len(),
6002 );
6003 }
6004 }
6005
6006 #[test]
6013 fn seccomp_per_filter_ceiling() {
6014 if skip_without_seccomp("the kernel's per-filter instruction ceiling") {
6015 return;
6016 }
6017
6018 let over_rules = first_rule_count_above(BPF_MAXINSNS_CEILING)
6019 .expect("bracket the per-filter ceiling");
6020 let (under_filter, under_count) =
6021 probe_filter(over_rules - 1).expect("build the under-ceiling probe filter");
6022 let (over_filter, over_count) =
6023 probe_filter(over_rules).expect("build the over-ceiling probe filter");
6024
6025 assert!(
6026 under_count <= BPF_MAXINSNS_CEILING && over_count > BPF_MAXINSNS_CEILING,
6027 "the two probe filters ({under_count} and {over_count} instructions) do not \
6028 straddle BPF_MAXINSNS_CEILING ({BPF_MAXINSNS_CEILING}), so this test would prove \
6029 nothing about where the ceiling is"
6030 );
6031
6032 let under_outcome = load_in_child(&under_filter);
6033 let over_outcome = load_in_child(&over_filter);
6034
6035 eprintln!(
6036 "seccomp per-filter ceiling: largest filter that loaded = {under_count} \
6037 instructions; smallest that did not = {over_count} instructions, refused with \
6038 errno {} (EINVAL is {}); recorded BPF_MAXINSNS_CEILING = {BPF_MAXINSNS_CEILING}",
6039 over_outcome.err().unwrap_or(0),
6040 libc::EINVAL,
6041 );
6042
6043 assert_eq!(
6044 under_outcome,
6045 Ok(()),
6046 "a {under_count}-instruction filter was refused, below the \
6047 {BPF_MAXINSNS_CEILING}-instruction ceiling this test expects the kernel to hold"
6048 );
6049 assert_eq!(
6050 over_outcome,
6051 Err(libc::EINVAL),
6052 "a {over_count}-instruction filter was not refused with EINVAL, so \
6053 BPF_MAXINSNS_CEILING ({BPF_MAXINSNS_CEILING}) is not where this kernel puts its \
6054 per-filter limit"
6055 );
6056 }
6057
6058 #[test]
6066 fn seccomp_cumulative_ceiling() {
6067 if skip_without_seccomp("the kernel's cumulative filter-chain ceiling") {
6068 return;
6069 }
6070
6071 let (filter, count) = probe_filter(120).expect("build the cumulative probe filter");
6074 let per_filter_cost = count + KERNEL_PER_FILTER_OVERHEAD_INSNS;
6075 let max_loads = SECCOMP_MAX_INSNS_PER_PATH / per_filter_cost + 16;
6078
6079 let mut fds = [0 as libc::c_int; 2];
6080 let rc = unsafe { libc::pipe2(fds.as_mut_ptr(), libc::O_CLOEXEC) };
6082 assert!(rc == 0, "pipe2: {}", io::Error::last_os_error());
6083 let (read_fd, write_fd) =
6085 unsafe { (OwnedFd::from_raw_fd(fds[0]), OwnedFd::from_raw_fd(fds[1])) };
6086
6087 let pid = unsafe { libc::fork() };
6091 assert!(pid >= 0, "fork failed: {}", io::Error::last_os_error());
6092
6093 if pid == 0 {
6094 let mut loads: u32 = 0;
6095 let mut refusal: i32 = 0;
6096 while loads < max_loads as u32 {
6097 match filter.load() {
6098 Ok(()) => loads += 1,
6099 Err(error) => {
6100 refusal = refusal_errno(&error);
6101 break;
6102 }
6103 }
6104 }
6105
6106 let mut report = [0u8; 8];
6107 report[..4].copy_from_slice(&loads.to_ne_bytes());
6108 report[4..].copy_from_slice(&refusal.to_ne_bytes());
6109 unsafe { libc::write(write_fd.as_raw_fd(), report.as_ptr().cast(), report.len()) };
6112 unsafe { libc::_exit(0) }
6114 }
6115
6116 drop(write_fd);
6117 let mut report = [0u8; 8];
6118 std::io::Read::read_exact(&mut std::fs::File::from(read_fd), &mut report)
6119 .expect("read the cumulative probe child's report");
6120 let loads = u32::from_ne_bytes(report[..4].try_into().unwrap()) as usize;
6121 let refusal = i32::from_ne_bytes(report[4..].try_into().unwrap());
6122
6123 let mut status: libc::c_int = 0;
6124 let waited = unsafe { libc::waitpid(pid, &mut status, 0) };
6126 assert!(waited == pid, "waitpid: {}", io::Error::last_os_error());
6127
6128 let stacked = loads * count;
6129 let cumulative_at_refusal = count + loads * per_filter_cost;
6130 let loads_if_counted_classic = SECCOMP_MAX_INSNS_PER_PATH / per_filter_cost;
6131 let shipped = super::seccomp_filter_instruction_count(false)
6132 .expect("measure the shipped filter for comparison");
6133
6134 eprintln!(
6135 "seccomp cumulative ceiling: {loads} loads of a {count}-instruction filter \
6136 succeeded ({stacked} instructions stacked, {cumulative_at_refusal} counted the \
6137 way the kernel counts once the {KERNEL_PER_FILTER_OVERHEAD_INSNS}-instruction \
6138 per-filter overhead is added); load {} was refused with errno {refusal} (ENOMEM \
6139 is {}), against the recorded SECCOMP_MAX_INSNS_PER_PATH of \
6140 {SECCOMP_MAX_INSNS_PER_PATH} — which the exported classic length puts at at most \
6141 {loads_if_counted_classic} loads. The runtime installs ONE filter of {shipped} \
6142 instructions, so reaching this ceiling would take {} of them in a single task.",
6143 loads + 1,
6144 libc::ENOMEM,
6145 SECCOMP_MAX_INSNS_PER_PATH / shipped,
6146 );
6147
6148 assert!(
6149 loads >= 2,
6150 "the kernel refused the filter chain after only {loads} load(s), so this is not \
6151 a cumulative limit and the ENOMEM it returns is not the one being ruled out"
6152 );
6153 assert_eq!(
6154 refusal,
6155 libc::ENOMEM,
6156 "stacking filters past SECCOMP_MAX_INSNS_PER_PATH was refused with errno \
6157 {refusal}, not ENOMEM ({}) — the rule-out in \
6158 docs/content/reference/seccomp-filter-instruction-budget.md rests on ENOMEM \
6159 being what this ceiling returns",
6160 libc::ENOMEM
6161 );
6162 assert!(
6163 loads <= loads_if_counted_classic,
6164 "the kernel took {loads} loads of a {count}-instruction filter — more than the \
6165 {loads_if_counted_classic} that fit under SECCOMP_MAX_INSNS_PER_PATH \
6166 ({SECCOMP_MAX_INSNS_PER_PATH}) even before the conversion to eBPF lengthens \
6167 each one, so this kernel's ceiling is higher than the recorded value"
6168 );
6169 assert!(
6174 loads * 2 > loads_if_counted_classic,
6175 "the kernel refused after {loads} loads of a {count}-instruction filter, far \
6176 short of the {loads_if_counted_classic} that SECCOMP_MAX_INSNS_PER_PATH \
6177 ({SECCOMP_MAX_INSNS_PER_PATH}) implies — the ceiling being measured is not the \
6178 one recorded"
6179 );
6180 assert!(
6181 shipped * 2 < SECCOMP_MAX_INSNS_PER_PATH,
6182 "the shipped filter ({shipped} instructions) is within a factor of two of the \
6183 cumulative ceiling, which would make the rule-out this test supports untrue"
6184 );
6185 }
6186 }
6187}
6188
6189#[cfg(test)]
6194pub(crate) fn build_elf64(interp: Option<&str>, needed: &[&str], runpath: Option<&str>) -> Vec<u8> {
6195 let has_dynamic = !needed.is_empty() || runpath.is_some();
6196
6197 let mut phnum: u16 = 1; if interp.is_some() {
6199 phnum += 1;
6200 }
6201 if has_dynamic {
6202 phnum += 1;
6203 }
6204
6205 let ehsize = 64usize;
6206 let phentsize = 56usize;
6207 let phoff = ehsize;
6208 let body_start = phoff + phentsize * phnum as usize;
6209
6210 let mut strtab: Vec<u8> = vec![0];
6212 let mut needed_offsets = Vec::new();
6213 for name in needed {
6214 needed_offsets.push(strtab.len() as u64);
6215 strtab.extend_from_slice(name.as_bytes());
6216 strtab.push(0);
6217 }
6218 let runpath_offset = runpath.map(|r| {
6219 let off = strtab.len() as u64;
6220 strtab.extend_from_slice(r.as_bytes());
6221 strtab.push(0);
6222 off
6223 });
6224
6225 let mut body: Vec<u8> = Vec::new();
6226
6227 let interp_offset = body_start + body.len();
6228 if let Some(i) = interp {
6229 body.extend_from_slice(i.as_bytes());
6230 body.push(0);
6231 }
6232
6233 while !(body_start + body.len()).is_multiple_of(8) {
6235 body.push(0);
6236 }
6237 let dyn_offset = body_start + body.len();
6238 let num_dyn = needed.len() + runpath.map_or(0, |_| 1) + 3; let dyn_size = num_dyn * 16;
6240 let strtab_offset = dyn_offset + dyn_size;
6241
6242 if has_dynamic {
6243 let mut push_dyn = |tag: u64, val: u64| {
6244 body.extend_from_slice(&tag.to_le_bytes());
6245 body.extend_from_slice(&val.to_le_bytes());
6246 };
6247 for &o in &needed_offsets {
6248 push_dyn(1, o); }
6250 if let Some(ro) = runpath_offset {
6251 push_dyn(29, ro); }
6253 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);
6257 body.extend_from_slice(&strtab);
6258 }
6259
6260 let total_len = body_start + body.len();
6261 let mut out = vec![0u8; total_len];
6262 out[0..4].copy_from_slice(b"\x7fELF");
6263 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 {
6276 phdrs.push((
6277 3,
6278 interp_offset as u64,
6279 interp_offset as u64,
6280 i.len() as u64 + 1,
6281 ));
6282 }
6283 if has_dynamic {
6284 phdrs.push((2, dyn_offset as u64, dyn_offset as u64, dyn_size as u64));
6285 }
6286 for (idx, (p_type, p_offset, p_vaddr, p_filesz)) in phdrs.iter().enumerate() {
6287 let base = phoff + idx * phentsize;
6288 out[base..base + 4].copy_from_slice(&p_type.to_le_bytes());
6289 out[base + 8..base + 16].copy_from_slice(&p_offset.to_le_bytes());
6290 out[base + 16..base + 24].copy_from_slice(&p_vaddr.to_le_bytes());
6291 out[base + 32..base + 40].copy_from_slice(&p_filesz.to_le_bytes());
6292 }
6293
6294 out[body_start..total_len].copy_from_slice(&body);
6295 out
6296}
6297
6298#[cfg(test)]
6299mod tests {
6300 use super::*;
6301 use murmur_artifact::security_warnings::W_SEC_003;
6302
6303 #[test]
6310 fn fd_hygiene_range_starts_above_stdio() {
6311 assert_eq!(
6312 FD_HYGIENE_FIRST_FD, 3,
6313 "fd hygiene must start at 3 — 0/1/2 are the stdio pipes every spawn needs to \
6314 survive execve"
6315 );
6316 }
6317
6318 #[cfg(target_os = "linux")]
6329 #[allow(unsafe_code)]
6330 #[test]
6331 fn close_range_syscall_reports_failure_as_minus_one_and_errno() {
6332 let rc = unsafe {
6336 libc::syscall(
6337 libc::SYS_close_range,
6338 FD_HYGIENE_FIRST_FD,
6339 libc::c_uint::MAX,
6340 libc::c_uint::MAX,
6341 )
6342 };
6343
6344 assert_eq!(rc, -1, "an invalid flag word must not be accepted");
6345 assert_eq!(
6346 std::io::Error::last_os_error().raw_os_error(),
6347 Some(libc::EINVAL),
6348 "the two statements the production path runs on failure must carry the kernel's errno"
6349 );
6350 }
6351
6352 fn sealed_capable() -> crate::namespace_probe::NamespaceReading {
6355 crate::namespace_probe::NamespaceReading {
6356 network: Ok(()),
6357 mount: crate::namespace_probe::MountAttempt::Created,
6358 sealed_root: crate::namespace_probe::MountAttempt::Created,
6359 userns_grant: crate::sealed::UsernsGrant::Withheld,
6360 attachment: crate::sealed::ProfileAttachment::Unconfined,
6361 }
6362 }
6363
6364 fn sealed_incapable() -> crate::namespace_probe::NamespaceReading {
6366 crate::namespace_probe::NamespaceReading {
6367 network: Err(crate::network_namespace::NamespaceAttemptError::Creator(
6368 "the namespace creator did not run".to_string(),
6369 )),
6370 mount: crate::namespace_probe::MountAttempt::NotAttempted,
6371 sealed_root: crate::namespace_probe::MountAttempt::NotAttempted,
6372 userns_grant: crate::sealed::UsernsGrant::Withheld,
6373 attachment: crate::sealed::ProfileAttachment::NotReported,
6374 }
6375 }
6376
6377 #[test]
6381 fn doctor_reads_the_decision_from_the_probe_it_is_handed() {
6382 if !cfg!(target_os = "linux") {
6383 return;
6384 }
6385 let sealed_host = HostProbe::from_parts(Some(true), sealed_capable());
6386 assert_eq!(sealed_host.tier(), EnforcementTier::KernelSealed);
6387 assert_eq!(
6388 crate::containment::applied_mechanism(
6389 &sealed_host,
6390 murmur_artifact::ContainmentClass::Scoped
6391 ),
6392 "landlock+seccomp"
6393 );
6394 assert_eq!(
6395 crate::containment::applied_mechanism(
6396 &sealed_host,
6397 murmur_artifact::ContainmentClass::Sealed
6398 ),
6399 "mountns+pivot_root+landlock+seccomp"
6400 );
6401 }
6402
6403 fn unprobed() -> HostProbe {
6406 HostProbe::from_parts(None, sealed_incapable())
6407 }
6408
6409 #[test]
6413 fn an_unprobed_host_resolves_to_seccomp_only() {
6414 let tier = unprobed().tier();
6415 if cfg!(target_os = "linux") {
6416 assert_eq!(tier, EnforcementTier::KernelSeccompOnly);
6417 } else {
6418 assert_eq!(tier, EnforcementTier::EnvironmentOnly);
6419 }
6420 assert_ne!(tier, EnforcementTier::KernelSealed);
6421 assert_ne!(tier, EnforcementTier::KernelFull);
6422 }
6423
6424 #[test]
6427 fn an_unprobed_host_names_a_blocker() {
6428 assert!(unprobed().sealed_shortfall().is_some());
6429 }
6430
6431 #[test]
6434 fn an_unprobed_host_achieves_less_than_sealed() {
6435 let achieved = crate::containment::achieved_class_for_tier(unprobed().tier());
6436 assert!(achieved < murmur_artifact::ContainmentClass::Sealed);
6437 }
6438
6439 #[test]
6442 fn an_unprobed_host_refuses_a_declared_sealed_floor() {
6443 let probe = unprobed();
6444 let achieved = crate::containment::achieved_class_for_tier(probe.tier());
6445 let error = crate::containment::check_containment_floor(
6446 murmur_artifact::ContainmentClass::Sealed,
6447 achieved,
6448 probe.sealed_shortfall().as_ref(),
6449 false,
6450 None,
6451 )
6452 .expect_err("an unprobed host cannot back a declared sealed floor");
6453 assert!(matches!(
6454 error,
6455 crate::errors::RuntimeError::ContainmentFloorUnmet { .. }
6456 ));
6457 }
6458
6459 #[test]
6463 fn a_probe_that_created_every_namespace_backs_sealed_while_the_setting_reads_one() {
6464 for attachment in [
6465 crate::sealed::ProfileAttachment::Unconfined,
6466 crate::sealed::ProfileAttachment::Other {
6467 profile: "docker-default".to_string(),
6468 },
6469 ] {
6470 let probe = HostProbe::from_parts(
6471 Some(true),
6472 crate::namespace_probe::NamespaceReading {
6473 attachment,
6474 ..sealed_capable()
6475 },
6476 );
6477 if !cfg!(target_os = "linux") {
6478 assert_eq!(probe.tier(), EnforcementTier::EnvironmentOnly);
6479 continue;
6480 }
6481 assert_eq!(probe.tier(), EnforcementTier::KernelSealed);
6482 assert_eq!(probe.sealed_shortfall(), None);
6483 assert!(probe.mount_namespace_usable());
6484 assert_eq!(probe.egress_namespace_failure(), None);
6485 assert_eq!(
6486 crate::temp_dir::temp_mapping(
6487 EnforcementTier::KernelFull,
6488 probe.mount_namespace_usable(),
6489 Path::new("/home/u/w"),
6490 ),
6491 crate::temp_dir::TempMapping::MountNamespace
6492 );
6493 }
6494 }
6495
6496 #[test]
6500 fn every_decision_is_identical_across_every_userns_grant() {
6501 use crate::namespace_probe::{
6502 MountAttempt, MountAttemptError, MountStep, NamespaceReading,
6503 };
6504 let root_refused = NamespaceReading {
6505 sealed_root: MountAttempt::Failed(MountAttemptError::Step {
6506 step: MountStep::PivotRoot,
6507 errno: libc::EPERM,
6508 }),
6509 ..sealed_capable()
6510 };
6511 for base in [sealed_capable(), root_refused, sealed_incapable()] {
6512 let decide = |grant: crate::sealed::UsernsGrant| {
6513 let probe = HostProbe::from_parts(
6514 Some(true),
6515 NamespaceReading {
6516 userns_grant: grant,
6517 ..base.clone()
6518 },
6519 );
6520 (
6521 probe.tier(),
6522 probe.sealed_shortfall().map(|shortfall| shortfall.blocker),
6523 probe.mount_namespace_usable(),
6524 probe
6525 .egress_namespace_failure()
6526 .map(|failure| failure.blocker),
6527 )
6528 };
6529 let expected = decide(crate::sealed::UsernsGrant::Withheld);
6530 for grant in crate::sealed::UsernsGrant::ALL {
6531 assert_eq!(decide(*grant), expected, "{grant:?} over {base:?}");
6532 }
6533 }
6534 }
6535
6536 #[test]
6540 fn a_live_reading_answers_every_question_from_its_own_attempts() {
6541 let probe = HostProbe::probe();
6542 let namespaces = probe.namespaces().clone();
6543 let linux = cfg!(target_os = "linux");
6544 assert_eq!(
6545 probe.tier() == EnforcementTier::KernelSealed,
6546 probe.sealed_shortfall().is_none(),
6547 "{namespaces:?}"
6548 );
6549 assert_eq!(
6550 probe.mount_namespace_usable(),
6551 linux
6552 && namespaces.network.is_ok()
6553 && namespaces.mount == crate::namespace_probe::MountAttempt::Created
6554 );
6555 assert_eq!(
6556 probe.egress_namespace_failure().is_some(),
6557 linux && namespaces.network.is_err()
6558 );
6559 }
6560
6561 #[test]
6562 fn tier_from_probe_non_linux_is_always_environment_only() {
6563 for landlock in [None, Some(true), Some(false)] {
6564 for namespaces in [sealed_capable(), sealed_incapable()] {
6565 assert_eq!(
6566 tier_from_probe(false, landlock, &namespaces),
6567 EnforcementTier::EnvironmentOnly
6568 );
6569 }
6570 }
6571 }
6572
6573 #[test]
6574 fn tier_from_probe_linux_fully_enforced_is_kernel_full() {
6575 assert_eq!(
6576 tier_from_probe(true, Some(true), &sealed_incapable()),
6577 EnforcementTier::KernelFull
6578 );
6579 }
6580
6581 #[test]
6582 fn tier_from_probe_linux_partially_enforced_is_seccomp_only() {
6583 assert_eq!(
6584 tier_from_probe(true, Some(false), &sealed_incapable()),
6585 EnforcementTier::KernelSeccompOnly
6586 );
6587 }
6588
6589 #[test]
6590 fn tier_from_probe_linux_no_probe_result_is_seccomp_only() {
6591 assert_eq!(
6592 tier_from_probe(true, None, &sealed_incapable()),
6593 EnforcementTier::KernelSeccompOnly
6594 );
6595 }
6596
6597 #[test]
6601 fn tier_from_probe_reaches_sealed_only_when_every_precondition_holds() {
6602 use crate::namespace_probe::{
6603 MountAttempt, MountAttemptError, MountStep, NamespaceReading,
6604 };
6605
6606 assert_eq!(
6607 tier_from_probe(true, Some(true), &sealed_capable()),
6608 EnforcementTier::KernelSealed
6609 );
6610
6611 let network_refused = NamespaceReading {
6613 network: Err(crate::network_namespace::NamespaceAttemptError::Step {
6614 step: crate::network_namespace::CreationStep::IdentityMaps,
6615 errno: libc::EPERM,
6616 }),
6617 mount: MountAttempt::NotAttempted,
6618 sealed_root: MountAttempt::NotAttempted,
6619 ..sealed_capable()
6620 };
6621 assert_eq!(
6622 tier_from_probe(true, Some(true), &network_refused),
6623 EnforcementTier::KernelFull
6624 );
6625
6626 for step in [
6628 MountStep::JoinSession,
6629 MountStep::UnshareUserMount,
6630 MountStep::IdentityMaps,
6631 MountStep::MakePrivateNested,
6632 MountStep::Tmpfs,
6633 MountStep::PivotPrepare,
6634 MountStep::PivotRoot,
6635 MountStep::DetachOldRoot,
6636 ] {
6637 let reading = NamespaceReading {
6638 sealed_root: MountAttempt::Failed(MountAttemptError::Step {
6639 step,
6640 errno: libc::EPERM,
6641 }),
6642 ..sealed_capable()
6643 };
6644 assert_eq!(
6645 tier_from_probe(true, Some(true), &reading),
6646 EnforcementTier::KernelFull,
6647 "a sealed root refused at {step:?} must fall back to KernelFull"
6648 );
6649 }
6650
6651 let tmp_refused = NamespaceReading {
6653 mount: MountAttempt::Failed(MountAttemptError::Step {
6654 step: MountStep::MakePrivate,
6655 errno: libc::EACCES,
6656 }),
6657 ..sealed_capable()
6658 };
6659 assert_eq!(
6660 tier_from_probe(true, Some(true), &tmp_refused),
6661 EnforcementTier::KernelSealed
6662 );
6663
6664 assert_eq!(
6667 tier_from_probe(true, Some(false), &sealed_capable()),
6668 EnforcementTier::KernelSeccompOnly
6669 );
6670 assert_eq!(
6671 tier_from_probe(true, None, &sealed_capable()),
6672 EnforcementTier::KernelSeccompOnly
6673 );
6674
6675 assert_eq!(
6677 tier_from_probe(false, Some(true), &sealed_capable()),
6678 EnforcementTier::EnvironmentOnly
6679 );
6680 }
6681
6682 #[test]
6686 fn the_tmp_mapping_follows_the_mount_attempt() {
6687 use crate::namespace_probe::{
6688 MountAttempt, MountAttemptError, MountStep, NamespaceReading,
6689 };
6690 use crate::temp_dir::{temp_mapping, NotMappedReason, TempMapping};
6691 if !cfg!(target_os = "linux") {
6692 return;
6693 }
6694 let workdir = Path::new("/home/u/w");
6695 let refused = |step, errno| MountAttempt::Failed(MountAttemptError::Step { step, errno });
6696
6697 let mount_refused = HostProbe::from_parts(
6698 Some(true),
6699 NamespaceReading {
6700 mount: refused(MountStep::MakePrivate, libc::EACCES),
6701 sealed_root: refused(MountStep::MakePrivateNested, libc::EACCES),
6702 ..sealed_capable()
6703 },
6704 );
6705 assert_eq!(mount_refused.tier(), EnforcementTier::KernelFull);
6706 assert_eq!(
6707 temp_mapping(
6708 mount_refused.tier(),
6709 mount_refused.mount_namespace_usable(),
6710 workdir
6711 ),
6712 TempMapping::NotMapped(NotMappedReason::NoMountNamespace)
6713 );
6714
6715 let root_refused = HostProbe::from_parts(
6716 Some(true),
6717 NamespaceReading {
6718 sealed_root: refused(MountStep::PivotRoot, libc::EPERM),
6719 ..sealed_capable()
6720 },
6721 );
6722 assert_eq!(root_refused.tier(), EnforcementTier::KernelFull);
6723 assert_eq!(
6724 temp_mapping(
6725 root_refused.tier(),
6726 root_refused.mount_namespace_usable(),
6727 workdir
6728 ),
6729 TempMapping::MountNamespace
6730 );
6731
6732 let network_refused = HostProbe::from_parts(
6733 Some(true),
6734 NamespaceReading {
6735 network: Err(crate::network_namespace::NamespaceAttemptError::Step {
6736 step: crate::network_namespace::CreationStep::Unshare,
6737 errno: libc::EPERM,
6738 }),
6739 mount: MountAttempt::NotAttempted,
6740 sealed_root: MountAttempt::NotAttempted,
6741 ..sealed_capable()
6742 },
6743 );
6744 assert!(!network_refused.mount_namespace_usable());
6745 assert!(matches!(
6746 temp_mapping(
6747 network_refused.tier(),
6748 network_refused.mount_namespace_usable(),
6749 workdir
6750 ),
6751 TempMapping::NotMapped(_)
6752 ));
6753 assert!(network_refused.egress_namespace_failure().is_some());
6754 }
6755
6756 #[test]
6758 fn ruleset_landlock_abi_is_the_kernel_abi_capped_at_the_target_on_landlock_tiers() {
6759 for tier in [EnforcementTier::KernelFull, EnforcementTier::KernelSealed] {
6760 for (kernel, built) in [
6761 (Some(1), Some(1)),
6762 (Some(2), Some(2)),
6763 (Some(3), Some(3)),
6764 (Some(8), Some(3)),
6765 (Some(u8::MAX), Some(3)),
6766 ] {
6767 assert_eq!(
6768 ruleset_landlock_abi(kernel, tier),
6769 built,
6770 "kernel ABI {kernel:?} on {tier:?}"
6771 );
6772 }
6773 }
6774 }
6775
6776 #[test]
6778 fn ruleset_landlock_abi_is_none_without_a_kernel_abi_or_a_landlock_tier() {
6779 for tier in [EnforcementTier::KernelFull, EnforcementTier::KernelSealed] {
6780 assert_eq!(ruleset_landlock_abi(None, tier), None);
6781 assert_eq!(ruleset_landlock_abi(Some(0), tier), None);
6782 }
6783 for tier in [
6784 EnforcementTier::KernelSeccompOnly,
6785 EnforcementTier::EnvironmentOnly,
6786 ] {
6787 for kernel in [None, Some(0), Some(1), Some(3), Some(8)] {
6788 assert_eq!(
6789 ruleset_landlock_abi(kernel, tier),
6790 None,
6791 "kernel ABI {kernel:?} on {tier:?}"
6792 );
6793 }
6794 }
6795 }
6796
6797 #[test]
6800 fn ruleset_landlock_abi_reaches_shell_enforcement_from_the_probe() {
6801 let negative = unprobed();
6802 for abi in [None, Some(1), Some(8)] {
6803 let enforcement = ShellEnforcement::resolve_declared(
6804 &CapabilityPolicy::default(),
6805 murmur_artifact::ContainmentClass::Advisory,
6806 negative.clone().with_landlock_kernel_abi(abi),
6807 )
6808 .unwrap()
6809 .with_test_session_dirs();
6810 assert_eq!(
6811 enforcement.landlock_abi,
6812 ruleset_landlock_abi(abi, enforcement.tier),
6813 "kernel ABI {abi:?}"
6814 );
6815 assert_eq!(enforcement.landlock_abi, None);
6817 }
6818 if cfg!(target_os = "linux") {
6819 let full = HostProbe::from_parts(Some(true), sealed_incapable());
6820 let enforcement = ShellEnforcement::resolve_declared(
6821 &CapabilityPolicy::default(),
6822 murmur_artifact::ContainmentClass::Scoped,
6823 full.with_landlock_kernel_abi(Some(8)),
6824 )
6825 .unwrap()
6826 .with_test_session_dirs();
6827 assert_eq!(enforcement.tier, EnforcementTier::KernelFull);
6828 assert_eq!(enforcement.landlock_abi, Some(LANDLOCK_TARGET_ABI));
6829 }
6830 }
6831
6832 #[test]
6834 fn landlock_doctor_lines_name_the_kernel_and_ruleset_abi() {
6835 if !cfg!(target_os = "linux") {
6836 for abi in [None, Some(1), Some(8)] {
6837 assert_eq!(
6838 landlock_doctor_lines(abi),
6839 vec!["kernel ABI: n/a (Landlock is a Linux mechanism)".to_string()]
6840 );
6841 }
6842 return;
6843 }
6844
6845 let none = landlock_doctor_lines(None).join("\n");
6846 assert!(none.contains("no Landlock on this host"), "{none}");
6847 assert_eq!(landlock_doctor_lines(Some(0)), landlock_doctor_lines(None));
6848
6849 let v1 = landlock_doctor_lines(Some(1)).join("\n");
6850 assert!(v1.contains("kernel ABI: 1"), "{v1}");
6851 assert!(v1.contains("ruleset ABI: 1"), "{v1}");
6852 assert!(v1.contains("EXDEV"), "{v1}");
6853 assert!(v1.contains("Invalid cross-device link"), "{v1}");
6854 assert!(v1.contains("Linux 5.19 (Landlock ABI v2)"), "{v1}");
6855 assert!(v1.contains("uv"), "{v1}");
6856
6857 let v2 = landlock_doctor_lines(Some(2)).join("\n");
6858 assert!(v2.contains("kernel ABI: 2"), "{v2}");
6859 assert!(v2.contains("ruleset ABI: 2"), "{v2}");
6860 assert!(!v2.contains("EXDEV"), "{v2}");
6861 assert!(
6862 v2.contains("not mediated below Landlock ABI v3 (Linux 6.2)"),
6863 "{v2}"
6864 );
6865
6866 for kernel in [3, 8] {
6867 let lines = landlock_doctor_lines(Some(kernel));
6868 assert_eq!(
6869 lines,
6870 vec![
6871 format!("kernel ABI: {kernel}"),
6872 "ruleset ABI: 3 (the kernel's, capped at 3)".to_string(),
6873 ]
6874 );
6875 }
6876 }
6877
6878 #[test]
6881 fn applied_tier_is_unconfined_on_every_host_for_the_unconfined_stance() {
6882 use murmur_artifact::ContainmentClass;
6883 for host_tier in [
6884 EnforcementTier::KernelSealed,
6885 EnforcementTier::KernelFull,
6886 EnforcementTier::KernelSeccompOnly,
6887 EnforcementTier::EnvironmentOnly,
6888 ] {
6889 assert_eq!(
6890 applied_tier(host_tier, ContainmentClass::Unconfined),
6891 EnforcementTier::Unconfined,
6892 "{host_tier:?}"
6893 );
6894 }
6895 assert_eq!(tier_warning(EnforcementTier::Unconfined, false), None);
6896 assert_eq!(
6897 ruleset_landlock_abi(Some(4), EnforcementTier::Unconfined),
6898 None
6899 );
6900 }
6901
6902 #[test]
6906 fn applied_tier_never_installs_a_composed_root_for_a_weaker_declaration() {
6907 use murmur_artifact::ContainmentClass;
6908
6909 assert_eq!(
6910 applied_tier(EnforcementTier::KernelSealed, ContainmentClass::Sealed),
6911 EnforcementTier::KernelSealed
6912 );
6913 for declared in [ContainmentClass::Advisory, ContainmentClass::Scoped] {
6914 assert_eq!(
6915 applied_tier(EnforcementTier::KernelSealed, declared),
6916 EnforcementTier::KernelFull,
6917 "declaring {declared} must not opt a capsule into the composed root"
6918 );
6919 }
6920
6921 for host_tier in [
6924 EnforcementTier::KernelFull,
6925 EnforcementTier::KernelSeccompOnly,
6926 EnforcementTier::EnvironmentOnly,
6927 ] {
6928 for declared in [
6929 ContainmentClass::Advisory,
6930 ContainmentClass::Scoped,
6931 ContainmentClass::Sealed,
6932 ] {
6933 assert_eq!(applied_tier(host_tier, declared), host_tier);
6934 }
6935 }
6936 }
6937
6938 #[test]
6939 fn sealed_bind_dirs_are_whole_directories_outside_the_fixed_runtime_paths() {
6940 use murmur_artifact::{InterpreterRuntimeDir, InterpreterRuntimeGrant};
6941
6942 let policy = CapabilityPolicy {
6943 shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
6944 binary: "python3".to_string(),
6945 dirs: vec![
6946 InterpreterRuntimeDir {
6948 path: "/usr/lib/python3.11".to_string(),
6949 list_dir: true,
6950 },
6951 InterpreterRuntimeDir {
6952 path: "/opt/py/lib".to_string(),
6953 list_dir: true,
6954 },
6955 InterpreterRuntimeDir {
6957 path: "/opt/py/lib".to_string(),
6958 list_dir: false,
6959 },
6960 InterpreterRuntimeDir {
6961 path: "relative/lib".to_string(),
6962 list_dir: false,
6963 },
6964 ],
6965 }],
6966 ..CapabilityPolicy::default()
6967 };
6968 let exec_allow = vec![
6969 PathBuf::from("/usr/bin/python3"),
6970 PathBuf::from("/opt/toolchain/bin/cc"),
6971 ];
6972
6973 assert_eq!(
6974 resolve_sealed_bind_dirs(&exec_allow, &policy),
6975 vec![
6976 PathBuf::from("/opt/toolchain/bin"),
6977 PathBuf::from("/opt/py/lib")
6978 ]
6979 );
6980 }
6981
6982 #[test]
6987 fn host_path_execute_roots_include_interpreter_runtime_dirs_and_staged_trees() {
6988 use crate::host_paths::{check_execute_roots, HostPath, HostPathAccess, HostPathMechanism};
6989 let policy = CapabilityPolicy {
6990 shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
6991 binary: "python3".to_string(),
6992 dirs: vec![murmur_artifact::InterpreterRuntimeDir {
6993 path: "/nonexistent-host-path-test/interp".to_string(),
6994 list_dir: true,
6995 }],
6996 }],
6997 shell_staged_runtime: vec![murmur_artifact::StagedRuntimeGrant {
6998 binary: "python3".to_string(),
6999 source_path: "/nonexistent-host-path-test/opt/py".to_string(),
7000 pin: "pin-1".to_string(),
7001 }],
7002 ..CapabilityPolicy::default()
7003 };
7004 let roots = execute_roots_from(&[], &policy);
7005 let dirs: Vec<&Path> = roots.iter().map(|root| root.dir.as_path()).collect();
7006 assert_eq!(
7007 dirs,
7008 [
7009 Path::new("/nonexistent-host-path-test/interp"),
7010 Path::new("/nonexistent-host-path-test/opt/py"),
7011 ]
7012 );
7013 let read_write = |path: &str| HostPath {
7014 declared: path.to_string(),
7015 path: PathBuf::from(path),
7016 access: HostPathAccess::ReadWrite,
7017 credential_locations: Vec::new(),
7018 };
7019
7020 let inside_interpreter = [read_write("/nonexistent-host-path-test/interp/cache")];
7021 for mechanism in [HostPathMechanism::Landlock, HostPathMechanism::Bind] {
7022 assert!(check_execute_roots(&inside_interpreter, &roots, mechanism).is_err());
7023 }
7024
7025 let holding_staged = [read_write("/nonexistent-host-path-test/opt")];
7026 assert!(check_execute_roots(&holding_staged, &roots, HostPathMechanism::Landlock).is_err());
7027 assert_eq!(
7028 check_execute_roots(&holding_staged, &roots, HostPathMechanism::Bind).unwrap(),
7029 [PathBuf::from("/nonexistent-host-path-test/opt/py")]
7030 );
7031 }
7032
7033 #[test]
7038 fn staged_runtime_dirs_keep_fixed_path_overlaps_and_drop_only_duplicates_and_relatives() {
7039 use murmur_artifact::StagedRuntimeGrant;
7040
7041 let grant = |binary: &str, source: &str| StagedRuntimeGrant {
7042 binary: binary.to_string(),
7043 source_path: source.to_string(),
7044 pin: "pin-1".to_string(),
7045 };
7046 let policy = CapabilityPolicy {
7047 shell_staged_runtime: vec![
7048 grant("python3", "/opt/py"),
7049 grant("perl", "/usr/lib/perl5"),
7052 grant("python3.12", "/opt/py"),
7054 grant("ruby", "relative/ruby"),
7055 ],
7056 ..CapabilityPolicy::default()
7057 };
7058
7059 assert_eq!(
7060 resolve_staged_runtime_dirs(&policy),
7061 vec![PathBuf::from("/opt/py"), PathBuf::from("/usr/lib/perl5")]
7062 );
7063 }
7064
7065 #[test]
7069 fn every_staged_runtime_dir_also_gets_a_listable_landlock_grant() {
7070 use murmur_artifact::StagedRuntimeGrant;
7071
7072 let policy = CapabilityPolicy {
7073 shell_staged_runtime: vec![StagedRuntimeGrant {
7074 binary: "python3".to_string(),
7075 source_path: "/opt/py".to_string(),
7076 pin: "pin-1".to_string(),
7077 }],
7078 ..CapabilityPolicy::default()
7079 };
7080
7081 let dirs = resolve_staged_runtime_dirs(&policy);
7082 let grants = resolve_staged_runtime_landlock_grants(&policy);
7083
7084 assert_eq!(
7085 grants,
7086 vec![LandlockGrant {
7087 path: PathBuf::from("/opt/py"),
7088 list_dir: true,
7089 executable: true,
7090 pinned: None,
7091 }],
7092 "a staged tree is walked by the runtime that uses it, so it must be listable",
7093 );
7094 assert_eq!(
7095 grants
7096 .iter()
7097 .map(|grant| grant.path.clone())
7098 .collect::<Vec<_>>(),
7099 dirs,
7100 "the bound set and the granted set must not drift apart",
7101 );
7102 }
7103
7104 #[test]
7105 fn every_sealed_runtime_path_gets_a_listable_non_executable_landlock_grant() {
7106 let grants = resolve_sealed_runtime_landlock_grants(EnforcementTier::KernelSealed);
7107
7108 assert_eq!(
7109 grants,
7110 crate::sealed::SEALED_RUNTIME_PATHS
7111 .iter()
7112 .map(|path| LandlockGrant {
7113 path: PathBuf::from(path),
7114 list_dir: true,
7115 executable: false,
7116 pinned: None,
7117 })
7118 .collect::<Vec<_>>(),
7119 "the composed root binds exactly these, so exactly these must be enumerable — \
7120 the bound set and the granted set must not drift apart",
7121 );
7122 assert!(
7123 grants.iter().all(|grant| grant.list_dir),
7124 "a runtime tree an interpreter walks is useless without getdents64",
7125 );
7126 assert!(
7127 grants.iter().all(|grant| !grant.executable),
7128 "Execute is the exec allowlist: granting it over /usr, /bin and /sbin would let a \
7129 sealed capsule run every binary the host ships, whatever shell.allow says",
7130 );
7131 }
7132
7133 #[test]
7134 fn sealed_runtime_landlock_grants_are_sealed_tier_only() {
7135 for tier in [
7136 EnforcementTier::KernelFull,
7137 EnforcementTier::KernelSeccompOnly,
7138 EnforcementTier::EnvironmentOnly,
7139 ] {
7140 assert!(
7141 resolve_sealed_runtime_landlock_grants(tier).is_empty(),
7142 "{tier:?} has no composed root, so granting ReadDir on SEALED_RUNTIME_PATHS \
7143 would enumerate the real host filesystem",
7144 );
7145 }
7146 }
7147
7148 #[test]
7153 fn shell_enforcement_grants_sealed_runtime_paths_only_when_sealed_applies() {
7154 let policy = CapabilityPolicy::default();
7155 let usr = PathBuf::from("/usr");
7156
7157 let scoped = ShellEnforcement::resolve_declared(
7158 &policy,
7159 murmur_artifact::ContainmentClass::Scoped,
7160 HostProbe::probe(),
7161 )
7162 .expect("an empty policy resolves")
7163 .with_test_session_dirs();
7164 assert!(
7165 !scoped.landlock_grants.iter().any(|grant| grant.path == usr),
7166 "a scoped capsule runs Landlock over the real host filesystem — /usr must stay \
7167 unenumerable there",
7168 );
7169
7170 let sealed = ShellEnforcement::resolve_declared(
7171 &policy,
7172 murmur_artifact::ContainmentClass::Sealed,
7173 HostProbe::probe(),
7174 )
7175 .expect("an empty policy resolves")
7176 .with_test_session_dirs();
7177 let granted: Vec<&LandlockGrant> = sealed
7178 .landlock_grants
7179 .iter()
7180 .filter(|grant| {
7181 crate::sealed::SEALED_RUNTIME_PATHS.contains(&grant.path.to_str().unwrap_or(""))
7182 })
7183 .collect();
7184 if sealed.tier == EnforcementTier::KernelSealed {
7185 assert_eq!(
7186 granted.len(),
7187 crate::sealed::SEALED_RUNTIME_PATHS.len(),
7188 "a sealed session must carry one listable grant per bound runtime path",
7189 );
7190 assert!(granted
7191 .iter()
7192 .all(|grant| grant.list_dir && !grant.executable));
7193 } else {
7194 assert!(
7195 granted.is_empty(),
7196 "this host fell back to {:?}, where there is no composed root to enumerate",
7197 sealed.tier,
7198 );
7199 }
7200 }
7201
7202 #[test]
7203 fn every_sealed_etc_path_gets_a_non_executable_landlock_grant() {
7204 let grants = resolve_sealed_etc_landlock_grants(EnforcementTier::KernelSealed);
7205
7206 assert_eq!(
7207 grants,
7208 crate::sealed::SEALED_ETC_PATHS
7209 .iter()
7210 .map(|entry| LandlockGrant {
7211 path: PathBuf::from(entry.path),
7212 list_dir: entry.directory,
7213 executable: false,
7214 pinned: None,
7215 })
7216 .collect::<Vec<_>>(),
7217 "the composed root binds exactly these /etc entries, so exactly these must be \
7218 readable — the bound set and the granted set must not drift apart",
7219 );
7220 assert!(
7221 grants.iter().all(|grant| !grant.executable),
7222 "Execute is the exec allowlist, and nothing in /etc is a program a manifest asked to \
7223 run — /etc/alternatives is a directory of symlinks into /usr/bin",
7224 );
7225
7226 let listable: Vec<&str> = grants
7230 .iter()
7231 .filter(|grant| grant.list_dir)
7232 .map(|grant| grant.path.to_str().unwrap())
7233 .collect();
7234 assert_eq!(
7235 listable,
7236 vec![
7237 "/etc/ld.so.conf.d",
7238 "/etc/alternatives",
7239 "/etc/ssl",
7240 "/etc/pki",
7241 "/etc/ca-certificates",
7242 "/etc/terminfo",
7243 ],
7244 "ReadDir belongs to exactly the directory entries: os.listdir('/etc/ssl/certs') and \
7245 the c_rehash symlink lookup need it, and a regular file has no meaningful ReadDir",
7246 );
7247 }
7248
7249 #[test]
7250 fn sealed_etc_landlock_grants_are_sealed_tier_only() {
7251 for tier in [
7252 EnforcementTier::KernelFull,
7253 EnforcementTier::KernelSeccompOnly,
7254 EnforcementTier::EnvironmentOnly,
7255 ] {
7256 assert!(
7257 resolve_sealed_etc_landlock_grants(tier).is_empty(),
7258 "{tier:?} has no composed root, so these rules would hand out the real host's \
7259 /etc — its trust store, resolv.conf and account databases",
7260 );
7261 }
7262 }
7263
7264 #[test]
7269 fn shell_enforcement_grants_sealed_etc_paths_only_when_sealed_applies() {
7270 let policy = CapabilityPolicy::default();
7271 let is_etc_grant = |grant: &LandlockGrant| {
7272 crate::sealed::SEALED_ETC_PATHS
7273 .iter()
7274 .any(|entry| Path::new(entry.path) == grant.path)
7275 };
7276
7277 let scoped = ShellEnforcement::resolve_declared(
7278 &policy,
7279 murmur_artifact::ContainmentClass::Scoped,
7280 HostProbe::probe(),
7281 )
7282 .expect("an empty policy resolves")
7283 .with_test_session_dirs();
7284 assert!(
7285 !scoped.landlock_grants.iter().any(is_etc_grant),
7286 "a scoped capsule runs Landlock over the real host filesystem — /etc/shadow's \
7287 neighbours must not become readable there",
7288 );
7289
7290 let sealed = ShellEnforcement::resolve_declared(
7291 &policy,
7292 murmur_artifact::ContainmentClass::Sealed,
7293 HostProbe::probe(),
7294 )
7295 .expect("an empty policy resolves")
7296 .with_test_session_dirs();
7297 let granted: Vec<&LandlockGrant> = sealed
7298 .landlock_grants
7299 .iter()
7300 .filter(|grant| is_etc_grant(grant))
7301 .collect();
7302 if sealed.tier == EnforcementTier::KernelSealed {
7303 assert_eq!(
7304 granted.len(),
7305 crate::sealed::SEALED_ETC_PATHS.len(),
7306 "a sealed session must carry one read grant per bound /etc entry",
7307 );
7308 assert!(granted.iter().all(|grant| !grant.executable));
7309 } else {
7310 assert!(
7311 granted.is_empty(),
7312 "this host fell back to {:?}, where there is no composed root and /etc is the \
7313 host's own",
7314 sealed.tier,
7315 );
7316 }
7317 }
7318
7319 #[test]
7320 fn resolve_network_allowlist_ips_empty_list_is_empty() {
7321 let ips = resolve_network_allowlist_ips(&[]).unwrap();
7322 assert!(ips.is_empty());
7323 }
7324
7325 #[test]
7326 fn resolve_network_allowlist_ips_resolves_localhost_to_loopback() {
7327 let ips = resolve_network_allowlist_ips(&["localhost".to_string()]).unwrap();
7328 assert!(
7329 !ips.is_empty(),
7330 "localhost should resolve to at least one address"
7331 );
7332 assert!(
7333 ips.iter().all(|ip| ip.is_loopback()),
7334 "every resolved localhost address should be loopback, got {ips:?}"
7335 );
7336 }
7337
7338 #[test]
7339 fn resolve_network_allowlist_ips_rejects_invalid_host_syntax_cleanly() {
7340 let error = resolve_network_allowlist_ips(&["".to_string()]).unwrap_err();
7341 assert!(!error.is_empty());
7342 }
7343
7344 #[test]
7349 fn resolve_network_allowlist_ips_skips_unresolvable_host_without_failing() {
7350 let ips =
7351 resolve_network_allowlist_ips(&["definitely-not-a-real-host.invalid".to_string()])
7352 .expect("an unresolvable host must not be a hard error");
7353 assert!(
7354 ips.is_empty(),
7355 "an unresolvable host should contribute no IPs, got {ips:?}"
7356 );
7357 }
7358
7359 #[test]
7362 fn resolve_network_allowlist_ips_keeps_resolvable_hosts_when_another_is_unresolvable() {
7363 let ips = resolve_network_allowlist_ips(&[
7364 "definitely-not-a-real-host.invalid".to_string(),
7365 "localhost".to_string(),
7366 ])
7367 .expect("a mix of resolvable and unresolvable hosts must not be a hard error");
7368
7369 assert!(
7370 !ips.is_empty(),
7371 "localhost's addresses should survive alongside an unresolvable host"
7372 );
7373 assert!(
7374 ips.iter().all(|ip| ip.is_loopback()),
7375 "only localhost should have contributed addresses, got {ips:?}"
7376 );
7377 }
7378
7379 #[test]
7380 fn resolve_network_allowlist_ips_dedupes_across_hosts() {
7381 let ips =
7384 resolve_network_allowlist_ips(&["localhost".to_string(), "localhost:8080".to_string()])
7385 .unwrap();
7386 let mut sorted = ips.clone();
7387 sorted.sort();
7388 sorted.dedup();
7389 assert_eq!(
7390 ips.len(),
7391 sorted.len(),
7392 "resolved IPs should already be deduplicated"
7393 );
7394 }
7395
7396 #[test]
7397 fn environment_only_has_no_resolved_ips_and_environment_only_tier() {
7398 let enforcement = ShellEnforcement::environment_only();
7399 assert_eq!(enforcement.tier, EnforcementTier::EnvironmentOnly);
7400 assert!(enforcement.network_allow_ips.is_empty());
7401 }
7402
7403 #[cfg(any(target_os = "linux", target_os = "macos"))]
7407 #[test]
7408 fn nproc_bound_without_a_scope_is_headroom_over_a_live_count() {
7409 match ShellEnforcement::environment_only()
7410 .with_test_session_dirs()
7411 .nproc_bound()
7412 {
7413 crate::resources::NprocBound::Headroom { baseline } => assert!(
7414 baseline > 0,
7415 "the test process is owned by this uid, so the count cannot be zero"
7416 ),
7417 other => panic!("no scope must give headroom, got {other:?}"),
7418 }
7419 }
7420
7421 #[cfg(target_os = "linux")]
7424 #[test]
7425 fn nproc_bound_without_a_scope_is_measured_on_each_call() {
7426 let _lock = crate::resources::test_support::host_threads_lock();
7427 let enforcement = ShellEnforcement::environment_only().with_test_session_dirs();
7428 let baseline = |bound| match bound {
7429 crate::resources::NprocBound::Headroom { baseline } => baseline,
7430 other => panic!("no scope must give headroom, got {other:?}"),
7431 };
7432
7433 let first = baseline(enforcement.nproc_bound());
7434 let holders = crate::resources::test_support::HeldThreads::start(200);
7435 let second = baseline(enforcement.nproc_bound());
7436 drop(holders);
7437
7438 assert!(
7439 second > first,
7440 "200 threads started between the calls must raise the baseline: {first} then {second}"
7441 );
7442 }
7443
7444 #[cfg(target_os = "linux")]
7446 #[test]
7447 fn nproc_bound_with_a_scope_leaves_rlimit_nproc_to_pids_max() {
7448 if !crate::cgroup::cgroup_delegation_available() {
7449 crate::runtime_err!(
7450 "[SKIP-HOST] nproc_bound_with_a_scope_leaves_rlimit_nproc_to_pids_max: this host \
7451 cannot delegate a cgroup v2 scope"
7452 );
7453 return;
7454 }
7455 let temp = tempfile::tempdir().unwrap();
7456 let limits = crate::resources::HostResourceLimits::default();
7457 let scope = crate::cgroup::prepare_scope(true, &limits, "ses_nproc_bound", temp.path())
7458 .expect("this host delegates a cgroup scope")
7459 .scope;
7460 let enforcement = ShellEnforcement::environment_only()
7461 .with_test_session_dirs()
7462 .with_host_bounding(scope, None);
7463
7464 assert_eq!(
7465 enforcement.nproc_bound(),
7466 crate::resources::NprocBound::CgroupPidsMax
7467 );
7468 }
7469
7470 #[cfg(target_os = "linux")]
7477 #[test]
7478 fn a_scoped_capsule_keeps_spawning_after_the_host_adds_threads() {
7479 const TEST: &str = "a_scoped_capsule_keeps_spawning_after_the_host_adds_threads";
7480 if !crate::cgroup::cgroup_delegation_available() {
7481 crate::runtime_err!("[SKIP-HOST] {TEST}: this host cannot delegate a cgroup v2 scope");
7482 return;
7483 }
7484 let _lock = crate::resources::test_support::host_threads_lock();
7485
7486 let temp = tempfile::tempdir().unwrap();
7487 let limits = crate::resources::HostResourceLimits {
7488 max_processes: 32,
7489 max_open_files: 256,
7490 ..crate::resources::HostResourceLimits::default()
7491 };
7492 let scope = crate::cgroup::prepare_scope(true, &limits, "ses_nproc_race", temp.path())
7493 .expect("this host delegates a cgroup scope")
7494 .scope;
7495 assert!(
7496 scope.is_some(),
7497 "a required scope on Linux is always created"
7498 );
7499 let node_available = crate::sandbox::resolve_invoked_binary_path("node") != "node";
7500 let mut shell_allow = vec!["bash".to_string()];
7501 if node_available {
7502 shell_allow.push("node".to_string());
7503 }
7504 let policy = CapabilityPolicy {
7505 shell_allow,
7506 resources: limits,
7507 ..CapabilityPolicy::default()
7508 };
7509 let mut enforcement = ShellEnforcement::environment_only().with_test_session_dirs();
7510 enforcement.resource_limits = limits;
7511 let enforcement = enforcement.with_host_bounding(scope, None);
7512
7513 let holders = crate::resources::test_support::HeldThreads::start(400);
7514
7515 let result = crate::shell::execute_shell(
7516 "bash",
7517 &[
7518 "-c",
7519 "for i in $(seq 1 40); do sleep 0.2 & done; wait; ulimit -Hu; ulimit -Hn",
7520 ],
7521 &[],
7522 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
7523 &policy,
7524 &enforcement,
7525 )
7526 .expect("bash must run");
7527 assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
7528 assert_eq!(result.resource_limit_hit, None);
7529 let (_, own_hard) = crate::resources::test_support::own_rlimit_nproc();
7530 let lines: Vec<&str> = result.stdout.lines().collect();
7531 assert_eq!(
7532 lines,
7533 [
7534 crate::resources::test_support::rendered_rlimit(own_hard).as_str(),
7535 "256"
7536 ],
7537 "RLIMIT_NPROC is inherited unchanged and RLIMIT_NOFILE is the declared ceiling",
7538 );
7539
7540 if node_available {
7541 let script = "const { spawn } = require('child_process');\n\
7542 const runs = Array.from({ length: 8 }, () => new Promise((resolve) => {\n\
7543 const child = spawn(process.execPath, ['-e', 'process.exit(0)']);\n\
7544 child.once('error', (error) => resolve(error.message));\n\
7545 child.once('exit', (code) => resolve(code === 0 ? null : `exit ${code}`));\n\
7546 }));\n\
7547 Promise.all(runs).then((outcomes) => {\n\
7548 const failures = outcomes.filter((outcome) => outcome !== null);\n\
7549 if (failures.length > 0) { console.error(failures.join('\\n')); process.exit(1); }\n\
7550 });\n";
7551 let result = crate::shell::execute_shell(
7552 "node",
7553 &["-e", script],
7554 &[],
7555 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
7556 &policy,
7557 &enforcement,
7558 )
7559 .expect("node must run");
7560 assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
7561 } else {
7562 crate::runtime_err!("[SKIP-HOST] {TEST}: no node on PATH, skipping the node half");
7563 }
7564
7565 drop(holders);
7566 }
7567
7568 #[cfg(target_os = "linux")]
7572 #[test]
7573 fn without_a_scope_the_nproc_baseline_is_measured_per_spawn() {
7574 const TEST: &str = "without_a_scope_the_nproc_baseline_is_measured_per_spawn";
7575 let _lock = crate::resources::test_support::host_threads_lock();
7576
7577 let temp = tempfile::tempdir().unwrap();
7578 let policy = CapabilityPolicy {
7579 shell_allow: vec!["bash".to_string()],
7580 resources: crate::resources::HostResourceLimits {
7581 max_processes: 64,
7582 ..crate::resources::HostResourceLimits::default()
7583 },
7584 ..CapabilityPolicy::default()
7585 };
7586 let mut enforcement = ShellEnforcement::environment_only().with_test_session_dirs();
7587 enforcement.resource_limits = policy.resources;
7588
7589 let before = crate::resources::uid_task_count().expect("Linux can count the uid's tasks");
7590 let (_, own_hard) = crate::resources::test_support::own_rlimit_nproc();
7591 if own_hard != libc::RLIM_INFINITY && own_hard < before + 664 {
7592 crate::runtime_err!(
7593 "[SKIP-HOST] {TEST}: the inherited hard RLIMIT_NPROC is {own_hard}, too close to \
7594 the uid's {before} tasks for 300 more threads to show in a child's ceiling"
7595 );
7596 return;
7597 }
7598
7599 let hard_nproc = || -> u64 {
7600 let result = crate::shell::execute_shell(
7601 "bash",
7602 &["-c", "ulimit -Hu"],
7603 &[],
7604 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
7605 &policy,
7606 &enforcement,
7607 )
7608 .expect("bash must run");
7609 assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
7610 result.stdout.trim().parse().expect("a finite ceiling")
7611 };
7612
7613 let holders = crate::resources::test_support::HeldThreads::start(300);
7614 let held = hard_nproc();
7615 assert!(
7616 held >= before + 64 + 150,
7617 "the ceiling must count the holder threads started after the enforcement was \
7618 resolved: {held} with {before} tasks before them"
7619 );
7620
7621 drop(holders);
7622 let released = hard_nproc();
7623 assert!(
7624 released < held,
7625 "the ceiling must drop once the holders exit: {released} after, {held} while held"
7626 );
7627 }
7628
7629 fn make_executable(path: &Path, contents: &str) {
7630 use std::os::unix::fs::PermissionsExt;
7631 std::fs::write(path, contents).unwrap();
7632 let mut perms = std::fs::metadata(path).unwrap().permissions();
7633 perms.set_mode(0o755);
7634 std::fs::set_permissions(path, perms).unwrap();
7635 }
7636
7637 fn bootstrap_log_contents(workdir: &Path) -> String {
7638 std::fs::read_to_string(workdir.join("logs").join("bootstrap.log")).unwrap_or_default()
7639 }
7640
7641 #[test]
7644 fn resolve_exec_allowlist_resolves_bare_name_via_path_dirs() {
7645 let temp = tempfile::tempdir().unwrap();
7646 let bin_dir = temp.path().join("bin");
7647 std::fs::create_dir(&bin_dir).unwrap();
7648 let tool = bin_dir.join("mytool");
7649 make_executable(&tool, "#!/bin/sh\nexit 0\n");
7650
7651 let resolved = resolve_exec_allowlist_in(&["mytool".to_string()], &[bin_dir]);
7652 assert_eq!(resolved, vec![std::fs::canonicalize(&tool).unwrap()]);
7653 }
7654
7655 #[test]
7656 fn resolve_exec_allowlist_skips_entries_that_do_not_resolve() {
7657 let temp = tempfile::tempdir().unwrap();
7658 let resolved = resolve_exec_allowlist_in(
7659 &["definitely-not-a-real-binary".to_string(), String::new()],
7660 &[temp.path().to_path_buf()],
7661 );
7662 assert!(
7663 resolved.is_empty(),
7664 "unresolvable entries must shrink (not fail) the allow set: {resolved:?}"
7665 );
7666 }
7667
7668 #[test]
7669 fn resolve_exec_allowlist_ignores_non_executable_path_matches() {
7670 let temp = tempfile::tempdir().unwrap();
7671 std::fs::write(temp.path().join("mytool"), "not executable").unwrap();
7672 let resolved =
7673 resolve_exec_allowlist_in(&["mytool".to_string()], &[temp.path().to_path_buf()]);
7674 assert!(resolved.is_empty());
7675 }
7676
7677 #[test]
7678 fn resolve_exec_allowlist_canonicalizes_path_matches_through_symlinks() {
7679 let temp = tempfile::tempdir().unwrap();
7680 let real = temp.path().join("real-tool");
7681 make_executable(&real, "#!/bin/sh\nexit 0\n");
7682 let bin_dir = temp.path().join("bin");
7683 std::fs::create_dir(&bin_dir).unwrap();
7684 std::os::unix::fs::symlink(&real, bin_dir.join("link-tool")).unwrap();
7685
7686 let resolved = resolve_exec_allowlist_in(&["link-tool".to_string()], &[bin_dir]);
7687 assert_eq!(resolved, vec![std::fs::canonicalize(&real).unwrap()]);
7688 }
7689
7690 #[test]
7693 fn resolve_invoked_binary_path_resolves_bare_name_via_path_dirs() {
7694 let temp = tempfile::tempdir().unwrap();
7695 let bin_dir = temp.path().join("bin");
7696 std::fs::create_dir(&bin_dir).unwrap();
7697 let tool = bin_dir.join("mytool");
7698 make_executable(&tool, "#!/bin/sh\nexit 0\n");
7699
7700 let resolved = resolve_invoked_binary_path_in("mytool", &[bin_dir]);
7701 assert_eq!(
7702 resolved,
7703 std::fs::canonicalize(&tool)
7704 .unwrap()
7705 .to_string_lossy()
7706 .into_owned(),
7707 "a resolvable name must report the canonical absolute path, not the bare name"
7708 );
7709 }
7710
7711 #[test]
7712 fn resolve_invoked_binary_path_falls_back_to_bare_name_when_unresolvable() {
7713 let temp = tempfile::tempdir().unwrap();
7714 let path_dirs = [temp.path().to_path_buf()];
7715 let resolved = resolve_invoked_binary_path_in("definitely-not-a-real-binary", &path_dirs);
7716 assert_eq!(
7717 resolved, "definitely-not-a-real-binary",
7718 "an unresolvable name must fall back to the bare name, never error"
7719 );
7720 assert_eq!(resolve_invoked_binary_path_in("", &[]), "");
7721 }
7722
7723 #[test]
7724 fn resolve_invoked_binary_path_takes_the_first_path_match() {
7725 let temp = tempfile::tempdir().unwrap();
7726 let first = temp.path().join("first");
7727 let second = temp.path().join("second");
7728 std::fs::create_dir(&first).unwrap();
7729 std::fs::create_dir(&second).unwrap();
7730 make_executable(&first.join("mytool"), "#!/bin/sh\nexit 0\n");
7731 make_executable(&second.join("mytool"), "#!/bin/sh\nexit 1\n");
7732
7733 let resolved = resolve_invoked_binary_path_in("mytool", &[first.clone(), second]);
7734 assert_eq!(
7735 resolved,
7736 std::fs::canonicalize(first.join("mytool"))
7737 .unwrap()
7738 .to_string_lossy()
7739 .into_owned(),
7740 "reporting must match execvp: the first PATH hit is the one that ran"
7741 );
7742 }
7743
7744 #[test]
7745 fn resolve_invoked_binary_path_ignores_non_executable_path_matches() {
7746 let temp = tempfile::tempdir().unwrap();
7747 std::fs::write(temp.path().join("mytool"), "not executable").unwrap();
7748 assert_eq!(
7749 resolve_invoked_binary_path_in("mytool", &[temp.path().to_path_buf()]),
7750 "mytool"
7751 );
7752 }
7753
7754 #[test]
7755 fn resolve_invoked_binary_path_canonicalizes_a_path_entry_through_symlinks() {
7756 let temp = tempfile::tempdir().unwrap();
7757 let real = temp.path().join("real-tool");
7758 make_executable(&real, "#!/bin/sh\nexit 0\n");
7759 let link = temp.path().join("link-tool");
7760 std::os::unix::fs::symlink(&real, &link).unwrap();
7761
7762 assert_eq!(
7763 resolve_invoked_binary_path_in(&link.to_string_lossy(), &[]),
7764 std::fs::canonicalize(&real)
7765 .unwrap()
7766 .to_string_lossy()
7767 .into_owned()
7768 );
7769 }
7770
7771 #[test]
7774 fn parse_elf_extracts_interp_needed_and_runpath() {
7775 let bytes = build_elf64(
7776 Some("/lib64/ld-linux-x86-64.so.2"),
7777 &["libc.so.6", "libm.so.6"],
7778 Some("$ORIGIN/../lib:/opt/custom/lib"),
7779 );
7780 let deps = parse_elf_dependencies(bytes.as_slice()).expect("well-formed ELF64 must parse");
7781 assert_eq!(deps.interp.as_deref(), Some("/lib64/ld-linux-x86-64.so.2"));
7782 assert_eq!(
7783 deps.needed,
7784 vec!["libc.so.6".to_string(), "libm.so.6".to_string()]
7785 );
7786 assert_eq!(
7787 deps.runpaths,
7788 vec!["$ORIGIN/../lib".to_string(), "/opt/custom/lib".to_string()],
7789 "DT_RUNPATH must be split on ':' with empty parts dropped"
7790 );
7791 }
7792
7793 #[test]
7794 fn parse_elf_static_binary_contributes_only_itself() {
7795 let bytes = build_elf64(None, &[], None);
7797 let deps =
7798 parse_elf_dependencies(bytes.as_slice()).expect("a static ELF64 is still valid ELF");
7799 assert_eq!(deps, ElfDependencies::default());
7800 }
7801
7802 #[test]
7803 fn parse_elf_rejects_non_elf_and_non_elf64() {
7804 assert!(parse_elf_dependencies(&b"not an elf at all, way too short"[..]).is_none());
7805 let mut elf32 = build_elf64(Some("/lib/ld.so"), &["libc.so.6"], None);
7807 elf32[4] = 1;
7808 assert!(
7809 parse_elf_dependencies(elf32.as_slice()).is_none(),
7810 "32-bit ELF is not a binary this runtime execs and must not parse"
7811 );
7812 }
7813
7814 #[cfg(target_os = "linux")]
7818 #[test]
7819 #[allow(clippy::print_stdout)]
7820 fn a_file_read_by_position_parses_exactly_as_its_bytes_in_memory() {
7821 let mut binaries = Vec::new();
7822 for name in ["python3", "perl", "bash", "git"] {
7823 match find_on_path(name) {
7824 Some(path) => binaries.push(std::fs::canonicalize(path).unwrap()),
7825 None => println!("[SKIP] no {name} on PATH"),
7826 }
7827 }
7828 let mut images: std::collections::BTreeSet<PathBuf> =
7829 resolve_landlock_grants(&binaries).into_iter().collect();
7830 let dynload = std::process::Command::new("python3")
7831 .args(["-I", "-S", "-c", "import sys; print('\\n'.join(sys.path))"])
7832 .output()
7833 .ok()
7834 .map(|output| String::from_utf8_lossy(&output.stdout).into_owned())
7835 .unwrap_or_default();
7836 for dir in dynload.lines().filter(|line| line.ends_with("lib-dynload")) {
7837 for entry in std::fs::read_dir(dir).into_iter().flatten().flatten() {
7838 images.insert(entry.path());
7839 }
7840 }
7841 let compare = |path: &Path| {
7842 let Ok(bytes) = std::fs::read(path) else {
7843 return;
7844 };
7845 let file = ElfFile::open_host(path).unwrap();
7846 assert_eq!(
7847 parse_elf_dependencies(bytes.as_slice()),
7848 parse_elf_dependencies(&file),
7849 "{}",
7850 path.display()
7851 );
7852 assert_eq!(
7853 elf_dynamic_symbol_names(bytes.as_slice()),
7854 elf_dynamic_symbol_names(&file),
7855 "{}",
7856 path.display()
7857 );
7858 assert_eq!(
7859 elf_is_shared_object(bytes.as_slice()),
7860 elf_is_shared_object(&file),
7861 "{}",
7862 path.display()
7863 );
7864 };
7865 for path in &images {
7866 compare(path);
7867 }
7868 println!("compared {} host images", images.len());
7869
7870 let temp = tempfile::tempdir().unwrap();
7873 let mut variants = 0;
7874 for binary in &binaries {
7875 let bytes = std::fs::read(binary).unwrap();
7876 let mut cuts = vec![
7877 0,
7878 1,
7879 17,
7880 18,
7881 63,
7882 64,
7883 100,
7884 4096,
7885 bytes.len() / 2,
7886 bytes.len() - 1,
7887 ];
7888 for field in [32usize, 40] {
7889 if let Some(offset) = read_u64(&bytes, field, true) {
7890 cuts.push(offset as usize + 30);
7891 }
7892 }
7893 let mut copies: Vec<Vec<u8>> = cuts
7894 .into_iter()
7895 .filter(|cut| *cut <= bytes.len())
7896 .map(|cut| bytes[..cut].to_vec())
7897 .collect();
7898 for field in [32usize, 40] {
7899 let mut moved = bytes.clone();
7900 moved[field..field + 8].copy_from_slice(&(bytes.len() as u64 - 8).to_le_bytes());
7901 copies.push(moved);
7902 }
7903 let mut big_endian = bytes.clone();
7904 big_endian[5] = 2;
7905 copies.push(big_endian);
7906 for copy in copies {
7907 let path = temp.path().join(format!("variant-{variants}"));
7908 std::fs::write(&path, ©).unwrap();
7909 compare(&path);
7910 variants += 1;
7911 }
7912 }
7913 println!("compared {variants} malformed images");
7914 }
7915
7916 #[cfg(target_os = "linux")]
7919 #[test]
7920 #[allow(clippy::print_stdout)]
7921 fn resolving_a_closure_reads_no_binary_in_full() {
7922 let (_temp, root) = crate::interpreter_runtime::tests::canonical_tempdir();
7923 let Some(stand_in) = crate::interpreter_runtime::tests::dash_copy(&root, "padded") else {
7924 return;
7925 };
7926 crate::interpreter_runtime::tests::pad_with_zeros(&stand_in, 64 << 20);
7927 let unpadded = resolve_landlock_grants(&[std::fs::canonicalize("/bin/dash").unwrap()]);
7928 let before = crate::interpreter_runtime::tests::thread_rchar();
7929 let grants = resolve_landlock_grants(std::slice::from_ref(&stand_in));
7930 let read = crate::interpreter_runtime::tests::thread_rchar() - before;
7931 assert_eq!(grants.len(), unpadded.len(), "{grants:?}");
7932 assert!(grants.contains(&stand_in));
7933 assert!(
7934 read < 8 * 1024 * 1024,
7935 "resolving the closure read {read} bytes"
7936 );
7937 println!("resolving the padded stand-in's closure read {read} bytes");
7938 }
7939
7940 #[test]
7943 fn resolve_soname_finds_via_search_dirs_and_runpath_origin() {
7944 let temp = tempfile::tempdir().unwrap();
7945 let origin = temp.path().join("app");
7946 std::fs::create_dir(&origin).unwrap();
7947 let libs = temp.path().join("libs");
7948 std::fs::create_dir(&libs).unwrap();
7949 std::fs::write(libs.join("libc.so.6"), "x").unwrap();
7950
7951 assert_eq!(
7953 resolve_soname("libc.so.6", &origin, &[], std::slice::from_ref(&libs)),
7954 Some(std::fs::canonicalize(libs.join("libc.so.6")).unwrap())
7955 );
7956 assert_eq!(
7958 resolve_soname("libmissing.so", &origin, &[], std::slice::from_ref(&libs)),
7959 None
7960 );
7961
7962 let origin_lib = origin.join("lib");
7964 std::fs::create_dir(&origin_lib).unwrap();
7965 std::fs::write(origin_lib.join("libfoo.so"), "x").unwrap();
7966 assert_eq!(
7967 resolve_soname("libfoo.so", &origin, &["$ORIGIN/lib".to_string()], &[]),
7968 Some(std::fs::canonicalize(origin_lib.join("libfoo.so")).unwrap())
7969 );
7970 }
7971
7972 #[test]
7975 fn resolve_landlock_grants_derives_interp_and_needed_closure() {
7976 let temp = tempfile::tempdir().unwrap();
7977 let bin_dir = temp.path().join("bin");
7978 let lib_dir = temp.path().join("lib");
7979 std::fs::create_dir(&bin_dir).unwrap();
7980 std::fs::create_dir(&lib_dir).unwrap();
7981
7982 let interp = bin_dir.join("ld-fake.so");
7984 std::fs::write(&interp, "not really elf").unwrap();
7985 std::fs::write(lib_dir.join("libfake.so.1"), "not really elf").unwrap();
7986
7987 let prog = bin_dir.join("prog");
7989 let elf = build_elf64(Some(interp.to_str().unwrap()), &["libfake.so.1"], None);
7990 std::fs::write(&prog, &elf).unwrap();
7991
7992 let grants =
7993 resolve_landlock_grants_in(std::slice::from_ref(&prog), std::slice::from_ref(&lib_dir));
7994
7995 let expect = |p: PathBuf| std::fs::canonicalize(p).unwrap();
7996 assert!(
7997 grants.contains(&expect(prog)),
7998 "the binary itself must be granted: {grants:?}"
7999 );
8000 assert!(
8001 grants.contains(&expect(interp)),
8002 "the ELF interpreter must be granted: {grants:?}"
8003 );
8004 assert!(
8005 grants.contains(&expect(lib_dir.join("libfake.so.1"))),
8006 "each resolved DT_NEEDED library must be granted: {grants:?}"
8007 );
8008 }
8009
8010 #[test]
8011 fn resolve_landlock_grants_skips_unresolvable_deps_and_missing_binaries() {
8012 let temp = tempfile::tempdir().unwrap();
8013 let bin_dir = temp.path().join("bin");
8014 std::fs::create_dir(&bin_dir).unwrap();
8015
8016 let prog = bin_dir.join("prog");
8018 std::fs::write(
8019 &prog,
8020 build_elf64(Some("/definitely/not/here.so"), &["libnope.so"], None),
8021 )
8022 .unwrap();
8023 let missing = bin_dir.join("does-not-exist");
8024
8025 let grants =
8026 resolve_landlock_grants_in(&[prog.clone(), missing], std::slice::from_ref(&bin_dir));
8027
8028 assert_eq!(grants, vec![std::fs::canonicalize(&prog).unwrap()]);
8031 }
8032
8033 fn interp_grant(binary: &str, dirs: &[(&str, bool)]) -> InterpreterRuntimeGrant {
8036 InterpreterRuntimeGrant {
8037 binary: binary.to_string(),
8038 dirs: dirs
8039 .iter()
8040 .map(|(path, list_dir)| murmur_artifact::InterpreterRuntimeDir {
8041 path: path.to_string(),
8042 list_dir: *list_dir,
8043 })
8044 .collect(),
8045 }
8046 }
8047
8048 #[test]
8049 fn resolve_interpreter_runtime_grants_copies_list_dir_verbatim() {
8050 let grants = resolve_interpreter_runtime_grants(&[interp_grant(
8053 "python3",
8054 &[
8055 ("/usr/lib/python3.11", true),
8056 ("/usr/lib/python3.11/lib-dynload", false),
8057 ],
8058 )]);
8059
8060 assert_eq!(
8061 grants,
8062 vec![
8063 LandlockGrant {
8064 path: PathBuf::from("/usr/lib/python3.11"),
8065 list_dir: true,
8066 executable: true,
8067 pinned: None,
8068 },
8069 LandlockGrant {
8070 path: PathBuf::from("/usr/lib/python3.11/lib-dynload"),
8071 list_dir: false,
8072 executable: true,
8073 pinned: None,
8074 },
8075 ]
8076 );
8077 }
8078
8079 #[test]
8080 fn resolve_interpreter_runtime_grants_flattens_multiple_grants_and_needs_no_existing_path() {
8081 let grants = resolve_interpreter_runtime_grants(&[
8084 interp_grant("python3", &[("/opt/py/nonexistent", false)]),
8085 interp_grant("ruby", &[("/opt/rb/also-nonexistent", true)]),
8086 ]);
8087
8088 assert_eq!(
8089 grants,
8090 vec![
8091 LandlockGrant {
8092 path: PathBuf::from("/opt/py/nonexistent"),
8093 list_dir: false,
8094 executable: true,
8095 pinned: None,
8096 },
8097 LandlockGrant {
8098 path: PathBuf::from("/opt/rb/also-nonexistent"),
8099 list_dir: true,
8100 executable: true,
8101 pinned: None,
8102 },
8103 ]
8104 );
8105 assert!(resolve_interpreter_runtime_grants(&[]).is_empty());
8106 }
8107
8108 #[test]
8109 fn non_listable_files_marks_every_closure_file_non_enumerable() {
8110 let wrapped = LandlockGrant::non_listable_files(vec![
8113 PathBuf::from("/lib/x86_64-linux-gnu/libc.so.6"),
8114 PathBuf::from("/usr/bin/bash"),
8115 ]);
8116 assert!(wrapped.iter().all(|grant| !grant.list_dir), "{wrapped:?}");
8117 assert_eq!(
8118 wrapped.iter().map(|g| g.path.clone()).collect::<Vec<_>>(),
8119 vec![
8120 PathBuf::from("/lib/x86_64-linux-gnu/libc.so.6"),
8121 PathBuf::from("/usr/bin/bash"),
8122 ]
8123 );
8124 }
8125
8126 fn write_entrypoint(dir: &Path, name: &str, head: &[u8]) -> PathBuf {
8129 let path = dir.join(name);
8130 std::fs::write(&path, head).unwrap();
8131 path
8132 }
8133
8134 fn installed(path: &Path, workdir: &Path) -> NativeEntrypoint {
8136 NativeEntrypoint::derive(path, &std::fs::read(path).unwrap(), workdir)
8137 }
8138
8139 #[test]
8140 fn shebang_interpreter_reads_the_absolute_interpreter_and_nothing_else() {
8141 let cases: &[(&[u8], Option<&str>)] = &[
8142 (b"#!/bin/sh\necho hi\n", Some("/bin/sh")),
8143 (b"#! /usr/bin/python3 -u\n", Some("/usr/bin/python3")),
8144 (b"#!/usr/bin/env python3\n", Some("/usr/bin/env")),
8146 (b"#!sh\n", None),
8147 (b"#!\n", None),
8148 (b"\x7fELF\x02\x01\x01", None),
8149 (b"", None),
8150 ];
8151 for (head, expected) in cases {
8152 assert_eq!(
8153 shebang_interpreter(head),
8154 expected.map(PathBuf::from),
8155 "{:?}",
8156 String::from_utf8_lossy(head)
8157 );
8158 }
8159
8160 let mut long = b"#!".to_vec();
8162 long.extend(std::iter::repeat_n(b' ', SHEBANG_READ_LIMIT as usize));
8163 long.extend(b"/bin/sh\n");
8164 assert_eq!(shebang_interpreter(&long), None);
8165 }
8166
8167 #[test]
8170 fn for_native_entrypoint_adds_the_pinned_entrypoint_and_its_interpreter_only() {
8171 let dir = tempfile::tempdir().unwrap();
8172 let entrypoint = write_entrypoint(dir.path(), "tool", b"#!/bin/sh\ncat\n");
8173 let existing = LandlockGrant {
8174 path: PathBuf::from("/usr/bin/git"),
8175 list_dir: false,
8176 executable: true,
8177 pinned: None,
8178 };
8179 let base = ShellEnforcement {
8180 tier: EnforcementTier::KernelFull,
8181 landlock_grants: vec![existing.clone()],
8182 ..ShellEnforcement::environment_only().with_test_session_dirs()
8183 };
8184
8185 let native = base.for_native_entrypoint(&installed(&entrypoint, dir.path()), None);
8186
8187 assert_eq!(native.landlock_grants[0], existing);
8188 let added: Vec<&LandlockGrant> = native.landlock_grants[1..].iter().collect();
8189 let identity = FileIdentity::of(&std::fs::metadata(&entrypoint).unwrap());
8190 assert!(
8191 added
8192 .iter()
8193 .any(|grant| grant.path == entrypoint && grant.pinned == Some(identity)),
8194 "the entrypoint must be granted, pinned to its inode: {added:?}"
8195 );
8196 let canonical_sh = std::fs::canonicalize("/bin/sh").unwrap();
8197 assert!(
8198 added.iter().any(|grant| grant.path == canonical_sh),
8199 "/bin/sh must be granted: {added:?}"
8200 );
8201 assert!(
8202 added
8203 .iter()
8204 .all(|grant| grant.executable && !grant.list_dir),
8205 "{added:?}"
8206 );
8207 assert!(added.iter().all(|grant| !grant.path.is_dir()), "{added:?}");
8209 assert!(native.sealed_bind_dirs.is_empty(), "not sealed: no binds");
8210 assert!(
8211 Arc::ptr_eq(&native.session_netns, &base.session_netns),
8212 "a native spawn joins the session's own namespace"
8213 );
8214 assert_eq!(
8215 base.landlock_grants.len(),
8216 1,
8217 "the session's grants are untouched"
8218 );
8219 }
8220
8221 #[test]
8224 fn for_native_entrypoint_sets_exactly_the_given_store_and_the_session_holds_none() {
8225 let dir = tempfile::tempdir().unwrap();
8226 let entrypoint = installed(
8227 &write_entrypoint(
8228 dir.path(),
8229 "tool",
8230 b"#!/bin/sh
8231",
8232 ),
8233 dir.path(),
8234 );
8235 let store = dir.path().join("state").join("config-capsule");
8236 for tier in [
8237 EnforcementTier::KernelFull,
8238 EnforcementTier::KernelSealed,
8239 EnforcementTier::KernelSeccompOnly,
8240 EnforcementTier::EnvironmentOnly,
8241 ] {
8242 let session = ShellEnforcement {
8243 tier,
8244 ..ShellEnforcement::environment_only().with_test_session_dirs()
8245 };
8246 let native = session.for_native_entrypoint(&entrypoint, Some(&store));
8247 assert_eq!(
8248 native.native_state_dir.as_deref(),
8249 Some(store.as_path()),
8250 "{tier:?}"
8251 );
8252 assert_eq!(session.native_state_dir, None, "{tier:?}");
8253 assert_eq!(
8254 session
8255 .for_native_entrypoint(&entrypoint, None)
8256 .native_state_dir,
8257 None,
8258 "{tier:?}"
8259 );
8260 assert!(
8262 native
8263 .landlock_grants
8264 .iter()
8265 .all(|grant| !grant.path.starts_with(&store)),
8266 "{tier:?}"
8267 );
8268 assert!(
8269 native
8270 .sealed_bind_dirs
8271 .iter()
8272 .all(|bind| !bind.starts_with(&store)),
8273 "{tier:?}"
8274 );
8275 }
8276 }
8277
8278 #[test]
8281 fn for_native_entrypoint_binds_an_interpreter_outside_the_runtime_paths_when_sealed() {
8282 let dir = tempfile::tempdir().unwrap();
8283 let sealed = ShellEnforcement {
8284 tier: EnforcementTier::KernelSealed,
8285 ..ShellEnforcement::environment_only().with_test_session_dirs()
8286 };
8287
8288 let system = installed(
8289 &write_entrypoint(dir.path(), "system", b"#!/bin/sh\n"),
8290 dir.path(),
8291 );
8292 assert!(sealed
8293 .for_native_entrypoint(&system, None)
8294 .sealed_bind_dirs
8295 .is_empty());
8296
8297 let custom = installed(
8298 &write_entrypoint(dir.path(), "custom", b"#!/opt/tools/bin/run\n"),
8299 dir.path(),
8300 );
8301 assert_eq!(
8302 sealed.for_native_entrypoint(&custom, None).sealed_bind_dirs,
8303 vec![PathBuf::from("/opt/tools/bin")]
8304 );
8305
8306 let scoped = ShellEnforcement {
8307 tier: EnforcementTier::KernelFull,
8308 ..ShellEnforcement::environment_only().with_test_session_dirs()
8309 };
8310 assert!(scoped
8311 .for_native_entrypoint(&custom, None)
8312 .sealed_bind_dirs
8313 .is_empty());
8314 }
8315
8316 #[test]
8320 fn a_rewritten_entrypoint_gains_no_grant() {
8321 let dir = tempfile::tempdir().unwrap();
8322 let entrypoint = write_entrypoint(dir.path(), "tool", b"#!/bin/sh\ncat\n");
8323 let native = installed(&entrypoint, dir.path());
8324
8325 for tier in [EnforcementTier::KernelFull, EnforcementTier::KernelSealed] {
8326 let base = ShellEnforcement {
8327 tier,
8328 ..ShellEnforcement::environment_only().with_test_session_dirs()
8329 };
8330 let before = base.for_native_entrypoint(&native, None);
8331
8332 std::fs::write(&entrypoint, b"#!/opt/elsewhere/bin/run\n").unwrap();
8333 let after = base.for_native_entrypoint(&native, None);
8334 std::fs::write(&entrypoint, b"#!/bin/sh\ncat\n").unwrap();
8335
8336 assert_eq!(after.landlock_grants, before.landlock_grants, "{tier:?}");
8337 assert_eq!(after.sealed_bind_dirs, before.sealed_bind_dirs, "{tier:?}");
8338 assert!(
8339 after
8340 .landlock_grants
8341 .iter()
8342 .all(|grant| !grant.path.starts_with("/opt/elsewhere")),
8343 "{tier:?}: {:?}",
8344 after.landlock_grants
8345 );
8346 }
8347 }
8348
8349 #[test]
8352 fn the_entrypoint_closure_does_not_reach_into_the_workdir() {
8353 let temp = tempfile::tempdir().unwrap();
8354 let workdir = temp.path().join("workdir");
8355 let tool_dir = workdir.join("tools").join("tool");
8356 std::fs::create_dir_all(&tool_dir).unwrap();
8357 let system = temp.path().join("system");
8358 std::fs::create_dir(&system).unwrap();
8359 std::fs::write(system.join("libsys.so"), "x").unwrap();
8360 let outside = temp.path().join("outside");
8361 std::fs::create_dir(&outside).unwrap();
8362 std::fs::write(outside.join("libpicked.so"), "x").unwrap();
8363
8364 std::fs::write(
8366 tool_dir.join("libplanted.so"),
8367 build_elf64(
8368 None,
8369 &[outside.join("libpicked.so").to_str().unwrap()],
8370 None,
8371 ),
8372 )
8373 .unwrap();
8374 std::fs::write(outside.join("host-binary"), "x").unwrap();
8377 std::os::unix::fs::symlink(outside.join("host-binary"), tool_dir.join("liblinked.so"))
8378 .unwrap();
8379 let image = build_elf64(
8380 None,
8381 &["libplanted.so", "liblinked.so", "libsys.so"],
8382 Some("$ORIGIN"),
8383 );
8384 let entrypoint = tool_dir.join("tool");
8385 std::fs::write(&entrypoint, &image).unwrap();
8386
8387 let native = NativeEntrypoint::derive_in(
8388 &entrypoint,
8389 &image,
8390 &workdir,
8391 std::slice::from_ref(&system),
8392 );
8393
8394 let sys = std::fs::canonicalize(system.join("libsys.so")).unwrap();
8395 assert_eq!(native.closure, vec![sys]);
8396 }
8397
8398 #[cfg(target_os = "linux")]
8401 #[test]
8402 fn a_replaced_entrypoint_loses_its_pinned_grant() {
8403 let accessible = tempfile::tempdir().unwrap();
8404 let session = accessible
8405 .path()
8406 .join(".murmur")
8407 .join("ses_native_entrypoint");
8408 std::fs::create_dir_all(&session).unwrap();
8409 let entrypoint = write_entrypoint(&session, "tool", b"#!/bin/sh\n");
8410 let native = installed(&entrypoint, accessible.path());
8411 let grants = ShellEnforcement {
8412 tier: EnforcementTier::KernelFull,
8413 ..ShellEnforcement::environment_only().with_test_session_dirs()
8414 }
8415 .for_native_entrypoint(&native, None)
8416 .landlock_grants;
8417 let open = |grants: &[LandlockGrant]| {
8418 linux_enforce::open_landlock_fds(linux_enforce::LandlockFdRequest {
8419 landlock_grants: grants,
8420 ..linux_enforce::LandlockFdRequest::workdir_only(accessible.path())
8421 })
8422 .unwrap()
8423 .grant_count()
8424 };
8425 let installed_count = open(&grants);
8426 std::fs::hard_link(&entrypoint, session.join("installed")).unwrap();
8429
8430 std::fs::remove_file(&entrypoint).unwrap();
8431 std::os::unix::fs::symlink("/bin/sh", &entrypoint).unwrap();
8432 assert_eq!(open(&grants), installed_count - 1, "symlink swapped in");
8433
8434 std::fs::remove_file(&entrypoint).unwrap();
8435 std::fs::write(&entrypoint, b"#!/bin/sh\n").unwrap();
8436 assert_eq!(open(&grants), installed_count - 1, "new file, new inode");
8437 }
8438
8439 #[test]
8453 fn capsule_device_grants_are_exactly_three_devices_with_only_dev_null_writable() {
8454 let listed: Vec<(&str, bool)> = CAPSULE_DEVICE_GRANTS
8455 .iter()
8456 .map(|device| (device.path, device.writable))
8457 .collect();
8458 assert_eq!(
8459 listed,
8460 vec![
8461 ("/dev/null", true),
8462 ("/dev/zero", false),
8463 ("/dev/urandom", false),
8464 ],
8465 "the capsule device set is fixed at exactly these three paths and these access \
8466 levels. /dev/null must stay writable — Python's subprocess.DEVNULL opens it O_RDWR \
8467 and a shell `2>/dev/null` redirect opens it O_WRONLY, so a read-only grant breaks \
8468 both. Widening this list is allowed only on demonstrated workload failure, and the \
8469 failure gets recorded alongside the widening (see the manual acceptance procedure on \
8470 the diagnostics reference page)."
8471 );
8472 }
8473
8474 #[test]
8475 fn capsule_device_grants_exclude_the_devices_deliberately_left_out() {
8476 for excluded in [
8481 "/dev/random",
8482 "/dev/full",
8483 "/dev/tty",
8484 "/dev/console",
8485 "/dev/mem",
8486 "/dev/sda",
8487 "/dev",
8488 ] {
8489 assert!(
8490 !CAPSULE_DEVICE_GRANTS
8491 .iter()
8492 .any(|device| device.path == excluded),
8493 "{excluded} must not be in the capsule device set — it is denied by the ordinary \
8494 no-matching-rule path, and adding it here would be the only way to change that"
8495 );
8496 }
8497 }
8498
8499 #[test]
8500 fn capsule_device_grants_name_absolute_paths_under_dev() {
8501 for device in CAPSULE_DEVICE_GRANTS {
8504 assert!(
8505 device.path.starts_with("/dev/"),
8506 "{} must be an absolute path under /dev",
8507 device.path
8508 );
8509 }
8510 }
8511
8512 #[test]
8520 fn denied_socket_domains_denies_unix_by_default() {
8521 let denied = denied_socket_domains(false);
8522 assert!(
8523 denied.contains(&LINUX_AF_UNIX),
8524 "AF_UNIX must be denied when the manifest does not declare \
8525 capabilities.network.unix_sockets — this is the /var/run/docker.sock escape"
8526 );
8527 }
8528
8529 #[test]
8530 fn denied_socket_domains_omits_unix_only_when_explicitly_allowed() {
8531 let denied = denied_socket_domains(true);
8532 assert!(
8533 !denied.contains(&LINUX_AF_UNIX),
8534 "a capsule that declared capabilities.network.unix_sockets: true must get no \
8535 AF_UNIX deny rule at all"
8536 );
8537 }
8538
8539 #[test]
8542 fn denied_socket_domains_always_denies_netlink_and_packet() {
8543 for unix_sockets_allowed in [false, true] {
8544 let denied = denied_socket_domains(unix_sockets_allowed);
8545 assert!(
8546 denied.contains(&LINUX_AF_NETLINK),
8547 "AF_NETLINK must be denied unconditionally (unix_sockets_allowed = \
8548 {unix_sockets_allowed})"
8549 );
8550 assert!(
8551 denied.contains(&LINUX_AF_PACKET),
8552 "AF_PACKET must be denied unconditionally (unix_sockets_allowed = \
8553 {unix_sockets_allowed})"
8554 );
8555 }
8556 }
8557
8558 #[test]
8562 fn denied_socket_domains_never_denies_ip_families() {
8563 for unix_sockets_allowed in [false, true] {
8564 let denied = denied_socket_domains(unix_sockets_allowed);
8565 assert!(!denied.contains(&LINUX_AF_INET_DOMAIN));
8566 assert!(!denied.contains(&LINUX_AF_INET6_DOMAIN));
8567 }
8568 }
8569
8570 #[test]
8573 fn denied_socket_domains_returns_exactly_the_documented_sets() {
8574 assert_eq!(
8575 denied_socket_domains(false),
8576 vec![LINUX_AF_NETLINK, LINUX_AF_PACKET, LINUX_AF_UNIX]
8577 );
8578 assert_eq!(
8579 denied_socket_domains(true),
8580 vec![LINUX_AF_NETLINK, LINUX_AF_PACKET]
8581 );
8582 }
8583
8584 #[cfg(target_os = "linux")]
8590 #[test]
8591 fn linux_socket_domain_constants_match_libc() {
8592 assert_eq!(LINUX_AF_UNIX, libc::AF_UNIX);
8593 assert_eq!(LINUX_AF_NETLINK, libc::AF_NETLINK);
8594 assert_eq!(LINUX_AF_PACKET, libc::AF_PACKET);
8595 assert_eq!(LINUX_AF_INET_DOMAIN, libc::AF_INET);
8596 assert_eq!(LINUX_AF_INET6_DOMAIN, libc::AF_INET6);
8597 }
8598
8599 #[test]
8604 fn socket_domain_allow_and_deny_sets_never_overlap() {
8605 for unix_sockets_allowed in [false, true] {
8606 let denied = denied_socket_domains(unix_sockets_allowed);
8607 for domain in allowed_socket_domains(unix_sockets_allowed) {
8608 assert!(
8609 !denied.contains(&domain),
8610 "socket domain {domain} is both allowed and denied \
8611 (unix_sockets_allowed = {unix_sockets_allowed})"
8612 );
8613 }
8614 }
8615 }
8616
8617 #[test]
8622 fn allowed_socket_domains_takes_unix_back_exactly_when_declared() {
8623 assert_eq!(
8624 allowed_socket_domains(false),
8625 vec![LINUX_AF_INET_DOMAIN, LINUX_AF_INET6_DOMAIN]
8626 );
8627 assert_eq!(
8628 allowed_socket_domains(true),
8629 vec![LINUX_AF_INET_DOMAIN, LINUX_AF_INET6_DOMAIN, LINUX_AF_UNIX]
8630 );
8631 }
8632
8633 #[test]
8641 fn allowlist_contains_no_syscall_that_must_stay_denied() {
8642 for name in SECCOMP_MUST_STAY_DENIED {
8643 assert!(
8644 !SECCOMP_SYSCALL_ALLOWLIST.contains(name),
8645 "{name} is in SECCOMP_MUST_STAY_DENIED but was added to \
8646 SECCOMP_SYSCALL_ALLOWLIST — if this is deliberate, remove it from the deny list \
8647 in the same edit and say why in the commit"
8648 );
8649 }
8650 }
8651
8652 #[test]
8657 fn allowlist_permits_exec_because_landlock_now_decides_it() {
8658 for name in ["execve", "execveat"] {
8659 assert!(
8660 SECCOMP_SYSCALL_ALLOWLIST.contains(&name),
8661 "{name} must reach the kernel — the default action is a deny, so omitting it \
8662 refuses the child's own exec and nothing runs"
8663 );
8664 }
8665 }
8666
8667 #[test]
8672 fn allowlist_excludes_socket_which_is_ruled_on_by_domain() {
8673 assert!(
8674 !SECCOMP_SYSCALL_ALLOWLIST.contains(&"socket"),
8675 "socket must be permitted per-domain by allowed_socket_domains, never unconditionally"
8676 );
8677 }
8678
8679 #[test]
8682 fn allowlist_has_no_duplicates() {
8683 let mut seen = std::collections::HashSet::new();
8684 for name in SECCOMP_SYSCALL_ALLOWLIST {
8685 assert!(seen.insert(*name), "{name} appears twice in the allowlist");
8686 }
8687 }
8688
8689 #[test]
8692 fn allowlist_covers_the_syscalls_no_process_can_run_without() {
8693 for name in [
8694 "read",
8695 "write",
8696 "openat",
8697 "close",
8698 "mmap",
8699 "munmap",
8700 "brk",
8701 "exit_group",
8702 "futex",
8703 "rt_sigreturn",
8704 "clone",
8705 "wait4",
8706 "getpid",
8707 "getdents64",
8708 ] {
8709 assert!(
8710 SECCOMP_SYSCALL_ALLOWLIST.contains(&name),
8711 "{name} is missing from the allowlist — no process could run under this filter"
8712 );
8713 }
8714 }
8715
8716 #[test]
8717 fn network_ip_allowed_matches_only_the_resolved_allowlist() {
8718 let allow = vec![IpAddr::from([127, 0, 0, 1])];
8719 assert!(network_ip_allowed(IpAddr::from([127, 0, 0, 1]), &allow));
8720 assert!(!network_ip_allowed(IpAddr::from([8, 8, 8, 8]), &allow));
8721 assert!(
8722 !network_ip_allowed(IpAddr::from([127, 0, 0, 1]), &[]),
8723 "an empty resolved allowlist must deny every destination"
8724 );
8725 }
8726
8727 #[test]
8730 fn tier_warning_kernel_full_warns_when_a_shell_is_declared() {
8731 assert_eq!(
8735 tier_warning(EnforcementTier::KernelFull, false),
8736 Some((W_SEC_005, KERNEL_ENFORCEMENT_NOTICE))
8737 );
8738 assert_eq!(tier_warning(EnforcementTier::KernelFull, true), None);
8739 }
8740
8741 #[test]
8745 fn tier_warning_kernel_sealed_states_what_proc_still_leaks() {
8746 let (code, notice) = tier_warning(EnforcementTier::KernelSealed, false).unwrap();
8747 assert_eq!(code, W_SEC_005);
8748 assert!(!notice.contains("stays visible"), "{notice}");
8749 assert!(
8750 !notice.contains("exactly as it is under scoped"),
8751 "{notice}"
8752 );
8753 assert!(notice.contains("no PID namespace"), "{notice}");
8754 assert!(
8755 notice
8756 .contains("Landlock refuses reading host /proc content at both scoped and sealed"),
8757 "{notice}"
8758 );
8759 assert!(notice.contains("stat(2) on /proc/<pid>"), "{notice}");
8760 assert!(
8761 notice.contains("exists, its owner and its start time"),
8762 "{notice}"
8763 );
8764 assert!(
8765 notice.contains("kill(2)") && notice.contains("any process of the same user"),
8766 "{notice}"
8767 );
8768 assert_eq!(tier_warning(EnforcementTier::KernelSealed, true), None);
8769 }
8770
8771 #[test]
8772 fn tier_warning_all_tiers_warn_only_when_shell_allow_is_nonempty() {
8773 assert_eq!(
8774 tier_warning(EnforcementTier::KernelSeccompOnly, false),
8775 Some((W_SEC_002, SECCOMP_ONLY_WARNING))
8776 );
8777 assert_eq!(
8778 tier_warning(EnforcementTier::EnvironmentOnly, false),
8779 Some((W_SEC_001, ENVIRONMENT_ONLY_WARNING))
8780 );
8781 assert_eq!(tier_warning(EnforcementTier::KernelFull, true), None);
8782 assert_eq!(tier_warning(EnforcementTier::KernelSeccompOnly, true), None);
8783 assert_eq!(tier_warning(EnforcementTier::EnvironmentOnly, true), None);
8784 }
8785
8786 #[test]
8791 fn aggregate_bounding_warns_only_off_linux_and_only_with_a_subprocess_route() {
8792 assert_eq!(
8793 aggregate_bounding_warning(false, true, false),
8794 Some((W_SEC_010, NO_AGGREGATE_BOUNDING_WARNING))
8795 );
8796 assert_eq!(
8797 aggregate_bounding_warning(false, false, false),
8798 None,
8799 "a capsule that cannot spawn a subprocess has nothing to bound"
8800 );
8801 assert_eq!(
8802 aggregate_bounding_warning(true, true, false),
8803 None,
8804 "on Linux this case is a refused launch, not a warning"
8805 );
8806 assert_eq!(
8807 aggregate_bounding_warning(true, true, true),
8808 None,
8809 "a scope exists, so there is no gap"
8810 );
8811 }
8812
8813 #[test]
8817 fn aggregate_bounding_warning_states_why_rlimit_nproc_is_insufficient() {
8818 let (_, message) = aggregate_bounding_warning(false, true, false).unwrap();
8819 assert!(message.contains("per-uid"), "message was: {message}");
8820 assert!(message.contains("fork bomb"), "message was: {message}");
8821 assert!(message.contains("pids.max"), "message was: {message}");
8822 }
8823
8824 #[test]
8825 fn warn_for_missing_aggregate_bounding_writes_the_code_and_link_to_bootstrap_log() {
8826 let temp = tempfile::tempdir().unwrap();
8827 warn_for_missing_aggregate_bounding(
8828 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
8829 true,
8830 false,
8831 );
8832 let log = bootstrap_log_contents(temp.path());
8833
8834 if cfg!(target_os = "linux") {
8835 assert!(
8836 log.is_empty(),
8837 "Linux refuses the launch instead of warning: {log}"
8838 );
8839 } else {
8840 assert!(log.contains(W_SEC_010), "log was: {log}");
8841 assert!(
8842 log.contains(&security_warning_link(W_SEC_010)),
8843 "log was: {log}"
8844 );
8845 }
8846 }
8847
8848 fn warning_test_policy() -> CapabilityPolicy {
8849 CapabilityPolicy {
8850 shell_allow: vec!["bash".to_string()],
8851 network_allow: vec!["https://api.example.com".to_string()],
8852 ..CapabilityPolicy::default()
8853 }
8854 }
8855
8856 #[test]
8857 fn warn_for_enforcement_tier_kernel_full_logs_scope_when_shell_declared() {
8858 let temp = tempfile::tempdir().unwrap();
8859 warn_for_enforcement_tier(
8860 EnforcementTier::KernelFull,
8861 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
8862 &warning_test_policy(),
8863 );
8864 let log = bootstrap_log_contents(temp.path());
8865 assert!(
8866 log.contains(W_SEC_005),
8867 "KernelFull must not be silent — it must state what the tier does and does not cover: {log}"
8868 );
8869 assert!(
8870 log.contains("denied, not absent"),
8871 "must state the scope's documented limit: paths outside the workdir are denied, not \
8872 absent: {log}"
8873 );
8874 assert!(
8875 log.contains("read+execute") && log.contains("outside the workdir"),
8876 "must describe what is now actually granted (narrow, derived read+execute outside \
8877 the workdir), not an unfixed grant bug: {log}"
8878 );
8879 assert!(
8880 log.contains(&security_warning_link(W_SEC_005)),
8881 "must link to the diagnostics doc page: {log}"
8882 );
8883 }
8884
8885 #[test]
8886 fn warn_for_enforcement_tier_kernel_full_silent_without_shell_allow() {
8887 let temp = tempfile::tempdir().unwrap();
8888 warn_for_enforcement_tier(
8889 EnforcementTier::KernelFull,
8890 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
8891 &CapabilityPolicy::default(),
8892 );
8893 assert_eq!(
8894 bootstrap_log_contents(temp.path()),
8895 "",
8896 "with no shell declared there is no subprocess to enforce on — stay silent"
8897 );
8898 }
8899
8900 #[test]
8901 fn warn_for_enforcement_tier_seccomp_only_logs_the_landlock_gap() {
8902 let temp = tempfile::tempdir().unwrap();
8903 warn_for_enforcement_tier(
8904 EnforcementTier::KernelSeccompOnly,
8905 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
8906 &warning_test_policy(),
8907 );
8908 let log = bootstrap_log_contents(temp.path());
8909 assert!(
8910 log.contains("Landlock"),
8911 "must name the missing primitive: {log}"
8912 );
8913 assert!(
8914 log.contains("filesystem"),
8915 "must name what is not enforced: {log}"
8916 );
8917 assert!(
8918 log.contains(W_SEC_002),
8919 "must carry its warning code: {log}"
8920 );
8921 assert!(
8922 log.contains(&security_warning_link(W_SEC_002)),
8923 "must link to the diagnostics doc page: {log}"
8924 );
8925 }
8926
8927 #[test]
8928 fn warn_for_enforcement_tier_environment_only_logs_permanent_and_bypass_warnings() {
8929 let temp = tempfile::tempdir().unwrap();
8930 warn_for_enforcement_tier(
8931 EnforcementTier::EnvironmentOnly,
8932 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
8933 &warning_test_policy(),
8934 );
8935 let log = bootstrap_log_contents(temp.path());
8936 assert!(
8937 log.contains("permanent"),
8938 "must state the tier is permanent, not pending: {log}"
8939 );
8940 assert!(
8941 log.contains("bash"),
8942 "the still-accurate bash/network bypass warning must also fire: {log}"
8943 );
8944 assert!(
8945 log.contains(W_SEC_001),
8946 "must carry the tier warning code: {log}"
8947 );
8948 assert!(
8949 log.contains(W_SEC_003),
8950 "must carry the bash/network bypass warning code: {log}"
8951 );
8952 }
8953
8954 struct ForcePrepareFailureGuard;
8957
8958 impl ForcePrepareFailureGuard {
8959 fn new() -> Self {
8960 FORCE_PREPARE_FAILURE.with(|flag| flag.set(true));
8961 Self
8962 }
8963 }
8964
8965 impl Drop for ForcePrepareFailureGuard {
8966 fn drop(&mut self) {
8967 FORCE_PREPARE_FAILURE.with(|flag| flag.set(false));
8968 }
8969 }
8970
8971 #[test]
8972 fn execute_shell_fails_closed_when_enforcement_setup_fails() {
8973 let temp = tempfile::tempdir().unwrap();
8974 let policy = CapabilityPolicy {
8975 shell_allow: vec!["bash".to_string()],
8976 ..CapabilityPolicy::default()
8977 };
8978 let marker = temp.path().join("spawned-anyway.marker");
8979 let script = format!("echo ran > '{}'", marker.display());
8980
8981 {
8982 let _guard = ForcePrepareFailureGuard::new();
8983 let error = crate::shell::execute_shell(
8984 "bash",
8985 &["-c", &script],
8986 &[],
8987 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
8988 &policy,
8989 &ShellEnforcement::environment_only().with_test_session_dirs(),
8990 )
8991 .unwrap_err();
8992 assert!(
8993 error.to_string().contains("sandbox"),
8994 "the error must name what failed to initialize: {error}"
8995 );
8996 assert!(
8997 !marker.exists(),
8998 "no subprocess may be spawned at all when enforcement setup fails"
8999 );
9000 }
9001
9002 let result = crate::shell::execute_shell(
9005 "bash",
9006 &["-c", &script],
9007 &[],
9008 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
9009 &policy,
9010 &ShellEnforcement::environment_only().with_test_session_dirs(),
9011 )
9012 .unwrap();
9013 assert_eq!(result.exit_code, 0);
9014 assert!(marker.exists());
9015 }
9016}
9017
9018#[cfg(target_os = "linux")]
9019#[cfg(test)]
9020#[allow(clippy::print_stdout, clippy::print_stderr)]
9021mod landlock_probe_isolation {
9022 #[test]
9034 fn the_landlock_probe_does_not_restrict_the_calling_process() {
9035 let before = crate::namespace_probe::probe_namespaces().sealed_root;
9036 let _ = super::linux_enforce::probe_landlock_full_access();
9037 let after = crate::namespace_probe::probe_namespaces().sealed_root;
9038
9039 assert_eq!(
9040 before, after,
9041 "the Landlock probe changed what the sealed probe reports ({before:?} -> {after:?}) — \
9042 it has restricted the calling process instead of a forked child, which permanently \
9043 forbids the mount family to this process and everything it spawns",
9044 );
9045 }
9046}
9047
9048#[cfg(all(test, target_os = "linux"))]
9049#[allow(clippy::print_stdout, clippy::print_stderr)]
9050mod linux_integration_tests {
9051 use std::path::Path;
9052
9053 use super::*;
9054
9055 fn host_bounding_base() -> ShellEnforcement {
9068 let base = tempfile::tempdir().unwrap().keep();
9071 ShellEnforcement::environment_only().with_runtime_dir(test_runtime_dir(&base), None)
9072 }
9073
9074 #[test]
9086 fn workdir_landlock_grant_withholds_device_and_socket_creation() {
9087 use landlock::{Access, AccessFs, ABI};
9088
9089 assert_eq!(
9090 LANDLOCK_TARGET_ABI, 3,
9091 "this test pins the ABI v3 set; raising the cap needs this test revisited with it"
9092 );
9093 let all_v3 = AccessFs::from_all(ABI::V3);
9094 let granted = linux_enforce::workdir_access_rights(true);
9098
9099 for withheld in [AccessFs::MakeChar, AccessFs::MakeBlock, AccessFs::MakeSock] {
9100 assert!(
9101 all_v3.contains(withheld),
9102 "{withheld:?} is expected to be part of Landlock ABI v3 — if it is not, this \
9103 test's premise (and the workdir grant's comment) needs revisiting"
9104 );
9105 assert!(
9106 !granted.contains(withheld),
9107 "the workdir grant must withhold {withheld:?}: with the full ABI set declared \
9108 by handle_access, withholding it here is what makes it denied rather than merely \
9109 un-granted"
9110 );
9111 }
9112
9113 for added in [AccessFs::Refer, AccessFs::Truncate] {
9114 assert!(
9115 granted.contains(added),
9116 "the workdir grant must carry {added:?}: without Refer a move between workdir \
9117 folders is EXDEV, and without Truncate a v3 ruleset refuses truncating the \
9118 capsule's own files"
9119 );
9120 }
9121
9122 assert!(
9123 granted.contains(AccessFs::MakeFifo),
9124 "MakeFifo stays granted — real build tooling creates named pipes in its working tree"
9125 );
9126
9127 let expected = all_v3 & !(AccessFs::MakeChar | AccessFs::MakeBlock | AccessFs::MakeSock);
9128 assert_eq!(
9129 granted, expected,
9130 "the workdir_exec grant must be exactly Landlock ABI v3 minus \
9131 MakeChar/MakeBlock/MakeSock — nothing else may be added or dropped without updating \
9132 this test and the constant's comment together"
9133 );
9134 }
9135
9136 #[test]
9140 fn workdir_rights_masked_to_abi_v1_equal_the_pre_refer_set() {
9141 use landlock::{Access, AccessFs, ABI};
9142
9143 let all_v1 = AccessFs::from_all(ABI::V1);
9144 let masked = linux_enforce::workdir_access_rights(true) & all_v1;
9145
9146 assert_eq!(
9147 masked,
9148 all_v1 & !(AccessFs::MakeChar | AccessFs::MakeBlock | AccessFs::MakeSock),
9149 "masked to ABI v1, the workdir grant must be the v1 set minus the three Make* rights"
9150 );
9151 assert!(!masked.contains(AccessFs::Refer));
9152 assert!(!masked.contains(AccessFs::Truncate));
9153 assert_eq!(
9154 linux_enforce::workdir_access_rights(false) & all_v1,
9155 masked & !AccessFs::Execute,
9156 "masking must not move the workdir_exec axis"
9157 );
9158 }
9159
9160 #[test]
9167 fn workdir_execute_right_is_granted_only_when_workdir_exec_is_declared() {
9168 use landlock::AccessFs;
9169
9170 assert!(
9171 !linux_enforce::workdir_access_rights(false).contains(AccessFs::Execute),
9172 "the default must withhold Execute from the workdir — that withholding is the whole \
9173 of what enforces capabilities.shell.allow now that the exec supervisor is gone"
9174 );
9175 assert!(
9176 linux_enforce::workdir_access_rights(true).contains(AccessFs::Execute),
9177 "capabilities.filesystem.workdir_exec: true must actually grant Execute, or the \
9178 compile-and-run workflows it exists for silently stop working"
9179 );
9180
9181 assert_eq!(
9183 linux_enforce::workdir_access_rights(true) & !AccessFs::Execute,
9184 linux_enforce::workdir_access_rights(false),
9185 "workdir_exec must be a one-bit axis — no other right may ride along with it"
9186 );
9187 }
9188
9189 fn sealed_with_runtime_dir(base: &Path) -> ShellEnforcement {
9191 ShellEnforcement {
9192 tier: EnforcementTier::KernelSealed,
9193 ..ShellEnforcement::environment_only().with_test_session_dirs()
9194 }
9195 .with_runtime_dir(test_runtime_dir(base), None)
9196 }
9197
9198 fn temp_dir_fd_of(enforcement: &ShellEnforcement) -> Option<Arc<std::os::fd::OwnedFd>> {
9201 enforcement
9202 .runtime_dir
9203 .as_ref()
9204 .and_then(|roots| roots.temp.as_ref())
9205 .map(|temp| Arc::new(temp.path_fd().unwrap()))
9206 }
9207
9208 fn home_dir_fd_of(enforcement: &ShellEnforcement) -> Option<Arc<std::os::fd::OwnedFd>> {
9211 enforcement
9212 .home_root()
9213 .map(|home| Arc::new(home.path_fd().unwrap()))
9214 }
9215
9216 #[test]
9225 fn the_temp_folder_and_runtime_dir_are_opened_for_landlock_and_the_sealed_bind() {
9226 let accessible = tempfile::tempdir().unwrap();
9227 let runtime = tempfile::tempdir().unwrap();
9228 let enforcement = sealed_with_runtime_dir(runtime.path());
9229 let temp_dir_fd = temp_dir_fd_of(&enforcement);
9230 let sources = SealedSourceFds::open(
9231 &enforcement,
9232 temp_dir_fd.as_ref(),
9233 home_dir_fd_of(&enforcement).as_ref(),
9234 None,
9235 )
9236 .expect("a real, writable workdir must yield the sealed sources");
9237 assert_eq!(
9238 sources.tmp_store_path,
9239 enforcement.temp_dir_path().unwrap(),
9240 "the composed root binds the session's temporary folder at /tmp"
9241 );
9242 assert_eq!(
9243 sources.home_dir.1,
9244 enforcement.home_dir_path().unwrap(),
9245 "the composed root binds the session's home folder at its own path"
9246 );
9247 let fds = linux_enforce::open_landlock_fds(linux_enforce::LandlockFdRequest {
9248 sealed: Some(&sources),
9249 temp_dir_fd,
9250 home_dir_fd: Some(Arc::clone(&sources.home_dir.0)),
9251 runtime_dir_fd: Some(Arc::clone(&sources.runtime_dir)),
9252 records_dir_fd: sources.records_dir.as_ref().map(|(fd, _)| Arc::clone(fd)),
9253 ..linux_enforce::LandlockFdRequest::workdir_only(accessible.path())
9254 })
9255 .expect("a real, writable workdir must yield sealed Landlock fds");
9256
9257 assert!(fds.temp_dir_fd().is_some());
9258 assert!(
9259 fds.home_dir_fd().is_some(),
9260 "the home folder takes the temporary folder's rule"
9261 );
9262 assert!(fds.runtime_dir_fd().is_some());
9263 assert!(
9264 fds.records_dir_fd().is_some(),
9265 "the records directory takes the runtime directory's read-only rule"
9266 );
9267 assert_eq!(
9268 std::fs::read_dir(accessible.path()).unwrap().count(),
9269 0,
9270 "enabling sealed must add nothing to the work folder"
9271 );
9272
9273 let fds = linux_enforce::open_landlock_fds(linux_enforce::LandlockFdRequest::workdir_only(
9274 accessible.path(),
9275 ))
9276 .expect("a real, writable workdir must yield Landlock fds on any tier");
9277 assert!(
9278 fds.temp_dir_fd().is_none(),
9279 "an enforcement holding no temporary folder grants none"
9280 );
9281 assert!(fds.home_dir_fd().is_none());
9282 }
9283
9284 #[test]
9288 fn a_native_tools_store_is_opened_for_landlock_and_the_sealed_bind() {
9289 let accessible = tempfile::tempdir().unwrap();
9290 let runtime = tempfile::tempdir().unwrap();
9291 let enforcement = sealed_with_runtime_dir(runtime.path());
9292 let store = runtime.path().join("state").join("config-capsule");
9293 std::fs::create_dir_all(&store).unwrap();
9294 let store_fd = Arc::new(linux_enforce::open_state_dir(&store).unwrap());
9295 let sources = SealedSourceFds::open(
9296 &enforcement,
9297 temp_dir_fd_of(&enforcement).as_ref(),
9298 home_dir_fd_of(&enforcement).as_ref(),
9299 Some((Arc::clone(&store_fd), store.clone())),
9300 )
9301 .unwrap();
9302 assert_eq!(
9303 sources.state_dir.as_ref().map(|(_, path)| path.as_path()),
9304 Some(store.as_path()),
9305 "the composed root binds the store at its own path"
9306 );
9307 let fds = linux_enforce::open_landlock_fds(linux_enforce::LandlockFdRequest {
9308 sealed: Some(&sources),
9309 state_dir_fd: Some(Arc::clone(&store_fd)),
9310 ..linux_enforce::LandlockFdRequest::workdir_only(accessible.path())
9311 })
9312 .unwrap();
9313 assert!(fds.state_dir_fd().is_some());
9314 let fds = linux_enforce::open_landlock_fds(linux_enforce::LandlockFdRequest::workdir_only(
9315 accessible.path(),
9316 ))
9317 .unwrap();
9318 assert!(fds.state_dir_fd().is_none());
9319
9320 let link = runtime.path().join("state").join("linked");
9321 std::os::unix::fs::symlink(&store, &link).unwrap();
9322 let error = linux_enforce::open_state_dir(&link).expect_err("refused");
9323 assert!(error.contains("state store"), "{error}");
9324 let file = runtime.path().join("state").join("file");
9325 std::fs::write(&file, "x").unwrap();
9326 assert!(linux_enforce::open_state_dir(&file).is_err());
9327 }
9328
9329 #[test]
9332 fn a_sealed_root_needs_the_temp_folder() {
9333 let runtime = tempfile::tempdir().unwrap();
9334 let mut roots = test_runtime_dir(runtime.path());
9335 roots.temp = None;
9336 let enforcement = ShellEnforcement {
9337 tier: EnforcementTier::KernelSealed,
9338 ..ShellEnforcement::environment_only().with_test_session_dirs()
9339 }
9340 .with_runtime_dir(roots, None);
9341 let error = SealedSourceFds::open(
9342 &enforcement,
9343 None,
9344 home_dir_fd_of(&enforcement).as_ref(),
9345 None,
9346 )
9347 .err()
9348 .expect("refused");
9349 assert!(error.contains("temporary folder"), "{error}");
9350 }
9351
9352 #[test]
9355 fn no_home_folder_refuses_a_sealed_root() {
9356 let runtime = tempfile::tempdir().unwrap();
9357 let roots = crate::runtime_dir::RuntimeDirRoots {
9358 home: None,
9359 ..test_runtime_dir(runtime.path())
9360 };
9361 let enforcement = ShellEnforcement {
9362 tier: EnforcementTier::KernelSealed,
9363 ..ShellEnforcement::environment_only().with_test_session_dirs()
9364 }
9365 .with_runtime_dir(roots, None);
9366 let error = SealedSourceFds::open(
9367 &enforcement,
9368 temp_dir_fd_of(&enforcement).as_ref(),
9369 None,
9370 None,
9371 )
9372 .err()
9373 .expect("refused");
9374 assert!(error.contains("no home folder"), "{error}");
9375 assert!(!runtime
9376 .path()
9377 .join(crate::runtime_dir::SEALED_ETC_SUBDIR)
9378 .join("passwd")
9379 .exists());
9380 }
9381
9382 #[test]
9390 fn the_synthetic_etc_files_are_written_into_sealed_etc_and_opened() {
9391 let accessible = tempfile::tempdir().unwrap();
9392 let runtime = tempfile::tempdir().unwrap();
9393 let enforcement = sealed_with_runtime_dir(runtime.path());
9394 let sources = SealedSourceFds::open(
9395 &enforcement,
9396 temp_dir_fd_of(&enforcement).as_ref(),
9397 home_dir_fd_of(&enforcement).as_ref(),
9398 None,
9399 )
9400 .unwrap();
9401 let fds = linux_enforce::open_landlock_fds(linux_enforce::LandlockFdRequest {
9402 sealed: Some(&sources),
9403 ..linux_enforce::LandlockFdRequest::workdir_only(accessible.path())
9404 })
9405 .expect("a real, writable workdir must yield sealed Landlock fds");
9406
9407 let synthetic: Vec<&crate::sealed::SealedEtcPath> = crate::sealed::SEALED_ETC_PATHS
9408 .iter()
9409 .filter(|entry| entry.synthetic.is_some())
9410 .collect();
9411 assert_eq!(
9412 fds.sealed_identity_fd_count(),
9413 synthetic.len(),
9414 "every synthetic /etc entry the composed root binds needs a rule of its own: {synthetic:?}",
9415 );
9416 let etc_dir = runtime.path().join(crate::runtime_dir::SEALED_ETC_SUBDIR);
9417 for entry in &synthetic {
9418 let path = crate::sealed::synthetic_etc_source(&etc_dir, entry.synthetic.unwrap());
9419 assert!(
9420 path.is_file(),
9421 "{} must be written into sealed-etc/",
9422 entry.path
9423 );
9424 }
9425 assert!(
9426 !accessible.path().join(".mur-etc").exists(),
9427 "the work folder gained .mur-etc"
9428 );
9429
9430 let fds = linux_enforce::open_landlock_fds(linux_enforce::LandlockFdRequest::workdir_only(
9431 accessible.path(),
9432 ))
9433 .unwrap();
9434 assert_eq!(fds.sealed_identity_fd_count(), 0);
9435 }
9436
9437 #[test]
9440 fn a_sealed_root_needs_the_runtime_directory() {
9441 let enforcement = ShellEnforcement {
9442 tier: EnforcementTier::KernelSealed,
9443 ..ShellEnforcement::environment_only()
9444 };
9445 let error = SealedSourceFds::open(&enforcement, None, None, None)
9446 .err()
9447 .expect("refused");
9448 assert!(error.contains("runtime directory"), "{error}");
9449 }
9450
9451 #[test]
9459 fn home_folder_is_the_synthetic_passwd_entrys_home_and_home() {
9460 use std::os::unix::fs::MetadataExt;
9461
9462 let runtime = tempfile::tempdir().unwrap();
9463 let roots = test_runtime_dir(runtime.path());
9464 let home = roots.home.clone().unwrap();
9465 let written = ensure_sealed_identity_files(&roots.sealed_etc, &home).unwrap();
9466 let env = crate::shell::build_shell_env(
9467 &crate::types::CapabilityPolicy::default(),
9468 &[],
9469 Some(home.path()),
9470 None,
9471 None,
9472 None,
9473 )
9474 .unwrap();
9475
9476 let passwd = std::fs::read_to_string(&written[0]).unwrap();
9477 let owner = std::fs::metadata(home.path()).unwrap();
9478 let entry: Vec<&str> = passwd
9479 .lines()
9480 .last()
9481 .expect("the capsule's own entry is the last line")
9482 .split(':')
9483 .collect();
9484
9485 assert_eq!(entry[2], owner.uid().to_string(), "uid field");
9486 assert_eq!(entry[3], owner.gid().to_string(), "gid field");
9487 assert_eq!(
9488 Some(&entry[5].to_string()),
9489 env.get("HOME"),
9490 "pw_dir and $HOME are the same string or the entry is worse than useless",
9491 );
9492 assert_eq!(
9493 entry[5],
9494 runtime
9495 .path()
9496 .join(crate::records_dir::RECORDS_DIR_NAME)
9497 .join(crate::records_dir::HOME_SUBDIR)
9498 .to_str()
9499 .unwrap()
9500 );
9501
9502 let group = std::fs::read_to_string(&written[1]).unwrap();
9503 assert_eq!(
9504 group.lines().last().unwrap().split(':').nth(2),
9505 Some(owner.gid().to_string().as_str()),
9506 "gid field of the capsule's own group",
9507 );
9508 }
9509
9510 fn sealed_planted() -> (
9512 tempfile::TempDir,
9513 PathBuf,
9514 PathBuf,
9515 crate::workdir_fs::WorkdirRoot,
9516 ) {
9517 let dir = tempfile::tempdir().unwrap();
9518 let accessible = dir.path().join("work");
9519 let outside = dir.path().join("outside");
9520 std::fs::create_dir_all(&accessible).unwrap();
9521 std::fs::create_dir_all(&outside).unwrap();
9522 std::fs::write(outside.join("sentinel.txt"), b"operator data").unwrap();
9523 let root = crate::workdir_fs::WorkdirRoot::open(&accessible).unwrap();
9524 (dir, root.path().to_path_buf(), outside, root)
9525 }
9526
9527 #[test]
9530 fn sealed_identity_files_keep_their_inode() {
9531 use std::os::unix::fs::MetadataExt;
9532
9533 let (_dir, _session, _outside, root) = sealed_planted();
9534 let runtime = tempfile::tempdir().unwrap();
9535 let roots = test_runtime_dir(runtime.path());
9536 let passwd = roots.sealed_etc.path().join("passwd");
9537 ensure_sealed_identity_files(&roots.sealed_etc, &root).unwrap();
9538 let first = std::fs::metadata(&passwd).unwrap().ino();
9539 ensure_sealed_identity_files(&roots.sealed_etc, &root).unwrap();
9540 assert_eq!(std::fs::metadata(&passwd).unwrap().ino(), first);
9541 assert!(root.take_refusals().is_empty());
9542 }
9543
9544 #[test]
9556 fn the_composed_root_tmp_rule_carries_exactly_the_workdir_right_set() {
9557 use landlock::AccessFs;
9558
9559 for workdir_exec in [false, true] {
9560 let rights = linux_enforce::workdir_access_rights(workdir_exec);
9561
9562 for needed in [
9563 AccessFs::WriteFile,
9564 AccessFs::ReadFile,
9565 AccessFs::ReadDir,
9566 AccessFs::MakeReg,
9567 AccessFs::MakeDir,
9568 AccessFs::RemoveFile,
9569 AccessFs::RemoveDir,
9570 ] {
9571 assert!(
9572 rights.contains(needed),
9573 "/tmp carries the workdir's rights, so it must carry {needed:?}: without it \
9574 `cc`, `mktemp` and `pip` fail on a mount that is genuinely writable",
9575 );
9576 }
9577
9578 assert_eq!(
9579 rights.contains(AccessFs::Execute),
9580 workdir_exec,
9581 "/tmp is backed by a directory inside the workdir, so Execute there must follow \
9582 capabilities.filesystem.workdir_exec exactly — granting it unconditionally would \
9583 make /tmp a hole through which capsule-written bytes run",
9584 );
9585 }
9586
9587 assert_eq!(
9588 linux_enforce::workdir_access_rights(true) & !AccessFs::Execute,
9589 linux_enforce::workdir_access_rights(false),
9590 "the /tmp rule and the workdir rule share one right-set binding in \
9591 apply_landlock_scope; if that set ever grows a second conditional axis, this test and \
9592 that binding must be revisited together",
9593 );
9594 }
9595
9596 fn unconfined_enforcement(declared: murmur_artifact::ResourceCapabilities) -> ShellEnforcement {
9599 ShellEnforcement {
9600 tier: EnforcementTier::Unconfined,
9601 declared_resources: declared,
9602 temp_mapping: crate::temp_dir::tier_temp_mapping(EnforcementTier::Unconfined),
9603 ..ShellEnforcement::environment_only()
9604 }
9605 .with_test_session_dirs()
9606 }
9607
9608 fn status_line(status: &str, field: &str) -> String {
9611 status
9612 .lines()
9613 .find(|line| line.starts_with(field))
9614 .unwrap_or_else(|| panic!("no {field} line in {status}"))
9615 .to_string()
9616 }
9617
9618 fn spawn_unconfined(enforcement: &ShellEnforcement, script: &str) -> String {
9620 let mut command = std::process::Command::new("bash");
9621 command.args(["-c", script]);
9622 let supervisor = prepare_enforcement(&mut command, enforcement, Path::new("/tmp"))
9623 .expect("the unconfined tier installs nothing that can fail");
9624 assert!(matches!(supervisor, SupervisorHandle::Noop));
9625 let output = command.output().expect("bash runs");
9626 assert!(output.status.success(), "{output:?}");
9627 String::from_utf8(output.stdout).unwrap()
9628 }
9629
9630 #[test]
9634 fn an_unconfined_child_keeps_no_new_privs_capabilities_and_network_namespace() {
9635 let enforcement = unconfined_enforcement(Default::default());
9636 let child = spawn_unconfined(
9637 &enforcement,
9638 "cat /proc/self/status; echo \"netns=$(readlink /proc/self/ns/net)\"",
9639 );
9640 let own = std::fs::read_to_string("/proc/self/status").unwrap();
9641 for field in ["NoNewPrivs:", "CapEff:"] {
9642 assert_eq!(
9643 status_line(&child, field),
9644 status_line(&own, field),
9645 "{child}"
9646 );
9647 }
9648 let own_netns = std::fs::read_link("/proc/self/ns/net").unwrap();
9649 assert!(
9650 child.contains(&format!("netns={}", own_netns.display())),
9651 "{child}"
9652 );
9653 assert_eq!(
9654 enforcement.session_netns.creations(),
9655 0,
9656 "the unconfined tier creates no network namespace"
9657 );
9658 }
9659
9660 #[test]
9663 fn an_unconfined_child_gets_only_the_declared_rlimits() {
9664 let read = |resource| {
9665 let limit = rustix::process::getrlimit(resource);
9666 let show = |value: Option<u64>| {
9667 value.map_or_else(|| "unlimited".to_string(), |value| value.to_string())
9668 };
9669 format!("{} {}", show(limit.current), show(limit.maximum))
9670 };
9671 let undeclared = spawn_unconfined(
9672 &unconfined_enforcement(Default::default()),
9673 "echo \"$(ulimit -Sn) $(ulimit -Hn)\"; echo \"$(ulimit -Sc) $(ulimit -Hc)\"",
9674 );
9675 assert_eq!(
9676 undeclared,
9677 format!(
9678 "{}\n{}\n",
9679 read(rustix::process::Resource::Nofile),
9680 read(rustix::process::Resource::Core)
9681 )
9682 );
9683 let declared = spawn_unconfined(
9684 &unconfined_enforcement(murmur_artifact::ResourceCapabilities {
9685 max_open_files: Some(256),
9686 ..Default::default()
9687 }),
9688 "echo \"$(ulimit -Sn) $(ulimit -Hn)\"; echo \"$(ulimit -Sc) $(ulimit -Hc)\"",
9689 );
9690 assert_eq!(
9691 declared,
9692 format!("256 256\n{}\n", read(rustix::process::Resource::Core))
9693 );
9694 }
9695
9696 #[test]
9697 fn prepare_enforcement_is_noop_for_environment_only_tier_even_on_linux() {
9698 let mut command = std::process::Command::new("true");
9699 let enforcement = ShellEnforcement::environment_only().with_test_session_dirs();
9700 let supervisor = prepare_enforcement(&mut command, &enforcement, Path::new("/tmp"))
9701 .expect("environment_only must never fail");
9702 assert!(matches!(supervisor, SupervisorHandle::Noop));
9703 assert_eq!(
9704 enforcement.session_netns.creations(),
9705 0,
9706 "a tier with no kernel sandbox must not create a network namespace"
9707 );
9708 }
9709
9710 #[test]
9716 fn kernel_tier_allows_exec_within_shell_allowlist() {
9717 if crate::network_namespace::skip_without_egress_namespace(
9718 "kernel_tier_allows_exec_within_shell_allowlist",
9719 ) {
9720 return;
9721 }
9722 let tier = detect_enforcement_tier();
9723 if tier == EnforcementTier::EnvironmentOnly {
9724 eprintln!(
9725 "skipping kernel_tier_allows_exec_within_shell_allowlist: resolved to \
9726 EnvironmentOnly on a Linux host — degrading gracefully"
9727 );
9728 return;
9729 }
9730
9731 let temp = tempfile::tempdir().unwrap();
9732 let policy = CapabilityPolicy {
9733 shell_allow: vec!["bash".to_string()],
9734 ..CapabilityPolicy::default()
9735 };
9736 let enforcement = ShellEnforcement {
9737 tier,
9738 network_allow_ips: Vec::new(),
9739 unix_sockets_allowed: policy.unix_sockets_allowed,
9740 landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
9741 &resolve_exec_allowlist(&policy.shell_allow),
9742 )),
9743 ..host_bounding_base()
9744 };
9745
9746 let result = crate::shell::execute_shell(
9747 "bash",
9748 &["-c", "exit 7"],
9749 &[],
9750 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
9751 &policy,
9752 &enforcement,
9753 )
9754 .unwrap();
9755
9756 assert_eq!(
9757 result.exit_code, 7,
9758 "bash itself is allowlisted, so kernel enforcement must permit its own execve — \
9759 proving the allowlist isn't a blanket deny"
9760 );
9761 }
9762
9763 #[test]
9764 fn kernel_tier_allows_nested_exec_of_second_allowlisted_binary() {
9765 if crate::network_namespace::skip_without_egress_namespace(
9766 "kernel_tier_allows_nested_exec_of_second_allowlisted_binary",
9767 ) {
9768 return;
9769 }
9770 let tier = detect_enforcement_tier();
9771 if tier == EnforcementTier::EnvironmentOnly {
9772 eprintln!(
9773 "skipping kernel_tier_allows_nested_exec_of_second_allowlisted_binary: \
9774 resolved to EnvironmentOnly on a Linux host — degrading gracefully"
9775 );
9776 return;
9777 }
9778
9779 let temp = tempfile::tempdir().unwrap();
9780 let policy = CapabilityPolicy {
9781 shell_allow: vec!["bash".to_string(), "ls".to_string()],
9782 ..CapabilityPolicy::default()
9783 };
9784 let enforcement = ShellEnforcement {
9785 tier,
9786 network_allow_ips: Vec::new(),
9787 unix_sockets_allowed: policy.unix_sockets_allowed,
9788 landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
9789 &resolve_exec_allowlist(&policy.shell_allow),
9790 )),
9791 ..host_bounding_base()
9792 };
9793
9794 let result = crate::shell::execute_shell(
9798 "bash",
9799 &["-c", "ls"],
9800 &[],
9801 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
9802 &policy,
9803 &enforcement,
9804 )
9805 .unwrap();
9806
9807 assert_eq!(
9808 result.exit_code, 0,
9809 "a second allowlisted binary exec'd from inside allowlisted bash must succeed \
9810 (stderr: {})",
9811 result.stderr
9812 );
9813 }
9814
9815 #[test]
9816 fn kernel_tier_denies_network_connect_outside_allowlist() {
9817 if crate::network_namespace::skip_without_egress_namespace(
9818 "kernel_tier_denies_network_connect_outside_allowlist",
9819 ) {
9820 return;
9821 }
9822 let tier = detect_enforcement_tier();
9823 if tier == EnforcementTier::EnvironmentOnly {
9824 eprintln!(
9825 "skipping kernel_tier_denies_network_connect_outside_allowlist: resolved to \
9826 EnvironmentOnly on a Linux host — degrading gracefully"
9827 );
9828 return;
9829 }
9830
9831 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
9836 let port = listener.local_addr().unwrap().port();
9837
9838 let temp = tempfile::tempdir().unwrap();
9839 let policy = CapabilityPolicy {
9840 shell_allow: vec!["bash".to_string()],
9841 ..CapabilityPolicy::default()
9842 };
9843 let enforcement = ShellEnforcement {
9844 tier,
9845 network_allow_rules: Vec::new(),
9848 network_allow_ips: Vec::new(),
9849 unix_sockets_allowed: policy.unix_sockets_allowed,
9850 landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
9851 &resolve_exec_allowlist(&policy.shell_allow),
9852 )),
9853 ..host_bounding_base()
9854 };
9855
9856 let script = format!("exec 3<>/dev/tcp/127.0.0.1/{port}");
9858 let result = crate::shell::execute_shell(
9859 "bash",
9860 &["-c", &script],
9861 &[],
9862 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
9863 &policy,
9864 &enforcement,
9865 )
9866 .unwrap();
9867
9868 assert_ne!(
9869 result.exit_code, 0,
9870 "a direct connect() to a destination outside capabilities.network.allow must fail \
9871 even though the port is really open on the host — the subprocess is in its own \
9872 network namespace with no route to it"
9873 );
9874 }
9875
9876 #[test]
9888 fn kernel_tier_reaches_an_allowlisted_destination_through_the_egress_proxy() {
9889 if crate::network_namespace::skip_without_egress_namespace(
9890 "kernel_tier_reaches_an_allowlisted_destination_through_the_egress_proxy",
9891 ) {
9892 return;
9893 }
9894 let tier = detect_enforcement_tier();
9895 if tier == EnforcementTier::EnvironmentOnly {
9896 eprintln!(
9897 "skipping kernel_tier_reaches_an_allowlisted_destination_through_the_egress_proxy: \
9898 resolved to EnvironmentOnly on a Linux host — degrading gracefully"
9899 );
9900 return;
9901 }
9902
9903 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
9906 let port = listener.local_addr().unwrap().port();
9907 let server = std::thread::spawn(move || {
9908 if let Ok((mut stream, _)) = listener.accept() {
9909 use std::io::Write;
9910 let _ = stream.write_all(b"pong\n");
9911 }
9912 });
9913
9914 let temp = tempfile::tempdir().unwrap();
9915 let allow = vec![format!("http://127.0.0.1:{port}")];
9918 let policy = CapabilityPolicy {
9919 shell_allow: vec!["bash".to_string()],
9920 network_allow: allow.clone(),
9921 ..CapabilityPolicy::default()
9922 };
9923 let rules = crate::network_policy::parse_network_allow_rules(&allow).unwrap();
9924 let enforcement = ShellEnforcement {
9925 tier,
9926 egress_tcp_ports: crate::egress_proxy::egress_listen_ports(&rules),
9927 network_allow_rules: rules,
9928 network_allow_ips: resolve_network_allowlist_ips(&allow).unwrap(),
9929 unix_sockets_allowed: policy.unix_sockets_allowed,
9930 landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
9931 &resolve_exec_allowlist(&policy.shell_allow),
9932 )),
9933 ..host_bounding_base()
9934 };
9935
9936 let script =
9939 format!("exec 3<>/dev/tcp/127.0.0.1/{port} && read -r reply <&3 && echo \"$reply\"");
9940 let result = crate::shell::execute_shell(
9941 "bash",
9942 &["-c", &script],
9943 &[],
9944 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
9945 &policy,
9946 &enforcement,
9947 )
9948 .unwrap();
9949 let _ = server.join();
9950
9951 assert_eq!(
9952 result.exit_code, 0,
9953 "a destination inside capabilities.network.allow must stay reachable — enforcement \
9954 adds denials for out-of-policy destinations, it does not degrade permitted ones \
9955 (stderr: {})",
9956 result.stderr
9957 );
9958 assert!(
9959 result.stdout.contains("pong"),
9960 "the host listener's bytes must come back through the proxy unmodified \
9961 (stdout: {:?}, stderr: {})",
9962 result.stdout,
9963 result.stderr
9964 );
9965 }
9966
9967 #[test]
9968 fn kernel_full_denies_filesystem_access_outside_workdir() {
9969 if crate::network_namespace::skip_without_egress_namespace(
9970 "kernel_full_denies_filesystem_access_outside_workdir",
9971 ) {
9972 return;
9973 }
9974 let tier = detect_enforcement_tier();
9975 if tier != EnforcementTier::KernelFull {
9976 eprintln!(
9977 "skipping kernel_full_denies_filesystem_access_outside_workdir: requires \
9978 EnforcementTier::KernelFull (Landlock ABI, kernel 5.13+); detected {tier:?} \
9979 instead"
9980 );
9981 return;
9982 }
9983
9984 let temp = tempfile::tempdir().unwrap();
9985 let policy = CapabilityPolicy {
9986 shell_allow: vec!["bash".to_string()],
9987 ..CapabilityPolicy::default()
9988 };
9989 let enforcement = ShellEnforcement {
9990 tier,
9991 network_allow_ips: Vec::new(),
9992 unix_sockets_allowed: policy.unix_sockets_allowed,
9993 landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
9994 &resolve_exec_allowlist(&policy.shell_allow),
9995 )),
9996 ..host_bounding_base()
9997 };
9998
9999 let result = crate::shell::execute_shell(
10000 "bash",
10001 &["-c", "cat /etc/hostname"],
10002 &[],
10003 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
10004 &policy,
10005 &enforcement,
10006 )
10007 .unwrap();
10008
10009 assert_ne!(
10010 result.exit_code, 0,
10011 "reading a file outside the Landlock-scoped workdir must fail under KernelFull"
10012 );
10013 }
10014
10015 fn scoped_landlock_enforcement(
10024 test: &str,
10025 policy: &CapabilityPolicy,
10026 min_abi: u8,
10027 ) -> Option<ShellEnforcement> {
10028 if crate::network_namespace::skip_without_egress_namespace(test) {
10029 return None;
10030 }
10031 let probe = HostProbe::probe();
10032 let enforcement = ShellEnforcement::resolve_declared(
10033 policy,
10034 murmur_artifact::ContainmentClass::Scoped,
10035 probe.clone(),
10036 )
10037 .unwrap()
10038 .with_test_session_dirs();
10039 if enforcement.tier != EnforcementTier::KernelFull
10040 || enforcement.landlock_abi < Some(min_abi)
10041 {
10042 eprintln!(
10043 "skipping {test}: needs EnforcementTier::KernelFull with a Landlock ruleset at ABI \
10044 {min_abi} or later; resolved {:?} at ABI {:?} (kernel reports {:?})",
10045 enforcement.tier,
10046 enforcement.landlock_abi,
10047 probe.landlock_kernel_abi()
10048 );
10049 return None;
10050 }
10051 Some(enforcement)
10052 }
10053
10054 fn coreutils_policy(extra: &[&str]) -> CapabilityPolicy {
10055 let mut shell_allow: Vec<String> = ["bash", "mkdir", "mv", "ln", "stat", "cat"]
10056 .iter()
10057 .map(|name| name.to_string())
10058 .collect();
10059 shell_allow.extend(extra.iter().map(|name| name.to_string()));
10060 CapabilityPolicy {
10061 shell_allow,
10062 ..CapabilityPolicy::default()
10063 }
10064 }
10065
10066 fn run_bash_in(
10067 workdir: &Path,
10068 script: &str,
10069 policy: &CapabilityPolicy,
10070 enforcement: &ShellEnforcement,
10071 ) -> crate::shell::ShellResult {
10072 let result = crate::shell::execute_shell(
10073 "bash",
10074 &["-c", script],
10075 &[],
10076 &crate::workdir_fs::WorkdirRoot::open(workdir).unwrap(),
10077 policy,
10078 enforcement,
10079 )
10080 .unwrap();
10081 println!(
10082 "exit {}\n--- stdout\n{}--- stderr\n{}---",
10083 result.exit_code, result.stdout, result.stderr
10084 );
10085 result
10086 }
10087
10088 fn work_and_outside() -> (tempfile::TempDir, PathBuf, PathBuf) {
10091 let base = tempfile::tempdir().unwrap();
10092 let work = base.path().join("work");
10093 let outside = base.path().join("outside");
10094 std::fs::create_dir(&work).unwrap();
10095 std::fs::create_dir(&outside).unwrap();
10096 (base, work, outside)
10097 }
10098
10099 #[test]
10102 fn kernel_tier_moves_files_between_workdir_folders() {
10103 let test = "kernel_tier_moves_files_between_workdir_folders";
10104 let policy = coreutils_policy(&[]);
10105 let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 3) else {
10106 return;
10107 };
10108 assert_eq!(enforcement.tier, EnforcementTier::KernelFull);
10109 assert_eq!(enforcement.landlock_abi, Some(3));
10110
10111 let temp = tempfile::tempdir().unwrap();
10112 let result = run_bash_in(
10113 temp.path(),
10114 r#"set -e
10115mkdir a b
10116echo payload > a/x
10117before=$(stat -c %i a/x)
10118mv a/x b/x
10119after=$(stat -c %i b/x)
10120echo "mv a/x b/x: inode $before -> $after"
10121[ "$before" = "$after" ] || { echo "inode changed, mv copied instead of renaming" >&2; exit 10; }
10122ln b/x a/y
10123[ "$(cat a/y)" = payload ] || exit 11
10124echo "ln b/x a/y: ok"
10125mkdir a/d
10126mv a/d b/d
10127[ -d b/d ] && [ ! -e a/d ] || exit 12
10128echo "mv a/d b/d: ok"
10129"#,
10130 &policy,
10131 &enforcement,
10132 );
10133
10134 assert_eq!(
10135 result.exit_code, 0,
10136 "a move, a hard link and a directory reparent between workdir folders must all \
10137 succeed under Refer"
10138 );
10139 }
10140
10141 #[test]
10148 fn home_folder_is_writable_like_tmp_and_the_records_around_it_are_not() {
10149 let test = "home_folder_is_writable_like_tmp_and_the_records_around_it_are_not";
10150 let policy = coreutils_policy(&["rm", "rmdir"]);
10151 let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
10152 return;
10153 };
10154 eprintln!(
10155 "{test}: tier {:?}, Landlock ruleset ABI {:?} (kernel {:?}), /tmp {:?}",
10156 enforcement.tier,
10157 enforcement.landlock_abi,
10158 HostProbe::probe().landlock_kernel_abi(),
10159 enforcement.installed_temp_mapping()
10160 );
10161 let records = enforcement.records_root().unwrap().clone();
10162 records
10163 .create_dir_all(crate::records_dir::RESULTS_SUBDIR, 0o700)
10164 .unwrap();
10165 let home = enforcement.home_dir_path().unwrap().to_path_buf();
10166
10167 let workdir = tempfile::tempdir().unwrap();
10168 let result = run_bash_in(
10169 workdir.path(),
10170 &format!(
10171 r#"[ "$HOME" = '{home}' ] && echo home-is-the-folder
10172echo a > "$HOME/a" && mv "$HOME/a" "$HOME/b" && mkdir "$HOME/d" && mv "$HOME/b" "$HOME/d/c" \
10173 && rm "$HOME/d/c" && rmdir "$HOME/d" && echo home-writable
10174if echo x > "$MURMUR_RECORDS_DIR/out/result.txt"; then echo result-written; else echo result-refused; fi
10175if mkdir -p "$MURMUR_RECORDS_DIR/children/x"; then echo child-created; else echo child-refused; fi
10176if mv "$HOME" "$HOME.moved"; then echo home-moved; else echo home-move-refused; fi
10177"#,
10178 home = home.display()
10179 ),
10180 &policy,
10181 &enforcement,
10182 );
10183
10184 assert_eq!(
10185 result.stdout,
10186 "home-is-the-folder\nhome-writable\nresult-refused\nchild-refused\nhome-move-refused\n",
10187 "stderr: {}",
10188 result.stderr
10189 );
10190 assert!(home.is_dir());
10191 assert_eq!(std::fs::read_dir(&home).unwrap().count(), 0);
10192 assert!(!records.host_path("out/result.txt").exists());
10193 assert!(!records.host_path("children").exists());
10194 assert!(!home.with_extension("moved").exists());
10195 assert_eq!(std::fs::read_dir(workdir.path()).unwrap().count(), 0);
10196 }
10197
10198 #[test]
10207 fn children_records_are_not_reachable_by_a_parents_commands() {
10208 let test = "children_records_are_not_reachable_by_a_parents_commands";
10209 let policy = coreutils_policy(&["ls"]);
10210 let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
10211 return;
10212 };
10213 let runtime_dir = enforcement.runtime_dir_path().unwrap().to_path_buf();
10214 std::fs::write(runtime_dir.join("MURMUR.md"), "RUNTIME-DIR-CONTROL\n").unwrap();
10215 let base = tempfile::tempdir().unwrap();
10216 let child_records = base
10217 .path()
10218 .join("records/children/worker-0123456789abcdef/ses_child");
10219 let result_file = child_records.join("out/result.txt");
10220 std::fs::create_dir_all(result_file.parent().unwrap()).unwrap();
10221 std::fs::write(&result_file, "CHILD-ANSWER-7Q2W").unwrap();
10222
10223 let workdir = tempfile::tempdir().unwrap();
10224 let result = run_bash_in(
10225 workdir.path(),
10226 &format!(
10227 r#"cat "$MURMUR_RUNTIME_DIR/MURMUR.md"
10228if ls '{dir}'; then echo listed; else echo list-refused; fi
10229if cat '{file}'; then echo read; else echo read-refused; fi
10230"#,
10231 dir = child_records.display(),
10232 file = result_file.display(),
10233 ),
10234 &policy,
10235 &enforcement,
10236 );
10237
10238 assert_eq!(
10239 result.stdout, "RUNTIME-DIR-CONTROL\nlist-refused\nread-refused\n",
10240 "stderr: {}",
10241 result.stderr
10242 );
10243 assert!(
10244 !result.stdout.contains("CHILD-ANSWER") && !result.stderr.contains("CHILD-ANSWER"),
10245 "the child's answer reached the parent's command: {result:?}"
10246 );
10247 for line in result.stderr.lines() {
10248 assert!(
10249 line.contains("Permission denied") || line.contains("No such file or directory"),
10250 "an unexpected refusal: {}",
10251 result.stderr
10252 );
10253 }
10254 assert_eq!(result.stderr.lines().count(), 2, "{}", result.stderr);
10255 }
10256
10257 #[test]
10261 fn kernel_tier_at_abi_v1_refuses_cross_folder_links_with_exdev() {
10262 let test = "kernel_tier_at_abi_v1_refuses_cross_folder_links_with_exdev";
10263 let policy = coreutils_policy(&[]);
10264 let Some(mut enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
10265 return;
10266 };
10267 enforcement.landlock_abi = Some(1);
10268
10269 let temp = tempfile::tempdir().unwrap();
10270 let result = run_bash_in(
10271 temp.path(),
10272 r#"mkdir a b || exit 20
10273echo payload > a/x || exit 21
10274before=$(stat -c %i a/x)
10275mv a/x b/x || exit 22
10276[ -f b/x ] && [ ! -e a/x ] || exit 23
10277after=$(stat -c %i b/x)
10278echo "mv a/x b/x: inode $before -> $after"
10279[ "$before" != "$after" ] || exit 24
10280if ln b/x a/y; then exit 25; fi
10281exit 42
10282"#,
10283 &policy,
10284 &enforcement,
10285 );
10286
10287 assert_eq!(
10288 result.exit_code, 42,
10289 "the capsule must launch at ABI v1 and run the script to its own exit code: mv falls \
10290 back to copy-and-delete (a new inode) and ln fails"
10291 );
10292 assert!(
10293 result.stderr.contains("Invalid cross-device link"),
10294 "ln across workdir folders at ABI v1 must fail with EXDEV; stderr: {}",
10295 result.stderr
10296 );
10297 }
10298
10299 #[test]
10301 fn kernel_tier_refuses_rename_out_of_the_workdir() {
10302 let test = "kernel_tier_refuses_rename_out_of_the_workdir";
10303 let policy = coreutils_policy(&[]);
10304 let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 2) else {
10305 return;
10306 };
10307
10308 let (_base, work, outside) = work_and_outside();
10309 let script = format!(
10310 r#"echo payload > x || exit 20
10311if mv x '{outside}/x'; then exit 21; fi
10312if ln x '{outside}/x2'; then exit 22; fi
10313exit 0
10314"#,
10315 outside = outside.display()
10316 );
10317 let result = run_bash_in(&work, &script, &policy, &enforcement);
10318
10319 assert_eq!(
10320 result.exit_code, 0,
10321 "mv and ln out of the workdir must both fail"
10322 );
10323 assert!(
10324 work.join("x").exists(),
10325 "the source must stay in the workdir"
10326 );
10327 assert!(!outside.join("x").exists(), "nothing may be moved out");
10328 assert!(!outside.join("x2").exists(), "nothing may be linked out");
10329 }
10330
10331 #[test]
10333 fn kernel_tier_refuses_rename_into_the_workdir() {
10334 let test = "kernel_tier_refuses_rename_into_the_workdir";
10335 let policy = coreutils_policy(&[]);
10336 let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 2) else {
10337 return;
10338 };
10339
10340 let (_base, work, outside) = work_and_outside();
10341 std::fs::write(outside.join("y"), "original\n").unwrap();
10342 let script = format!(
10343 r#"if mv '{outside}/y' ./y; then exit 21; fi
10344if ln '{outside}/y' ./y2; then exit 22; fi
10345exit 0
10346"#,
10347 outside = outside.display()
10348 );
10349 let result = run_bash_in(&work, &script, &policy, &enforcement);
10350
10351 assert_eq!(
10352 result.exit_code, 0,
10353 "mv and ln into the workdir must both fail"
10354 );
10355 assert_eq!(
10356 std::fs::read_to_string(outside.join("y")).unwrap(),
10357 "original\n",
10358 "the outside file must be untouched"
10359 );
10360 assert!(!work.join("y").exists(), "nothing may be moved in");
10361 assert!(!work.join("y2").exists(), "nothing may be linked in");
10362 }
10363
10364 #[test]
10367 fn kernel_tier_truncate_outside_the_workdir_is_refused() {
10368 use std::os::unix::fs::PermissionsExt;
10369
10370 let test = "kernel_tier_truncate_outside_the_workdir_is_refused";
10371 let policy = coreutils_policy(&["perl"]);
10372 let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 3) else {
10373 return;
10374 };
10375
10376 let (_base, work, outside) = work_and_outside();
10377 let target = outside.join("t");
10378 std::fs::write(&target, "keep\n").unwrap();
10379 std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o644)).unwrap();
10380 let script = format!(
10381 r#"perl -e 'truncate($ARGV[0], 0) or die "$!\n"' '{}'"#,
10382 target.display()
10383 );
10384
10385 let refused = run_bash_in(&work, &script, &policy, &enforcement);
10386 assert_ne!(
10387 refused.exit_code, 0,
10388 "truncate(2) outside the workdir must be refused at ABI 3"
10389 );
10390 assert!(
10391 refused.stderr.contains("Permission denied"),
10392 "the refusal must be EACCES; stderr: {}",
10393 refused.stderr
10394 );
10395 assert_eq!(std::fs::read_to_string(&target).unwrap(), "keep\n");
10396
10397 let mut at_v1 = enforcement.clone();
10398 at_v1.landlock_abi = Some(1);
10399 let unmediated = run_bash_in(&work, &script, &policy, &at_v1);
10400 assert_eq!(
10401 unmediated.exit_code, 0,
10402 "at ABI v1 truncate(2) is not mediated, which is the gap this test pins as closed above"
10403 );
10404 assert_eq!(std::fs::read_to_string(&target).unwrap(), "");
10405 }
10406
10407 #[test]
10410 fn kernel_tier_truncate_inside_the_workdir_and_dev_null_still_work() {
10411 let test = "kernel_tier_truncate_inside_the_workdir_and_dev_null_still_work";
10412 let policy = coreutils_policy(&["truncate"]);
10413 let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 3) else {
10414 return;
10415 };
10416
10417 let temp = tempfile::tempdir().unwrap();
10418 let result = run_bash_in(
10419 temp.path(),
10420 r#"set -e
10421echo hello > f && truncate -s 0 f && [ ! -s f ]
10422echo again > f && echo over > f && [ "$(cat f)" = over ]
10423: > g
10424echo discard > /dev/null
10425echo ok
10426"#,
10427 &policy,
10428 &enforcement,
10429 );
10430
10431 assert_eq!(
10432 result.exit_code, 0,
10433 "truncating inside the workdir and writing to /dev/null must keep working"
10434 );
10435 }
10436
10437 #[test]
10448 #[ignore = "needs MURMUR_TEST_UV and MURMUR_TEST_WHEEL; see the doc comment"]
10449 fn kernel_tier_uv_installs_a_wheel_in_the_workdir() {
10450 let test = "kernel_tier_uv_installs_a_wheel_in_the_workdir";
10451 let input = |name: &str| {
10452 let value = std::env::var(name)
10453 .unwrap_or_else(|_| panic!("{test} needs {name} set to an absolute path"));
10454 let path = PathBuf::from(value);
10455 assert!(
10456 path.is_absolute() && path.is_file(),
10457 "{name}={} is not a file",
10458 path.display()
10459 );
10460 path
10461 };
10462 let uv = input("MURMUR_TEST_UV");
10463 let wheel = input("MURMUR_TEST_WHEEL");
10464 let python = std::fs::canonicalize("/usr/bin/python3").expect("host python3");
10465 let stdlib = std::process::Command::new(&python)
10466 .args([
10467 "-I",
10468 "-c",
10469 "import sysconfig; print(sysconfig.get_paths()['stdlib'])",
10470 ])
10471 .output()
10472 .expect("host python3 runs");
10473 let stdlib = String::from_utf8(stdlib.stdout).unwrap().trim().to_string();
10474
10475 let policy = CapabilityPolicy {
10476 shell_allow: vec![
10477 "bash".to_string(),
10478 uv.display().to_string(),
10479 python.display().to_string(),
10480 ],
10481 shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
10482 binary: "python3".to_string(),
10483 dirs: vec![murmur_artifact::InterpreterRuntimeDir {
10484 path: stdlib,
10485 list_dir: true,
10486 }],
10487 }],
10488 ..CapabilityPolicy::default()
10489 };
10490 let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 2) else {
10491 return;
10492 };
10493
10494 let wheel_name = wheel.file_name().unwrap().to_str().unwrap().to_string();
10495 let script = format!(
10496 r#"set -e
10497export UV_CACHE_DIR="$PWD/.uv-cache" UV_PYTHON_DOWNLOADS=never
10498'{uv}' venv --python '{python}' .venv
10499'{uv}' pip install --offline --no-index --python .venv/bin/python './{wheel_name}'
10500.venv/bin/python -c 'import six; print(six.__version__)'
10501"#,
10502 uv = uv.display(),
10503 python = python.display(),
10504 );
10505 let install = |enforcement: &ShellEnforcement| {
10506 let temp = tempfile::tempdir().unwrap();
10507 std::fs::copy(&wheel, temp.path().join(&wheel_name)).unwrap();
10508 run_bash_in(temp.path(), &script, &policy, enforcement)
10509 };
10510
10511 let at_probed = install(&enforcement);
10512 assert_eq!(
10513 at_probed.exit_code, 0,
10514 "uv must install the wheel at ABI {:?}",
10515 enforcement.landlock_abi
10516 );
10517 assert!(at_probed.stdout.contains("1.16.0"), "{}", at_probed.stdout);
10518
10519 let mut at_v1 = enforcement.clone();
10520 at_v1.landlock_abi = Some(1);
10521 let refused = install(&at_v1);
10522 let output = format!("{}{}", refused.stdout, refused.stderr);
10523 assert_ne!(refused.exit_code, 0, "at ABI v1 the install must fail");
10524 assert!(
10525 output.contains("os error 18") || output.contains("Invalid cross-device link"),
10526 "at ABI v1 the install must fail with EXDEV: {output}"
10527 );
10528 }
10529
10530 #[test]
10536 fn kernel_full_runs_nontrivial_shell_allowlist_but_a_pass_here_does_not_prove_the_landlock_fix()
10537 {
10538 if crate::network_namespace::skip_without_egress_namespace("kernel_full_runs_nontrivial_shell_allowlist_but_a_pass_here_does_not_prove_the_landlock_fix") {
10539 return;
10540 }
10541 let tier = detect_enforcement_tier();
10542 if tier != EnforcementTier::KernelFull {
10543 eprintln!(
10544 "SKIP — PROVES NOTHING ABOUT THE LANDLOCK FIX ON THIS HOST: \
10545 kernel_full_runs_nontrivial_shell_allowlist_... requires \
10546 EnforcementTier::KernelFull (Landlock ABI, kernel 5.13+); detected {tier:?}. \
10547 This run does NOT execute the derived-grant Landlock code path at all, so a \
10548 green result here is not evidence the workdir-scope exec bug is fixed — only a \
10549 real KernelFull Linux host can prove that."
10550 );
10551 return;
10552 }
10553
10554 let temp = tempfile::tempdir().unwrap();
10555 let policy = CapabilityPolicy {
10560 shell_allow: vec!["bash".to_string(), "cat".to_string()],
10561 ..CapabilityPolicy::default()
10562 };
10563 let exec_allow_paths = resolve_exec_allowlist(&policy.shell_allow);
10564 let enforcement = ShellEnforcement {
10565 tier,
10566 network_allow_ips: Vec::new(),
10567 unix_sockets_allowed: policy.unix_sockets_allowed,
10568 landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
10569 &exec_allow_paths,
10570 )),
10571 ..host_bounding_base()
10572 };
10573
10574 let result = crate::shell::execute_shell(
10577 "bash",
10578 &["-c", "echo landlock-grant-ok | cat"],
10579 &[],
10580 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
10581 &policy,
10582 &enforcement,
10583 )
10584 .expect("execute_shell should return Ok, not Err");
10585
10586 assert_eq!(
10587 result.exit_code, 0,
10588 "bash + cat are both allowlisted and dynamically linked; the derived Landlock grants \
10589 must let each exec and dynamic-link outside the workdir (stderr: {})",
10590 result.stderr
10591 );
10592 assert!(
10593 result.stdout.contains("landlock-grant-ok"),
10594 "the nested `cat` must actually run and pass its stdin through (stdout: {}, stderr: {})",
10595 result.stdout,
10596 result.stderr
10597 );
10598 }
10599
10600 #[test]
10601 fn kernel_full_denies_write_outside_workdir_but_a_pass_here_does_not_prove_the_landlock_fix() {
10602 if crate::network_namespace::skip_without_egress_namespace("kernel_full_denies_write_outside_workdir_but_a_pass_here_does_not_prove_the_landlock_fix") {
10603 return;
10604 }
10605 let tier = detect_enforcement_tier();
10606 if tier != EnforcementTier::KernelFull {
10607 eprintln!(
10608 "SKIP — PROVES NOTHING ABOUT THE LANDLOCK FIX ON THIS HOST: \
10609 kernel_full_denies_write_outside_workdir_... requires \
10610 EnforcementTier::KernelFull (Landlock ABI, kernel 5.13+); detected {tier:?}. \
10611 This run does NOT execute the Landlock code path, so a green result here is not \
10612 evidence writes outside the workdir are actually denied — only a real KernelFull \
10613 Linux host can prove that."
10614 );
10615 return;
10616 }
10617
10618 let workdir = tempfile::tempdir().unwrap();
10619 let outside = tempfile::tempdir().unwrap();
10622 let target = outside.path().join("should-not-be-written.txt");
10623
10624 let policy = CapabilityPolicy {
10625 shell_allow: vec!["bash".to_string()],
10626 ..CapabilityPolicy::default()
10627 };
10628 let exec_allow_paths = resolve_exec_allowlist(&policy.shell_allow);
10629 let enforcement = ShellEnforcement {
10630 tier,
10631 network_allow_ips: Vec::new(),
10632 unix_sockets_allowed: policy.unix_sockets_allowed,
10633 landlock_grants: LandlockGrant::non_listable_files(resolve_landlock_grants(
10634 &exec_allow_paths,
10635 )),
10636 ..host_bounding_base()
10637 };
10638
10639 let script = format!("echo pwned > '{}'", target.display());
10643 let result = crate::shell::execute_shell(
10644 "bash",
10645 &["-c", &script],
10646 &[],
10647 &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
10648 &policy,
10649 &enforcement,
10650 )
10651 .expect("execute_shell should return Ok with a nonzero exit code, not Err");
10652
10653 assert!(
10654 !target.exists(),
10655 "a write to a path outside the Landlock-scoped workdir must be denied under KernelFull"
10656 );
10657 assert_ne!(
10658 result.exit_code, 0,
10659 "bash's redirection to an outside path must fail (Landlock denies the open-for-write)"
10660 );
10661 }
10662
10663 fn kernel_full_enforcement(
10667 tier: EnforcementTier,
10668 policy: &CapabilityPolicy,
10669 ) -> ShellEnforcement {
10670 let exec_allow_paths = resolve_exec_allowlist(&policy.shell_allow);
10671 let mut landlock_grants =
10672 LandlockGrant::non_listable_files(resolve_landlock_grants(&exec_allow_paths));
10673 landlock_grants.extend(resolve_interpreter_runtime_grants(
10674 &policy.shell_interpreter_runtime,
10675 ));
10676 ShellEnforcement {
10677 tier,
10678 network_allow_ips: Vec::new(),
10679 unix_sockets_allowed: policy.unix_sockets_allowed,
10680 landlock_grants,
10681 ..host_bounding_base()
10682 }
10683 }
10684
10685 #[test]
10695 fn home_folder_and_tmp_in_the_records_dir_on_a_sealed_root() {
10696 if crate::network_namespace::skip_without_egress_namespace(
10697 "home_folder_and_tmp_in_the_records_dir_on_a_sealed_root",
10698 ) {
10699 return;
10700 }
10701 let probe = HostProbe::probe();
10702 if probe.tier() != EnforcementTier::KernelSealed {
10703 eprintln!(
10704 "SKIP — PROVES NOTHING ABOUT THE SEALED ROOT ON THIS HOST: \
10705 home_folder_and_tmp_in_the_records_dir_on_a_sealed_root needs a host that reaches \
10706 KernelSealed (unprivileged user namespaces plus a usable Landlock ABI); detected \
10707 {:?}. No composed root is built in this run.",
10708 probe.tier()
10709 );
10710 return;
10711 }
10712
10713 let accessible = tempfile::tempdir().unwrap();
10714 let policy = CapabilityPolicy {
10715 shell_allow: vec![
10716 "bash".to_string(),
10717 "cat".to_string(),
10718 "id".to_string(),
10719 "touch".to_string(),
10720 "getent".to_string(),
10721 "cut".to_string(),
10722 ],
10723 ..CapabilityPolicy::default()
10724 };
10725 let runtime = tempfile::tempdir().unwrap();
10726 let enforcement = ShellEnforcement::resolve_declared(
10727 &policy,
10728 murmur_artifact::ContainmentClass::Sealed,
10729 probe.clone(),
10730 )
10731 .expect("a sealed-capable host resolves sealed enforcement")
10732 .with_runtime_dir(test_runtime_dir(runtime.path()), None);
10733 let child_result = runtime
10736 .path()
10737 .join("children/worker-0123456789abcdef/out/result.txt");
10738 std::fs::create_dir_all(child_result.parent().unwrap()).unwrap();
10739 std::fs::write(&child_result, "the child's answer").unwrap();
10740 assert_eq!(enforcement.tier, EnforcementTier::KernelSealed);
10741 eprintln!(
10742 "home_folder_and_tmp_in_the_records_dir_on_a_sealed_root: tier {:?}, Landlock \
10743 ruleset ABI {:?} (kernel {:?})",
10744 enforcement.tier,
10745 enforcement.landlock_abi,
10746 probe.landlock_kernel_abi()
10747 );
10748 let probe_name = format!("sealed-probe-{}", std::process::id());
10749
10750 let result = crate::shell::execute_shell(
10751 "bash",
10752 &[
10753 "-c",
10754 &format!(
10755 "echo t > /tmp/{probe_name} && cat /tmp/{probe_name} && id -un && \
10756 printf '%s\\n' \"$TMPDIR\" && printf '%s\\n' \"$HOME\" && \
10757 getent passwd \"$(id -u)\" | cut -d: -f6 && cd ~ && touch f && echo touched && \
10758 {{ cat '{child}' 2>&1 || true; }}",
10759 child = child_result.display()
10760 ),
10761 ],
10762 &[],
10763 &crate::workdir_fs::WorkdirRoot::open(accessible.path()).unwrap(),
10764 &policy,
10765 &enforcement,
10766 )
10767 .expect("execute_shell returns Ok even when the command itself fails");
10768
10769 assert_eq!(
10770 result.exit_code, 0,
10771 "stdout: {} stderr: {}",
10772 result.stdout, result.stderr
10773 );
10774 let mut lines = result.stdout.lines();
10775 assert_eq!(
10776 lines.next(),
10777 Some("t"),
10778 "the capsule's /tmp must be writable"
10779 );
10780 assert!(
10781 lines.next().is_some_and(|name| !name.is_empty()),
10782 "`id -un` resolves only through the synthetic /etc/passwd the composed root binds: \
10783 {}",
10784 result.stdout
10785 );
10786 assert_eq!(
10787 lines.next(),
10788 Some("/tmp"),
10789 "TMPDIR names /tmp inside the root"
10790 );
10791 let home = enforcement.home_dir_path().unwrap().to_str().unwrap();
10792 assert_eq!(lines.next(), Some(home), "$HOME is the home folder");
10793 assert_eq!(lines.next(), Some(home), "passwd's home is the home folder");
10794 assert_eq!(
10795 lines.next(),
10796 Some("touched"),
10797 "~ is writable inside the root"
10798 );
10799 let child_read = lines.next().unwrap_or_default();
10800 assert!(
10801 child_read.contains("No such file or directory"),
10802 "a child's result is not in the composed root: {}",
10803 result.stdout
10804 );
10805 assert!(
10806 !result.stdout.contains("the child's answer"),
10807 "{}",
10808 result.stdout
10809 );
10810 assert!(Path::new(home).join("f").is_file());
10811
10812 let folder = enforcement.temp_dir_path().unwrap();
10813 assert_eq!(
10814 std::fs::read_to_string(folder.join(&probe_name)).unwrap(),
10815 "t\n",
10816 "the bytes written to /tmp land in the session's temporary folder"
10817 );
10818 assert!(
10819 !Path::new("/tmp").join(&probe_name).exists(),
10820 "nothing reaches the host's /tmp"
10821 );
10822 assert!(runtime
10823 .path()
10824 .join(crate::runtime_dir::SEALED_ETC_SUBDIR)
10825 .join("passwd")
10826 .is_file());
10827 assert_eq!(
10828 std::fs::read_dir(accessible.path()).unwrap().count(),
10829 0,
10830 "enabling sealed must add nothing to the work folder"
10831 );
10832 }
10833
10834 #[test]
10838 fn kernel_full_interpreter_runtime_list_dir_false_opens_file_but_denies_listing() {
10839 if crate::network_namespace::skip_without_egress_namespace(
10840 "kernel_full_interpreter_runtime_list_dir_false_opens_file_but_denies_listing",
10841 ) {
10842 return;
10843 }
10844 let tier = detect_enforcement_tier();
10845 if tier != EnforcementTier::KernelFull {
10846 eprintln!(
10847 "SKIP — PROVES NOTHING ABOUT THE LANDLOCK FIX ON THIS HOST: \
10848 kernel_full_interpreter_runtime_list_dir_false_... requires \
10849 EnforcementTier::KernelFull (Landlock ABI, kernel 5.13+); detected {tier:?}. \
10850 This run does NOT execute the interpreter_runtime Landlock code path, so a green \
10851 result here is not evidence list_dir:false behaves correctly — only a real \
10852 KernelFull Linux host can prove that."
10853 );
10854 return;
10855 }
10856
10857 let workdir = tempfile::tempdir().unwrap();
10858 let stdlib = tempfile::tempdir().unwrap();
10860 let module = stdlib.path().join("mod.py");
10861 std::fs::write(&module, "x = 1\n").unwrap();
10862
10863 let policy = CapabilityPolicy {
10864 shell_allow: vec!["bash".to_string(), "cat".to_string(), "ls".to_string()],
10865 shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
10866 binary: "bash".to_string(),
10867 dirs: vec![murmur_artifact::InterpreterRuntimeDir {
10868 path: stdlib.path().to_string_lossy().into_owned(),
10869 list_dir: false,
10870 }],
10871 }],
10872 ..CapabilityPolicy::default()
10873 };
10874 let enforcement = kernel_full_enforcement(tier, &policy);
10875
10876 let read = crate::shell::execute_shell(
10879 "bash",
10880 &["-c", &format!("cat '{}'", module.display())],
10881 &[],
10882 &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
10883 &policy,
10884 &enforcement,
10885 )
10886 .expect("execute_shell should return Ok");
10887 assert_eq!(
10888 read.exit_code, 0,
10889 "list_dir:false must still permit opening a known file inside the grant (stderr: {})",
10890 read.stderr
10891 );
10892 assert!(read.stdout.contains("x = 1"), "stdout: {}", read.stdout);
10893
10894 let list = crate::shell::execute_shell(
10896 "bash",
10897 &["-c", &format!("ls '{}'", stdlib.path().display())],
10898 &[],
10899 &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
10900 &policy,
10901 &enforcement,
10902 )
10903 .expect("execute_shell should return Ok with a nonzero exit code");
10904 assert_ne!(
10905 list.exit_code, 0,
10906 "list_dir:false must deny enumerating the granted directory's own entries"
10907 );
10908 }
10909
10910 #[test]
10911 fn kernel_full_interpreter_runtime_list_dir_true_lists_dir_but_not_its_parent() {
10912 if crate::network_namespace::skip_without_egress_namespace(
10913 "kernel_full_interpreter_runtime_list_dir_true_lists_dir_but_not_its_parent",
10914 ) {
10915 return;
10916 }
10917 let tier = detect_enforcement_tier();
10918 if tier != EnforcementTier::KernelFull {
10919 eprintln!(
10920 "SKIP — PROVES NOTHING ABOUT THE LANDLOCK FIX ON THIS HOST: \
10921 kernel_full_interpreter_runtime_list_dir_true_... requires \
10922 EnforcementTier::KernelFull (Landlock ABI, kernel 5.13+); detected {tier:?}. \
10923 This run does NOT execute the interpreter_runtime Landlock code path, so a green \
10924 result here is not evidence list_dir:true (and its non-widening to the parent) \
10925 behaves correctly — only a real KernelFull Linux host can prove that."
10926 );
10927 return;
10928 }
10929
10930 let workdir = tempfile::tempdir().unwrap();
10931 let parent = tempfile::tempdir().unwrap();
10933 let stdlib = parent.path().join("stdlib");
10934 std::fs::create_dir(&stdlib).unwrap();
10935 std::fs::write(stdlib.join("mod.py"), "x = 1\n").unwrap();
10936
10937 let policy = CapabilityPolicy {
10938 shell_allow: vec!["bash".to_string(), "ls".to_string()],
10939 shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
10940 binary: "bash".to_string(),
10941 dirs: vec![murmur_artifact::InterpreterRuntimeDir {
10942 path: stdlib.to_string_lossy().into_owned(),
10943 list_dir: true,
10944 }],
10945 }],
10946 ..CapabilityPolicy::default()
10947 };
10948 let enforcement = kernel_full_enforcement(tier, &policy);
10949
10950 let list = crate::shell::execute_shell(
10952 "bash",
10953 &["-c", &format!("ls '{}'", stdlib.display())],
10954 &[],
10955 &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
10956 &policy,
10957 &enforcement,
10958 )
10959 .expect("execute_shell should return Ok");
10960 assert_eq!(
10961 list.exit_code, 0,
10962 "list_dir:true must permit enumerating the granted directory (stderr: {})",
10963 list.stderr
10964 );
10965 assert!(list.stdout.contains("mod.py"), "stdout: {}", list.stdout);
10966
10967 let list_parent = crate::shell::execute_shell(
10970 "bash",
10971 &["-c", &format!("ls '{}'", parent.path().display())],
10972 &[],
10973 &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
10974 &policy,
10975 &enforcement,
10976 )
10977 .expect("execute_shell should return Ok with a nonzero exit code");
10978 assert_ne!(
10979 list_parent.exit_code, 0,
10980 "a grant on stdlib/ must not make its (ungranted) parent enumerable"
10981 );
10982 }
10983
10984 #[test]
10995 fn kernel_full_non_executable_grant_lists_its_tree_but_refuses_to_run_from_it() {
10996 if crate::network_namespace::skip_without_egress_namespace(
10997 "kernel_full_non_executable_grant_lists_its_tree_but_refuses_to_run_from_it",
10998 ) {
10999 return;
11000 }
11001 let host_tier = detect_enforcement_tier();
11002 if !matches!(
11003 host_tier,
11004 EnforcementTier::KernelFull | EnforcementTier::KernelSealed
11005 ) {
11006 eprintln!(
11007 "SKIP — PROVES NOTHING ABOUT THE LANDLOCK FIX ON THIS HOST: \
11008 kernel_full_non_executable_grant_... needs a usable Landlock ABI (kernel 5.13+); \
11009 detected {host_tier:?}. This run does NOT install a Landlock domain, so a green \
11010 result is not evidence that withholding Execute denies execve."
11011 );
11012 return;
11013 }
11014
11015 let workdir = tempfile::tempdir().unwrap();
11016 let tree = tempfile::tempdir().unwrap();
11018 std::fs::write(tree.path().join("entry.txt"), "x\n").unwrap();
11019 let runme = tree.path().join("runme");
11020 std::fs::copy("/bin/echo", &runme).unwrap();
11021
11022 let policy = CapabilityPolicy {
11023 shell_allow: vec!["bash".to_string(), "ls".to_string()],
11024 ..CapabilityPolicy::default()
11025 };
11026 let with_tree_grant = |executable: bool| {
11027 let mut enforcement = kernel_full_enforcement(EnforcementTier::KernelFull, &policy);
11028 enforcement.landlock_grants.push(LandlockGrant {
11029 path: tree.path().to_path_buf(),
11030 list_dir: true,
11031 executable,
11032 pinned: None,
11033 });
11034 enforcement
11035 };
11036 let run = |enforcement: &ShellEnforcement, command: String| {
11037 crate::shell::execute_shell(
11038 "bash",
11039 &["-c", &command],
11040 &[],
11041 &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
11042 &policy,
11043 enforcement,
11044 )
11045 .expect("execute_shell should return Ok even when the command itself fails")
11046 };
11047
11048 let denied = with_tree_grant(false);
11049 let list = run(&denied, format!("ls '{}'", tree.path().display()));
11050 assert_eq!(
11051 list.exit_code, 0,
11052 "a non-executable grant must still be enumerable — that is its entire purpose \
11053 (stderr: {})",
11054 list.stderr
11055 );
11056 assert!(list.stdout.contains("entry.txt"), "stdout: {}", list.stdout);
11057
11058 let exec_denied = run(&denied, format!("'{}' ran", runme.display()));
11059 assert_ne!(
11060 exec_denied.exit_code, 0,
11061 "withholding Execute must deny execve inside the granted tree; a program ran instead \
11062 (stdout: {})",
11063 exec_denied.stdout
11064 );
11065 assert!(
11066 !exec_denied.stdout.contains("ran"),
11067 "the binary produced output, so it executed: {}",
11068 exec_denied.stdout
11069 );
11070
11071 let allowed = with_tree_grant(true);
11074 let exec_allowed = run(&allowed, format!("'{}' ran", runme.display()));
11075 assert_eq!(
11076 exec_allowed.exit_code, 0,
11077 "the same tree granted Execute must run (stderr: {})",
11078 exec_allowed.stderr
11079 );
11080 assert!(
11081 exec_allowed.stdout.contains("ran"),
11082 "stdout: {}",
11083 exec_allowed.stdout
11084 );
11085 }
11086
11087 #[test]
11100 fn kernel_full_non_executable_file_grant_is_readable_and_survives_a_bad_list_dir_claim() {
11101 if crate::network_namespace::skip_without_egress_namespace(
11102 "kernel_full_non_executable_file_grant_is_readable_and_survives_a_bad_list_dir_claim",
11103 ) {
11104 return;
11105 }
11106 let host_tier = detect_enforcement_tier();
11107 if !matches!(
11108 host_tier,
11109 EnforcementTier::KernelFull | EnforcementTier::KernelSealed
11110 ) {
11111 eprintln!(
11112 "SKIP — PROVES NOTHING ABOUT THE LANDLOCK FIX ON THIS HOST: \
11113 kernel_full_non_executable_file_grant_... needs a usable Landlock ABI (kernel \
11114 5.13+); detected {host_tier:?}. This run does NOT install a Landlock domain, so a \
11115 green result is not evidence that a file grant reads, nor that a ReadDir claim on \
11116 a file is narrowed instead of refusing the launch."
11117 );
11118 return;
11119 }
11120
11121 let workdir = tempfile::tempdir().unwrap();
11122 let store = tempfile::tempdir().unwrap();
11124 let bundle = store.path().join("ca-certificates.crt");
11125 std::fs::write(&bundle, "-----BEGIN CERTIFICATE-----\n").unwrap();
11126
11127 let policy = CapabilityPolicy {
11128 shell_allow: vec!["bash".to_string(), "cat".to_string()],
11129 ..CapabilityPolicy::default()
11130 };
11131 let with_file_grant = |list_dir: bool| {
11132 let mut enforcement = kernel_full_enforcement(EnforcementTier::KernelFull, &policy);
11133 enforcement.landlock_grants.push(LandlockGrant {
11134 path: bundle.clone(),
11135 list_dir,
11136 executable: false,
11137 pinned: None,
11138 });
11139 enforcement
11140 };
11141 let read = |enforcement: &ShellEnforcement| {
11142 crate::shell::execute_shell(
11143 "bash",
11144 &["-c", &format!("cat '{}'", bundle.display())],
11145 &[],
11146 &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
11147 &policy,
11148 enforcement,
11149 )
11150 .expect("execute_shell should return Ok even when the command itself fails")
11151 };
11152
11153 let honest = read(&with_file_grant(false));
11154 assert_eq!(
11155 honest.exit_code, 0,
11156 "ReadFile alone must open a granted regular file — this is what the /etc allowlist's \
11157 file entries rely on (stderr: {})",
11158 honest.stderr
11159 );
11160 assert!(
11161 honest.stdout.contains("BEGIN CERTIFICATE"),
11162 "stdout: {}",
11163 honest.stdout
11164 );
11165
11166 let overclaimed = read(&with_file_grant(true));
11167 assert_eq!(
11168 overclaimed.exit_code, 0,
11169 "a list_dir:true claim on a regular file must be narrowed to ReadFile, not turned \
11170 into an EINVAL that refuses the launch (stderr: {})",
11171 overclaimed.stderr
11172 );
11173 assert!(
11174 overclaimed.stdout.contains("BEGIN CERTIFICATE"),
11175 "stdout: {}",
11176 overclaimed.stdout
11177 );
11178 }
11179
11180 struct ForceNoNewPrivsFailureGuard;
11190
11191 impl ForceNoNewPrivsFailureGuard {
11192 fn new() -> Self {
11193 FORCE_NO_NEW_PRIVS_FAILURE.with(|flag| flag.set(true));
11194 Self
11195 }
11196 }
11197
11198 impl Drop for ForceNoNewPrivsFailureGuard {
11199 fn drop(&mut self) {
11200 FORCE_NO_NEW_PRIVS_FAILURE.with(|flag| flag.set(false));
11201 }
11202 }
11203
11204 struct ForceLandlockFailureGuard;
11205
11206 impl ForceLandlockFailureGuard {
11207 fn new() -> Self {
11208 FORCE_LANDLOCK_FAILURE.with(|flag| flag.set(true));
11209 Self
11210 }
11211 }
11212
11213 impl Drop for ForceLandlockFailureGuard {
11214 fn drop(&mut self) {
11215 FORCE_LANDLOCK_FAILURE.with(|flag| flag.set(false));
11216 }
11217 }
11218
11219 fn kernel_full_empty_grants() -> ShellEnforcement {
11223 ShellEnforcement {
11224 tier: EnforcementTier::KernelFull,
11225 network_allow_ips: Vec::new(),
11226 unix_sockets_allowed: false,
11227 landlock_grants: Vec::new(),
11228 ..host_bounding_base()
11229 }
11230 }
11231
11232 fn workdir_resolution_error() -> String {
11236 let temp = tempfile::tempdir().unwrap();
11237 let missing = temp.path().join("does-not-exist-workdir");
11238 let marker = temp.path().join("never-spawned.marker");
11239 let mut command = std::process::Command::new("bash");
11240 command
11241 .arg("-c")
11242 .arg(format!("echo ran > '{}'", marker.display()));
11243
11244 let error = prepare_enforcement(&mut command, &kernel_full_empty_grants(), &missing)
11245 .expect_err("an unresolvable workdir must fail before fork()");
11246 assert!(
11247 !marker.exists(),
11248 "prepare_enforcement must never spawn a subprocess: {error}"
11249 );
11250 error
11251 }
11252
11253 fn child_setup_failure_error<G>(arm: impl FnOnce() -> G) -> String {
11257 child_setup_failure_typed(&kernel_full_empty_grants(), arm).to_string()
11258 }
11259
11260 fn child_setup_failure_typed<G>(
11265 enforcement: &ShellEnforcement,
11266 arm: impl FnOnce() -> G,
11267 ) -> crate::shell::ShellExecError {
11268 let temp = tempfile::tempdir().unwrap();
11269 let policy = CapabilityPolicy {
11270 shell_allow: vec!["bash".to_string()],
11271 ..CapabilityPolicy::default()
11272 };
11273 let marker = temp.path().join("spawned-anyway.marker");
11274 let script = format!("echo ran > '{}'", marker.display());
11275
11276 let _guard = arm();
11277 let error = crate::shell::execute_shell(
11278 "bash",
11279 &["-c", &script],
11280 &[],
11281 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
11282 &policy,
11283 enforcement,
11284 )
11285 .expect_err("a forced child-side setup failure must make execute_shell return Err");
11286 assert!(
11287 !marker.exists(),
11288 "no subprocess may run when pre_exec setup fails: {error}"
11289 );
11290 error
11291 }
11292
11293 #[test]
11294 fn pre_exec_workdir_resolution_failure_is_caught_before_fork() {
11295 let error = workdir_resolution_error();
11296 assert!(
11297 error.contains("workdir") && error.contains("does-not-exist-workdir"),
11298 "the error must name the workdir path that failed to resolve: {error}"
11299 );
11300 assert!(
11301 !error.contains("os error 22"),
11302 "must not collapse to the bare EINVAL string: {error}"
11303 );
11304 }
11305
11306 #[test]
11307 fn pre_exec_no_new_privs_failure_is_distinct_and_fails_closed() {
11308 if crate::network_namespace::skip_without_egress_namespace(
11309 "pre_exec_no_new_privs_failure_is_distinct_and_fails_closed",
11310 ) {
11311 return;
11312 }
11313 let error = child_setup_failure_error(ForceNoNewPrivsFailureGuard::new);
11314 assert!(
11315 error.contains("no_new_privs"),
11316 "the error must name the no_new_privs step specifically: {error}"
11317 );
11318 assert!(
11319 !error.contains("os error 22"),
11320 "must not collapse to the bare EINVAL string: {error}"
11321 );
11322 }
11323
11324 #[test]
11325 fn pre_exec_landlock_failure_is_distinct_and_fails_closed() {
11326 if crate::network_namespace::skip_without_egress_namespace(
11327 "pre_exec_landlock_failure_is_distinct_and_fails_closed",
11328 ) {
11329 return;
11330 }
11331 let error = child_setup_failure_error(ForceLandlockFailureGuard::new);
11332 assert!(
11333 error.contains("landlock"),
11334 "the error must name the Landlock construction step specifically: {error}"
11335 );
11336 assert!(
11337 !error.contains("os error 22"),
11338 "must not collapse to the bare EINVAL string: {error}"
11339 );
11340 }
11341
11342 #[test]
11343 fn pre_exec_setup_failures_produce_pairwise_distinct_messages() {
11344 if crate::network_namespace::skip_without_egress_namespace(
11345 "pre_exec_setup_failures_produce_pairwise_distinct_messages",
11346 ) {
11347 return;
11348 }
11349 let workdir_msg = workdir_resolution_error();
11350 let no_new_privs_msg = child_setup_failure_error(ForceNoNewPrivsFailureGuard::new);
11351 let landlock_msg = child_setup_failure_error(ForceLandlockFailureGuard::new);
11352
11353 let messages = [
11354 ("workdir", &workdir_msg),
11355 ("no_new_privs", &no_new_privs_msg),
11356 ("landlock", &landlock_msg),
11357 ];
11358 for (i, (left_name, left)) in messages.iter().enumerate() {
11359 for (right_name, right) in &messages[i + 1..] {
11360 assert_ne!(left, right, "{left_name} vs {right_name} must differ");
11361 }
11362 }
11363
11364 for message in [&workdir_msg, &no_new_privs_msg, &landlock_msg] {
11365 assert!(!message.is_empty(), "a distinct message must not be empty");
11366 assert!(
11367 !message.contains("os error 22"),
11368 "no failure may read as a bare EINVAL: {message}"
11369 );
11370 }
11371 }
11372
11373 #[test]
11379 fn a_composed_root_failure_is_typed_and_session_fatal_not_just_another_message() {
11380 if crate::network_namespace::skip_without_egress_namespace(
11381 "a_composed_root_failure_is_typed_and_session_fatal_not_just_another_message",
11382 ) {
11383 return;
11384 }
11385 let error =
11386 child_setup_failure_typed(&sealed_test_enforcement(), ForceSealedRootFailureGuard::new);
11387
11388 assert!(
11389 matches!(
11390 error,
11391 crate::shell::ShellExecError::SealedRootConstructionFailed { .. }
11392 ),
11393 "a sealed-root diagnostic must survive as its own variant, not collapse into \
11394 ShellExecError::Failed: {error}"
11395 );
11396 let fatal = error
11397 .session_fatal()
11398 .expect("a composed-root failure must end the session, not just the tool call");
11399 assert!(
11400 matches!(
11401 fatal,
11402 crate::errors::RuntimeError::SealedRootConstructionFailed { .. }
11403 ),
11404 "must map to the RuntimeError the CLI renders as E-RUN-014: {fatal}"
11405 );
11406 let rendered = error.to_string();
11407 assert!(
11408 rendered.contains("composed root") && rendered.contains("unshare"),
11409 "the message must name both the mechanism and the step that failed: {rendered}"
11410 );
11411 assert!(
11412 !rendered.contains("os error 22"),
11413 "must not collapse to the bare EINVAL string: {rendered}"
11414 );
11415 }
11416
11417 #[test]
11421 fn an_ordinary_pre_exec_failure_is_not_session_fatal() {
11422 let landlock =
11423 child_setup_failure_typed(&kernel_full_empty_grants(), ForceLandlockFailureGuard::new);
11424 assert!(
11425 landlock.session_fatal().is_none(),
11426 "a Landlock setup failure is the tool call's problem, not the session's: {landlock}"
11427 );
11428
11429 let no_new_privs = child_setup_failure_typed(
11430 &kernel_full_empty_grants(),
11431 ForceNoNewPrivsFailureGuard::new,
11432 );
11433 assert!(
11434 no_new_privs.session_fatal().is_none(),
11435 "a no_new_privs failure is the tool call's problem, not the session's: {no_new_privs}"
11436 );
11437 }
11438
11439 fn server_policy(extra: &[&str]) -> CapabilityPolicy {
11447 CapabilityPolicy {
11448 shell_allow: ["bash", "nc", "sleep"]
11449 .iter()
11450 .chain(extra)
11451 .map(|binary| binary.to_string())
11452 .collect(),
11453 ..CapabilityPolicy::default()
11454 }
11455 }
11456
11457 fn session_enforcement(policy: &CapabilityPolicy) -> ShellEnforcement {
11459 let enforcement = ShellEnforcement::resolve_declared(
11460 policy,
11461 murmur_artifact::ContainmentClass::Scoped,
11462 HostProbe::probe(),
11463 )
11464 .expect("the policy resolves")
11465 .with_test_session_dirs();
11466 assert_eq!(
11467 enforcement.tier,
11468 EnforcementTier::KernelFull,
11469 "a scoped capsule on this host runs on KernelFull"
11470 );
11471 enforcement
11472 }
11473
11474 fn one_shot_server(reply: &str, port: u16) -> String {
11477 format!(
11478 "(printf \"{reply}\\n\" | nc -N -l 127.0.0.1 {port}) >/dev/null 2>&1 & echo $! > server.pid"
11479 )
11480 }
11481
11482 fn retrying_client(port: u16) -> String {
11485 format!(
11486 "for i in {{1..50}}; do exec 3<>/dev/tcp/127.0.0.1/{port} 2>/dev/null && break; \
11487 sleep 0.1; done; read -r reply <&3; echo \"$reply\""
11488 )
11489 }
11490
11491 fn bash(
11492 script: &str,
11493 workdir: &Path,
11494 policy: &CapabilityPolicy,
11495 enforcement: &ShellEnforcement,
11496 ) -> Result<crate::shell::ShellResult, crate::shell::ShellExecError> {
11497 crate::shell::execute_shell(
11498 "bash",
11499 &["-c", script],
11500 &[],
11501 &crate::workdir_fs::WorkdirRoot::open(workdir).unwrap(),
11502 policy,
11503 enforcement,
11504 )
11505 }
11506
11507 fn pid_in(workdir: &Path, file: &str) -> i32 {
11508 std::fs::read_to_string(workdir.join(file))
11509 .unwrap_or_else(|error| panic!("{file} was not written: {error}"))
11510 .trim()
11511 .parse()
11512 .unwrap_or_else(|error| panic!("{file} holds no pid: {error}"))
11513 }
11514
11515 fn process_exists(pid: i32) -> bool {
11516 Path::new(&format!("/proc/{pid}")).exists()
11517 }
11518
11519 fn process_gone_within(pid: i32, timeout: std::time::Duration) -> bool {
11522 let deadline = std::time::Instant::now() + timeout;
11523 while process_exists(pid) {
11524 if std::time::Instant::now() >= deadline {
11525 return false;
11526 }
11527 std::thread::sleep(std::time::Duration::from_millis(20));
11528 }
11529 true
11530 }
11531
11532 #[test]
11534 #[allow(clippy::print_stdout)]
11535 fn a_listener_started_by_one_command_answers_the_next() {
11536 if crate::network_namespace::skip_without_egress_namespace(
11537 "a_listener_started_by_one_command_answers_the_next",
11538 ) {
11539 return;
11540 }
11541 let temp = tempfile::tempdir().unwrap();
11542 let policy = server_policy(&[]);
11543 let enforcement = session_enforcement(&policy);
11544
11545 let started = std::time::Instant::now();
11546 let server = bash(
11547 &one_shot_server("pong", 8080),
11548 temp.path(),
11549 &policy,
11550 &enforcement,
11551 )
11552 .expect("the server command runs");
11553 let server_ms = started.elapsed().as_millis();
11554 assert_eq!(server.exit_code, 0, "stderr: {}", server.stderr);
11555 assert!(
11556 started.elapsed() < std::time::Duration::from_secs(5),
11557 "a backgrounded server with its output redirected must not hold the command open"
11558 );
11559
11560 let client = bash(&retrying_client(8080), temp.path(), &policy, &enforcement)
11561 .expect("the client command runs");
11562 println!(
11563 "tier {:?}; command 1 exit {} in {server_ms} ms; command 2 exit {} stdout {:?}; \
11564 namespaces created: {}",
11565 enforcement.tier,
11566 server.exit_code,
11567 client.exit_code,
11568 client.stdout.trim(),
11569 enforcement.session_netns.creations()
11570 );
11571 assert_eq!(client.exit_code, 0, "stderr: {}", client.stderr);
11572 assert_eq!(client.stdout.trim(), "pong");
11573 assert_eq!(enforcement.session_netns.creations(), 1);
11574
11575 enforcement.end_session();
11576 }
11577
11578 #[test]
11581 #[allow(clippy::print_stdout)]
11582 fn a_demoted_server_stays_reachable_from_later_commands() {
11583 use crate::detached::{DetachPolicy, DetachedRegistry, DetachedReport};
11584
11585 if crate::network_namespace::skip_without_egress_namespace(
11586 "a_demoted_server_stays_reachable_from_later_commands",
11587 ) {
11588 return;
11589 }
11590 let temp = tempfile::tempdir().unwrap();
11591 let base = tempfile::tempdir().unwrap();
11592 let policy = server_policy(&[]);
11593 let enforcement =
11594 session_enforcement(&policy).with_runtime_dir(test_runtime_dir(base.path()), None);
11595 let (registry, mut reports) = DetachedRegistry::new();
11596
11597 let outcome = crate::shell::run_shell(
11598 "nc",
11599 &["-l", "127.0.0.1", "8080"],
11600 &[],
11601 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
11602 &policy,
11603 &enforcement,
11604 Some(DetachPolicy {
11605 grace: Some(std::time::Duration::ZERO),
11606 delivered: true,
11607 registry: Arc::clone(®istry),
11608 command: "-l 127.0.0.1 8080".to_string(),
11609 context_id: "ctx_demoted_server".to_string(),
11610 provenance: None,
11611 abandoned: None,
11612 canceled: None,
11613 }),
11614 )
11615 .expect("the server command runs");
11616 let crate::shell::ShellOutcome::Detached(info) = outcome else {
11617 panic!("a zero grace period demotes the server, got {outcome:?}");
11618 };
11619
11620 let client = bash(
11621 "for i in {1..50}; do exec 3<>/dev/tcp/127.0.0.1/8080 2>/dev/null && break; \
11622 sleep 0.1; done; echo ping >&3",
11623 temp.path(),
11624 &policy,
11625 &enforcement,
11626 )
11627 .expect("the client command runs");
11628 assert_eq!(client.exit_code, 0, "stderr: {}", client.stderr);
11629
11630 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
11631 let completion = loop {
11632 match reports.try_recv() {
11633 Ok(DetachedReport::Completed(completion)) => break completion,
11634 Ok(DetachedReport::Lost(_)) => {}
11635 Err(_) if std::time::Instant::now() < deadline => {
11636 std::thread::sleep(std::time::Duration::from_millis(20));
11637 }
11638 Err(error) => panic!("the demoted server never completed: {error}"),
11639 }
11640 };
11641 let log = std::fs::read_to_string(&completion.output_path)
11642 .expect("the demoted command's log is written");
11643 println!(
11644 "demoted as {}; client exit {}; completion {} exit {}; log has ping: {}",
11645 info.work_id,
11646 client.exit_code,
11647 completion.work_id,
11648 completion.exit_code,
11649 log.contains("ping")
11650 );
11651 assert_eq!(completion.work_id, info.work_id);
11652 assert_eq!(completion.exit_code, 0, "log: {log}");
11653 assert!(log.contains("ping"), "log: {log}");
11654
11655 enforcement.end_session();
11656 }
11657
11658 #[test]
11663 #[allow(clippy::print_stdout)]
11664 fn a_truncated_log_reads_back_through_the_shell_tool_and_refuses_a_write() {
11665 if crate::network_namespace::skip_without_egress_namespace(
11668 "a_truncated_log_reads_back_through_the_shell_tool_and_refuses_a_write",
11669 ) {
11670 return;
11671 }
11672 let base = home_tempdir();
11673 let workdir = home_tempdir();
11674 let policy = CapabilityPolicy {
11675 shell_allow: vec!["bash".to_string(), "grep".to_string()],
11676 ..CapabilityPolicy::default()
11677 };
11678 let enforcement = ShellEnforcement::resolve_declared(
11679 &policy,
11680 murmur_artifact::ContainmentClass::Scoped,
11681 HostProbe::probe(),
11682 )
11683 .expect("the policy resolves")
11684 .with_runtime_dir(test_runtime_dir(base.path()), None);
11685 let landlock = matches!(
11686 enforcement.tier,
11687 EnforcementTier::KernelFull | EnforcementTier::KernelSealed
11688 );
11689 println!(
11690 "tier {:?}; landlock ABI {:?}",
11691 enforcement.tier,
11692 detect_landlock_abi()
11693 );
11694
11695 let printed = run_bash_in(
11697 workdir.path(),
11698 "for ((i = 1; i <= 20000; i++)); do echo $i; done",
11699 &policy,
11700 &enforcement,
11701 );
11702 assert_eq!(printed.exit_code, 0, "{}", printed.stderr);
11703 assert!(printed.truncated);
11704 let path = printed
11705 .full_output_path
11706 .clone()
11707 .expect("a truncated command names its log");
11708 assert!(path.starts_with(enforcement.records_dir_path().unwrap().to_str().unwrap()));
11709 let before = std::fs::read(&path).unwrap();
11710
11711 let through_bash = run_bash_in(
11712 workdir.path(),
11713 &format!("grep -x 20000 {path}"),
11714 &policy,
11715 &enforcement,
11716 );
11717 assert_eq!(through_bash.exit_code, 0, "{}", through_bash.stderr);
11718 assert_eq!(through_bash.stdout, "20000\n");
11719
11720 let through_grep = crate::shell::execute_shell(
11721 "grep",
11722 &["-x", "20000", &path],
11723 &[],
11724 &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
11725 &policy,
11726 &enforcement,
11727 )
11728 .unwrap();
11729 assert_eq!(through_grep.exit_code, 0, "{}", through_grep.stderr);
11730 assert_eq!(through_grep.stdout, "20000\n");
11731
11732 if landlock {
11733 let write = run_bash_in(
11734 workdir.path(),
11735 &format!("echo x >> {path}"),
11736 &policy,
11737 &enforcement,
11738 );
11739 assert_ne!(write.exit_code, 0, "a command appended to its own log");
11740 assert_eq!(std::fs::read(&path).unwrap(), before);
11741 } else {
11742 println!(
11743 "[SKIP-HOST] a_truncated_log_reads_back_through_the_shell_tool_and_refuses_a_write: \
11744 the write half needs Landlock; detected {:?}",
11745 enforcement.tier
11746 );
11747 }
11748 assert!(!workdir.path().join("logs").exists());
11749 enforcement.end_session();
11750 }
11751
11752 #[test]
11755 #[allow(clippy::print_stdout)]
11756 fn two_sessions_do_not_share_loopback() {
11757 if crate::network_namespace::skip_without_egress_namespace(
11758 "two_sessions_do_not_share_loopback",
11759 ) {
11760 return;
11761 }
11762 let port = {
11763 let probe = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
11764 probe.local_addr().unwrap().port()
11765 };
11766 let policy = server_policy(&[]);
11767 let (dir_a, dir_b) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
11768 let session_a = session_enforcement(&policy);
11769 let session_b = session_enforcement(&policy);
11770
11771 for (dir, session, reply) in [
11772 (&dir_a, &session_a, "pong-a"),
11773 (&dir_b, &session_b, "pong-b"),
11774 ] {
11775 let server = bash(&one_shot_server(reply, port), dir.path(), &policy, session)
11776 .expect("the server command runs");
11777 assert_eq!(server.exit_code, 0, "stderr: {}", server.stderr);
11778 }
11779 std::thread::sleep(std::time::Duration::from_millis(300));
11781 for dir in [&dir_a, &dir_b] {
11782 let pid = pid_in(dir.path(), "server.pid");
11783 assert!(process_exists(pid), "a server's bind on {port} failed");
11784 }
11785
11786 let host = std::net::TcpStream::connect(("127.0.0.1", port));
11787 let host_error = host.expect_err("the host must not reach a session's loopback server");
11788 assert_eq!(host_error.kind(), std::io::ErrorKind::ConnectionRefused);
11789
11790 let client_b = bash(&retrying_client(port), dir_b.path(), &policy, &session_b)
11791 .expect("B's client runs");
11792 assert_eq!(client_b.exit_code, 0, "stderr: {}", client_b.stderr);
11793
11794 let second_b = bash(
11796 &format!("exec 3<>/dev/tcp/127.0.0.1/{port}"),
11797 dir_b.path(),
11798 &policy,
11799 &session_b,
11800 )
11801 .expect("B's second client runs");
11802
11803 let client_a = bash(&retrying_client(port), dir_a.path(), &policy, &session_a)
11804 .expect("A's client runs");
11805 println!(
11806 "port {port}; host connect: {host_error}; B read {:?}; B's second client exit {} \
11807 stderr {:?}; A read {:?}",
11808 client_b.stdout.trim(),
11809 second_b.exit_code,
11810 second_b.stderr.trim(),
11811 client_a.stdout.trim()
11812 );
11813 assert_eq!(client_b.stdout.trim(), "pong-b");
11814 assert_ne!(second_b.exit_code, 0);
11815 assert!(
11816 second_b.stderr.contains("Connection refused"),
11817 "stderr: {}",
11818 second_b.stderr
11819 );
11820 assert_eq!(client_a.exit_code, 0, "stderr: {}", client_a.stderr);
11821 assert_eq!(client_a.stdout.trim(), "pong-a");
11822
11823 session_a.end_session();
11824 session_b.end_session();
11825 }
11826
11827 #[test]
11830 #[allow(clippy::print_stdout)]
11831 fn the_session_proxy_serves_every_command() {
11832 if crate::network_namespace::skip_without_egress_namespace(
11833 "the_session_proxy_serves_every_command",
11834 ) {
11835 return;
11836 }
11837 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
11839 let port = listener.local_addr().unwrap().port();
11840 std::thread::spawn(move || {
11841 for stream in listener.incoming() {
11842 use std::io::Write;
11843 if let Ok(mut stream) = stream {
11844 let _ = stream.write_all(b"pong\n");
11845 }
11846 }
11847 });
11848
11849 let temp = tempfile::tempdir().unwrap();
11850 let policy = CapabilityPolicy {
11851 network_allow: vec![format!("http://127.0.0.1:{port}")],
11852 ..server_policy(&[])
11853 };
11854 let enforcement = session_enforcement(&policy);
11855 let through_proxy =
11856 format!("exec 3<>/dev/tcp/127.0.0.1/{port} && read -r reply <&3 && echo \"$reply\"");
11857
11858 let first = bash(&through_proxy, temp.path(), &policy, &enforcement).unwrap();
11859 let unlisted = bash(
11860 "exec 3<>/dev/tcp/10.255.255.1/9",
11861 temp.path(),
11862 &policy,
11863 &enforcement,
11864 )
11865 .unwrap();
11866 let third = bash(&through_proxy, temp.path(), &policy, &enforcement).unwrap();
11867 println!(
11868 "first {:?} exit {}; unlisted exit {} stderr {:?}; third {:?} exit {}; namespaces \
11869 created: {}",
11870 first.stdout.trim(),
11871 first.exit_code,
11872 unlisted.exit_code,
11873 unlisted.stderr.trim(),
11874 third.stdout.trim(),
11875 third.exit_code,
11876 enforcement.session_netns.creations()
11877 );
11878 assert_eq!(first.exit_code, 0, "stderr: {}", first.stderr);
11879 assert_eq!(first.stdout.trim(), "pong");
11880 assert_ne!(unlisted.exit_code, 0, "an unlisted destination must fail");
11881 assert_eq!(third.exit_code, 0, "stderr: {}", third.stderr);
11882 assert_eq!(third.stdout.trim(), "pong");
11883 assert_eq!(enforcement.session_netns.creations(), 1);
11884
11885 enforcement.end_session();
11886 }
11887
11888 #[test]
11891 #[allow(clippy::print_stdout)]
11892 fn concurrent_first_commands_share_one_namespace() {
11893 if crate::network_namespace::skip_without_egress_namespace(
11894 "concurrent_first_commands_share_one_namespace",
11895 ) {
11896 return;
11897 }
11898 let temp = tempfile::tempdir().unwrap();
11899 let policy = server_policy(&[]);
11900 let enforcement = session_enforcement(&policy);
11901 let servers = (8081..=8083)
11904 .map(|port| one_shot_server("pong", port).replace("server.pid", &format!("{port}.pid")))
11905 .collect::<Vec<_>>()
11906 .join("; ");
11907 let scripts = [
11908 servers,
11909 retrying_client(8081),
11910 retrying_client(8082),
11911 retrying_client(8083),
11912 ];
11913
11914 let barrier = std::sync::Barrier::new(scripts.len());
11915 let results: Vec<crate::shell::ShellResult> = std::thread::scope(|scope| {
11916 let handles: Vec<_> = scripts
11917 .iter()
11918 .map(|script| {
11919 let enforcement = enforcement.clone();
11920 let (barrier, policy, workdir) = (&barrier, &policy, temp.path());
11921 scope.spawn(move || {
11922 barrier.wait();
11923 bash(script, workdir, policy, &enforcement).expect("the command runs")
11924 })
11925 })
11926 .collect();
11927 handles
11928 .into_iter()
11929 .map(|handle| handle.join().unwrap())
11930 .collect()
11931 });
11932
11933 for (index, result) in results.iter().enumerate() {
11934 println!(
11935 "command {index}: exit {} stdout {:?}",
11936 result.exit_code,
11937 result.stdout.trim()
11938 );
11939 assert_eq!(result.exit_code, 0, "stderr: {}", result.stderr);
11940 }
11941 for client in &results[1..] {
11942 assert_eq!(client.stdout.trim(), "pong");
11943 }
11944 println!(
11945 "namespaces created: {}",
11946 enforcement.session_netns.creations()
11947 );
11948 assert_eq!(enforcement.session_netns.creations(), 1);
11949
11950 enforcement.end_session();
11951 }
11952
11953 #[test]
11956 #[allow(clippy::print_stdout)]
11957 fn ending_the_session_kills_everything_it_started() {
11958 if crate::cgroup::skip_without_host_support(
11959 "ending_the_session_kills_everything_it_started",
11960 ) {
11961 return;
11962 }
11963 let temp = tempfile::tempdir().unwrap();
11964 let policy = server_policy(&["setsid"]);
11965 let scope =
11966 crate::cgroup::prepare_scope(true, &policy.resources, "ses_end_kills", temp.path())
11967 .expect("a delegated scope")
11968 .scope
11969 .expect("a required scope on Linux is never None");
11970 let enforcement =
11971 session_enforcement(&policy).with_host_bounding(Some(Arc::clone(&scope)), None);
11972
11973 let started = bash(
11974 "setsid sleep 300 >/dev/null 2>&1 & echo $! > sleeper.pid; \
11975 nc -l 127.0.0.1 8080 >/dev/null 2>&1 & echo $! > server.pid",
11976 temp.path(),
11977 &policy,
11978 &enforcement,
11979 )
11980 .expect("the command runs");
11981 assert_eq!(started.exit_code, 0, "stderr: {}", started.stderr);
11982 let sleeper = pid_in(temp.path(), "sleeper.pid");
11983 let server = pid_in(temp.path(), "server.pid");
11984 assert!(process_exists(sleeper), "the setsid sleeper is running");
11985 assert!(process_exists(server), "the background server is running");
11986
11987 let ending = std::time::Instant::now();
11988 enforcement.end_session();
11989 let took = ending.elapsed();
11990
11991 let sleeper_gone = process_gone_within(sleeper, std::time::Duration::from_secs(2));
11992 let server_gone = process_gone_within(server, std::time::Duration::from_secs(2));
11993 let procs = std::fs::read_to_string(scope.path().join("cgroup.procs")).unwrap();
11994 let after = bash("true", temp.path(), &policy, &enforcement);
11995 println!(
11996 "end_session took {} ms; sleeper {sleeper} gone: {sleeper_gone}; server {server} \
11997 gone: {server_gone}; cgroup.procs after: {:?}; next command: {:?}",
11998 took.as_millis(),
11999 procs.trim(),
12000 after.as_ref().map(|result| result.exit_code)
12001 );
12002 assert!(took < std::time::Duration::from_secs(3), "took {took:?}");
12003 assert!(sleeper_gone, "the setsid sleeper outlived its session");
12004 assert!(server_gone, "the background server outlived its session");
12005 assert!(procs.trim().is_empty(), "cgroup.procs: {procs}");
12006 let refusal = after
12007 .expect_err("a command after the session ended must be refused")
12008 .to_string();
12009 assert!(refusal.contains("session has ended"), "{refusal}");
12010 }
12011
12012 fn home_tempdir() -> tempfile::TempDir {
12020 tempfile::tempdir_in(std::env::var_os("HOME").expect("HOME is set")).unwrap()
12021 }
12022
12023 fn mapped_enforcement(
12028 test: &str,
12029 policy: &CapabilityPolicy,
12030 class: murmur_artifact::ContainmentClass,
12031 base: &Path,
12032 workdir: &Path,
12033 ) -> Option<ShellEnforcement> {
12034 if crate::network_namespace::skip_without_egress_namespace(test) {
12035 return None;
12036 }
12037 let probe = HostProbe::probe();
12038 let mount_namespace_usable = probe.mount_namespace_usable();
12039 let enforcement = ShellEnforcement::resolve_declared(policy, class, probe)
12040 .unwrap()
12041 .with_runtime_dir(test_runtime_dir(base), None);
12042 let mapping =
12043 crate::temp_dir::temp_mapping(enforcement.tier, mount_namespace_usable, workdir);
12044 if mapping != crate::temp_dir::TempMapping::MountNamespace {
12045 crate::runtime_err!(
12046 "[SKIP-HOST] {test}: this host maps no private /tmp at {class:?} ({mapping:?} on \
12047 {:?})",
12048 enforcement.tier
12049 );
12050 return None;
12051 }
12052 Some(enforcement.with_temp_mapping(mapping))
12053 }
12054
12055 fn unmapped_kernel_full_enforcement(
12058 test: &str,
12059 policy: &CapabilityPolicy,
12060 base: &Path,
12061 workdir: &Path,
12062 ) -> Option<ShellEnforcement> {
12063 if crate::network_namespace::skip_without_egress_namespace(test) {
12064 return None;
12065 }
12066 let live = HostProbe::probe();
12067 if !matches!(
12068 live.tier(),
12069 EnforcementTier::KernelFull | EnforcementTier::KernelSealed
12070 ) {
12071 crate::runtime_err!(
12072 "[SKIP-HOST] {test}: needs Landlock; detected {:?}",
12073 live.tier()
12074 );
12075 return None;
12076 }
12077 let refused = crate::namespace_probe::MountAttempt::Failed(
12078 crate::namespace_probe::MountAttemptError::Step {
12079 step: crate::namespace_probe::MountStep::UnshareMount,
12080 errno: libc::EPERM,
12081 },
12082 );
12083 let probe = HostProbe::from_parts(
12084 Some(true),
12085 crate::namespace_probe::NamespaceReading {
12086 mount: refused.clone(),
12087 sealed_root: refused,
12088 ..live.namespaces().clone()
12089 },
12090 )
12091 .with_landlock_kernel_abi(live.landlock_kernel_abi());
12092 assert!(!probe.mount_namespace_usable());
12093 let enforcement = ShellEnforcement::resolve_declared(
12094 policy,
12095 murmur_artifact::ContainmentClass::Scoped,
12096 probe,
12097 )
12098 .unwrap()
12099 .with_runtime_dir(test_runtime_dir(base), None);
12100 assert_eq!(enforcement.tier, EnforcementTier::KernelFull);
12101 let mapping = crate::temp_dir::temp_mapping(enforcement.tier, false, workdir);
12102 assert_eq!(
12103 mapping,
12104 crate::temp_dir::TempMapping::NotMapped(
12105 crate::temp_dir::NotMappedReason::NoMountNamespace
12106 )
12107 );
12108 Some(enforcement.with_temp_mapping(mapping))
12109 }
12110
12111 fn unique_tmp_name(label: &str) -> String {
12113 static NEXT: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
12114 format!(
12115 "murmur-probe-{label}-{}-{}",
12116 std::process::id(),
12117 NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
12118 )
12119 }
12120
12121 #[test]
12125 fn the_tmp_map_spec_reexposes_exact_paths_beneath_tmp_read_only_but_the_workdir() {
12126 let base = tempfile::tempdir().unwrap();
12127 let workdir = tempfile::tempdir().unwrap();
12128 let grant_dir = tempfile::tempdir().unwrap();
12129 let grant = grant_dir.path().join("bin");
12130 std::fs::write(&grant, b"#!/bin/sh\n").unwrap();
12131 let mut enforcement = ShellEnforcement {
12132 tier: EnforcementTier::KernelFull,
12133 ..ShellEnforcement::environment_only().with_test_session_dirs()
12134 }
12135 .with_runtime_dir(test_runtime_dir(base.path()), None)
12136 .with_temp_mapping(crate::temp_dir::TempMapping::MountNamespace);
12137 enforcement.landlock_grants =
12138 LandlockGrant::non_listable_files(vec![grant.clone(), PathBuf::from("/usr/bin/env")]);
12139 let folder = enforcement
12140 .runtime_dir
12141 .as_ref()
12142 .and_then(|roots| roots.temp.as_ref())
12143 .unwrap()
12144 .path_fd()
12145 .unwrap();
12146
12147 let spec = build_tmp_map_spec(&enforcement, &folder, workdir.path()).unwrap();
12148
12149 let canonical = |path: &Path| {
12150 std::fs::canonicalize(path)
12151 .unwrap()
12152 .to_string_lossy()
12153 .into_owned()
12154 };
12155 let mut expected = vec![
12156 (canonical(workdir.path()), false),
12157 (
12158 canonical(&base.path().join(crate::runtime_dir::RUNTIME_SUBDIR)),
12159 true,
12160 ),
12161 (
12162 canonical(&base.path().join(crate::records_dir::RECORDS_DIR_NAME)),
12163 true,
12164 ),
12165 (
12166 canonical(
12167 &base
12168 .path()
12169 .join(crate::records_dir::RECORDS_DIR_NAME)
12170 .join(crate::records_dir::HOME_SUBDIR),
12171 ),
12172 false,
12173 ),
12174 (canonical(&grant), true),
12175 ];
12176 expected.sort();
12177 assert_eq!(spec.reexposed_sources(), expected);
12178 }
12179
12180 #[test]
12183 fn a_native_tools_store_under_tmp_is_reexposed_writable() {
12184 let base = tempfile::tempdir_in(crate::temp_dir::canonical_tmp()).unwrap();
12185 let workdir = tempfile::tempdir().unwrap();
12186 let store = base.path().join("state").join("config-capsule");
12187 std::fs::create_dir_all(&store).unwrap();
12188 let session = ShellEnforcement {
12189 tier: EnforcementTier::KernelFull,
12190 ..ShellEnforcement::environment_only().with_test_session_dirs()
12191 }
12192 .with_runtime_dir(test_runtime_dir(base.path()), None)
12193 .with_temp_mapping(crate::temp_dir::TempMapping::MountNamespace);
12194 let folder = session
12195 .runtime_dir
12196 .as_ref()
12197 .and_then(|roots| roots.temp.as_ref())
12198 .unwrap()
12199 .path_fd()
12200 .unwrap();
12201 let canonical_store = std::fs::canonicalize(&store)
12202 .unwrap()
12203 .to_string_lossy()
12204 .into_owned();
12205
12206 let native = ShellEnforcement {
12207 native_state_dir: Some(store.clone()),
12208 ..session.clone()
12209 };
12210 let spec = build_tmp_map_spec(&native, &folder, workdir.path()).unwrap();
12211 assert!(
12212 spec.reexposed_sources()
12213 .contains(&(canonical_store.clone(), false)),
12214 "{:?}",
12215 spec.reexposed_sources()
12216 );
12217
12218 let spec = build_tmp_map_spec(&session, &folder, workdir.path()).unwrap();
12219 assert!(
12220 spec.reexposed_sources()
12221 .iter()
12222 .all(|(source, _)| source != &canonical_store),
12223 "{:?}",
12224 spec.reexposed_sources()
12225 );
12226 }
12227
12228 #[test]
12231 fn a_literal_tmp_path_reaches_the_session_temp_folder() {
12232 const TEST: &str = "a_literal_tmp_path_reaches_the_session_temp_folder";
12233 for class in [
12234 murmur_artifact::ContainmentClass::Scoped,
12235 murmur_artifact::ContainmentClass::Advisory,
12236 ] {
12237 let base = home_tempdir();
12238 let workdir = home_tempdir();
12239 let policy = coreutils_policy(&[]);
12240 let Some(enforcement) =
12241 mapped_enforcement(TEST, &policy, class, base.path(), workdir.path())
12242 else {
12243 return;
12244 };
12245 let name = unique_tmp_name("literal");
12246 let result = run_bash_in(
12247 workdir.path(),
12248 &format!("echo x > /tmp/{name} && cat /tmp/{name} && printf '%s\\n' \"$TMPDIR\""),
12249 &policy,
12250 &enforcement,
12251 );
12252 assert_eq!(result.exit_code, 0, "{class:?}: {}", result.stderr);
12253 assert_eq!(result.stdout, "x\n/tmp\n", "{class:?}");
12254 let folder = enforcement.temp_dir_path().unwrap();
12255 assert_eq!(std::fs::read_to_string(folder.join(&name)).unwrap(), "x\n");
12256 assert!(
12257 !Path::new("/tmp").join(&name).exists(),
12258 "{class:?}: the host's /tmp gained {name}"
12259 );
12260 }
12261 }
12262
12263 #[test]
12266 fn a_literal_tmp_path_reaches_the_session_temp_folder_on_the_seccomp_only_tier() {
12267 const TEST: &str =
12268 "a_literal_tmp_path_reaches_the_session_temp_folder_on_the_seccomp_only_tier";
12269 let base = home_tempdir();
12270 let workdir = home_tempdir();
12271 let policy = coreutils_policy(&[]);
12272 let Some(enforcement) = mapped_enforcement(
12273 TEST,
12274 &policy,
12275 murmur_artifact::ContainmentClass::Scoped,
12276 base.path(),
12277 workdir.path(),
12278 ) else {
12279 return;
12280 };
12281 let enforcement = ShellEnforcement {
12282 tier: EnforcementTier::KernelSeccompOnly,
12283 ..enforcement
12284 };
12285 assert_eq!(
12286 enforcement.installed_temp_mapping(),
12287 crate::temp_dir::TempMapping::MountNamespace
12288 );
12289 let name = unique_tmp_name("seccomp-only");
12290 let result = run_bash_in(
12291 workdir.path(),
12292 &format!("echo x > /tmp/{name} && cat /tmp/{name} && printf '%s\\n' \"$TMPDIR\""),
12293 &policy,
12294 &enforcement,
12295 );
12296 assert_eq!(result.exit_code, 0, "{}", result.stderr);
12297 assert_eq!(result.stdout, "x\n/tmp\n");
12298 let folder = enforcement.temp_dir_path().unwrap();
12299 assert_eq!(std::fs::read_to_string(folder.join(&name)).unwrap(), "x\n");
12300 assert!(
12301 !Path::new("/tmp").join(&name).exists(),
12302 "the host's /tmp gained {name}"
12303 );
12304 }
12305
12306 #[test]
12308 fn two_sessions_do_not_share_tmp() {
12309 const TEST: &str = "two_sessions_do_not_share_tmp";
12310 let policy = coreutils_policy(&[]);
12311 let (base_a, base_b) = (home_tempdir(), home_tempdir());
12312 let (work_a, work_b) = (home_tempdir(), home_tempdir());
12313 let class = murmur_artifact::ContainmentClass::Scoped;
12314 let Some(a) = mapped_enforcement(TEST, &policy, class, base_a.path(), work_a.path()) else {
12315 return;
12316 };
12317 let Some(b) = mapped_enforcement(TEST, &policy, class, base_b.path(), work_b.path()) else {
12318 return;
12319 };
12320 let name = unique_tmp_name("shared");
12321
12322 let first = run_bash_in(work_a.path(), &format!("echo a > /tmp/{name}"), &policy, &a);
12323 assert_eq!(first.exit_code, 0, "{}", first.stderr);
12324 let second = run_bash_in(
12325 work_b.path(),
12326 &format!("test ! -e /tmp/{name} && echo b > /tmp/{name} && cat /tmp/{name}"),
12327 &policy,
12328 &b,
12329 );
12330 assert_eq!(second.exit_code, 0, "{}", second.stderr);
12331 assert_eq!(second.stdout, "b\n");
12332 let again = run_bash_in(work_a.path(), &format!("cat /tmp/{name}"), &policy, &a);
12333 assert_eq!(again.stdout, "a\n", "{}", again.stderr);
12334 assert_ne!(a.temp_dir_path(), b.temp_dir_path());
12335 assert!(!Path::new("/tmp").join(&name).exists());
12336 }
12337
12338 #[test]
12341 fn a_workdir_under_tmp_stays_reachable_when_tmp_is_mapped() {
12342 const TEST: &str = "a_workdir_under_tmp_stays_reachable_when_tmp_is_mapped";
12343 let base = tempfile::tempdir().unwrap();
12344 let workdir = tempfile::tempdir().unwrap();
12345 assert!(workdir.path().starts_with(crate::temp_dir::canonical_tmp()));
12346 let policy = coreutils_policy(&[]);
12347 let Some(enforcement) = mapped_enforcement(
12348 TEST,
12349 &policy,
12350 murmur_artifact::ContainmentClass::Scoped,
12351 base.path(),
12352 workdir.path(),
12353 ) else {
12354 return;
12355 };
12356 std::fs::write(
12357 enforcement.runtime_dir_path().unwrap().join("probe"),
12358 b"runtime\n",
12359 )
12360 .unwrap();
12361 let name = unique_tmp_name("under");
12362 let result = run_bash_in(
12363 workdir.path(),
12364 &format!(
12365 "echo a > \"$(pwd)/f\" && cat \"$(pwd)/f\" && cat \"$MURMUR_RUNTIME_DIR/probe\" && \
12366 echo h > \"$HOME/h\" && cat \"$HOME/h\" && echo t > /tmp/{name} && \
12367 cat /tmp/{name}"
12368 ),
12369 &policy,
12370 &enforcement,
12371 );
12372 assert_eq!(result.exit_code, 0, "{}", result.stderr);
12373 assert_eq!(result.stdout, "a\nruntime\nh\nt\n");
12374 assert_eq!(
12375 std::fs::read_to_string(workdir.path().join("f")).unwrap(),
12376 "a\n"
12377 );
12378
12379 let sibling = base.path().join("sibling");
12383 std::fs::write(&sibling, b"host\n").unwrap();
12384 let record = enforcement.records_dir_path().unwrap().join("trace.jsonl");
12385 std::fs::write(&record, b"{}\n").unwrap();
12386 let refused = run_bash_in(
12387 workdir.path(),
12388 &format!(
12389 "test ! -e {sibling} && echo absent; echo z >> \"$MURMUR_RECORDS_DIR/trace.jsonl\"",
12390 sibling = sibling.display()
12391 ),
12392 &policy,
12393 &enforcement,
12394 );
12395 assert!(refused.stdout.starts_with("absent\n"), "{}", refused.stderr);
12396 assert_ne!(refused.exit_code, 0);
12397 assert!(
12398 refused.stderr.contains("Read-only file system"),
12399 "{}",
12400 refused.stderr
12401 );
12402 assert_eq!(std::fs::read(&record).unwrap(), b"{}\n");
12403 let folder = enforcement.temp_dir_path().unwrap();
12404 assert_eq!(std::fs::read_to_string(folder.join(&name)).unwrap(), "t\n");
12405 assert!(!Path::new("/tmp").join(&name).exists());
12406 }
12407
12408 #[test]
12411 fn without_a_mount_namespace_tmp_is_refused_and_tmpdir_works() {
12412 const TEST: &str = "without_a_mount_namespace_tmp_is_refused_and_tmpdir_works";
12413 let base = home_tempdir();
12414 let workdir = home_tempdir();
12415 let policy = coreutils_policy(&[]);
12416 let Some(enforcement) =
12417 unmapped_kernel_full_enforcement(TEST, &policy, base.path(), workdir.path())
12418 else {
12419 return;
12420 };
12421 let name = unique_tmp_name("refused");
12422 let refused = run_bash_in(
12423 workdir.path(),
12424 &format!("echo y > /tmp/{name}"),
12425 &policy,
12426 &enforcement,
12427 );
12428 assert_ne!(refused.exit_code, 0);
12429 assert!(
12430 refused.stderr.contains("Permission denied"),
12431 "{}",
12432 refused.stderr
12433 );
12434 assert!(!Path::new("/tmp").join(&name).exists());
12435
12436 let folder = enforcement.temp_dir_path().unwrap().to_path_buf();
12437 let works = run_bash_in(
12438 workdir.path(),
12439 "echo z > \"$TMPDIR/m\" && cat \"$TMPDIR/m\" && printf '%s\\n' \"$TMPDIR\" \"$TMP\" \
12440 \"$TEMP\"",
12441 &policy,
12442 &enforcement,
12443 );
12444 assert_eq!(works.exit_code, 0, "{}", works.stderr);
12445 let folder_text = folder.to_string_lossy();
12446 assert_eq!(
12447 works.stdout,
12448 format!("z\n{folder_text}\n{folder_text}\n{folder_text}\n")
12449 );
12450 assert_eq!(std::fs::read_to_string(folder.join("m")).unwrap(), "z\n");
12451 }
12452
12453 #[test]
12456 fn a_record_cannot_be_moved_or_linked_into_tmp() {
12457 const TEST: &str = "a_record_cannot_be_moved_or_linked_into_tmp";
12458 let policy = coreutils_policy(&[]);
12459 for mapped in [true, false] {
12460 let base = home_tempdir();
12461 let workdir = home_tempdir();
12462 let enforcement = if mapped {
12463 mapped_enforcement(
12464 TEST,
12465 &policy,
12466 murmur_artifact::ContainmentClass::Scoped,
12467 base.path(),
12468 workdir.path(),
12469 )
12470 } else {
12471 unmapped_kernel_full_enforcement(TEST, &policy, base.path(), workdir.path())
12472 };
12473 let Some(enforcement) = enforcement else {
12474 return;
12475 };
12476 let records = enforcement.records_dir_path().unwrap().to_path_buf();
12477 let record = records.join("trace.jsonl");
12478 std::fs::write(&record, b"{\"event\":\"session_start\"}\n").unwrap();
12479 let before = std::fs::read(&record).unwrap();
12480 for script in [
12481 "ln \"$MURMUR_RECORDS_DIR/trace.jsonl\" /tmp/l",
12482 "ln \"$MURMUR_RECORDS_DIR/trace.jsonl\" \"$TMPDIR/l2\"",
12483 "ln \"$MURMUR_RECORDS_DIR/trace.jsonl\" \"$MURMUR_RECORDS_DIR/tmp/l3\"",
12484 "mv \"$MURMUR_RECORDS_DIR/trace.jsonl\" \"$TMPDIR/m\"",
12485 "mv \"$MURMUR_RECORDS_DIR/trace.jsonl\" \"$MURMUR_RECORDS_DIR/tmp/m2\"",
12486 "echo z >> \"$MURMUR_RECORDS_DIR/trace.jsonl\"",
12487 "mv \"$MURMUR_RECORDS_DIR/tmp\" \"$MURMUR_RECORDS_DIR/tmp-moved\"",
12488 ] {
12489 let result = run_bash_in(workdir.path(), script, &policy, &enforcement);
12490 assert_ne!(
12491 result.exit_code, 0,
12492 "mapped: {mapped}: `{script}` succeeded"
12493 );
12494 }
12495 assert_eq!(std::fs::read(&record).unwrap(), before, "mapped: {mapped}");
12496 assert!(records
12497 .join(crate::temp_dir::SESSION_TEMP_DIR_NAME)
12498 .is_dir());
12499 assert!(!records.join("tmp-moved").exists());
12500 }
12501 }
12502
12503 #[test]
12506 fn tmp_follows_workdir_exec() {
12507 const TEST: &str = "tmp_follows_workdir_exec";
12508 for (workdir_exec, expected) in [(false, 126), (true, 0)] {
12509 let base = home_tempdir();
12510 let workdir = home_tempdir();
12511 let policy = CapabilityPolicy {
12512 workdir_exec_allowed: workdir_exec,
12513 ..coreutils_policy(&["cp", "true"])
12514 };
12515 let Some(enforcement) = mapped_enforcement(
12516 TEST,
12517 &policy,
12518 murmur_artifact::ContainmentClass::Advisory,
12519 base.path(),
12520 workdir.path(),
12521 ) else {
12522 return;
12523 };
12524 let result = run_bash_in(
12525 workdir.path(),
12526 "cp /bin/true /tmp/t && /tmp/t",
12527 &policy,
12528 &enforcement,
12529 );
12530 assert_eq!(
12531 result.exit_code, expected,
12532 "workdir_exec: {workdir_exec}: {}",
12533 result.stderr
12534 );
12535 if !workdir_exec {
12536 assert!(
12537 result.stderr.contains("Permission denied"),
12538 "{}",
12539 result.stderr
12540 );
12541 }
12542 }
12543 }
12544
12545 #[test]
12547 fn a_failed_tmp_mapping_spawns_nothing() {
12548 const TEST: &str = "a_failed_tmp_mapping_spawns_nothing";
12549 let base = home_tempdir();
12550 let workdir = home_tempdir();
12551 let policy = coreutils_policy(&[]);
12552 let Some(enforcement) = mapped_enforcement(
12553 TEST,
12554 &policy,
12555 murmur_artifact::ContainmentClass::Scoped,
12556 base.path(),
12557 workdir.path(),
12558 ) else {
12559 return;
12560 };
12561 let error = {
12562 let _forced = ForceTmpMapFailureGuard::new();
12563 crate::shell::execute_shell(
12564 "bash",
12565 &["-c", "echo ran > \"$(pwd)/marker\""],
12566 &[],
12567 &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
12568 &policy,
12569 &enforcement,
12570 )
12571 .expect_err("a failed mapping fails the spawn")
12572 };
12573 let message = error.to_string();
12574 println!("{message}");
12575 assert!(
12576 message.contains("could not map /tmp onto this session's temporary folder"),
12577 "{message}"
12578 );
12579 assert!(
12580 matches!(error, crate::shell::ShellExecError::Failed(_)),
12581 "an ordinary spawn failure, not a session-fatal one: {error:?}"
12582 );
12583 assert!(!workdir.path().join("marker").exists(), "the command ran");
12584 }
12585
12586 #[test]
12589 fn a_link_swapped_onto_the_temp_folder_redirects_no_bind() {
12590 const TEST: &str = "a_link_swapped_onto_the_temp_folder_redirects_no_bind";
12591 let base = home_tempdir();
12592 let workdir = home_tempdir();
12593 let policy = coreutils_policy(&[]);
12594 let Some(enforcement) = mapped_enforcement(
12595 TEST,
12596 &policy,
12597 murmur_artifact::ContainmentClass::Scoped,
12598 base.path(),
12599 workdir.path(),
12600 ) else {
12601 return;
12602 };
12603 let folder = enforcement.temp_dir_path().unwrap().to_path_buf();
12604 let decoy = base.path().join("decoy");
12605 std::fs::create_dir(&decoy).unwrap();
12606 std::fs::rename(&folder, base.path().join("folder-moved")).unwrap();
12607 std::os::unix::fs::symlink(&decoy, &folder).unwrap();
12608
12609 let error = crate::shell::execute_shell(
12610 "bash",
12611 &["-c", "echo x > /tmp/swapped"],
12612 &[],
12613 &crate::workdir_fs::WorkdirRoot::open(workdir.path()).unwrap(),
12614 &policy,
12615 &enforcement,
12616 )
12617 .expect_err("a swapped folder fails the spawn")
12618 .to_string();
12619 println!("{error}");
12620 assert!(
12621 error.contains("could not map /tmp onto this session's temporary folder"),
12622 "{error}"
12623 );
12624 assert!(error.contains("opening the temporary folder"), "{error}");
12625 assert!(
12626 error.contains(&std::io::Error::from_raw_os_error(libc::ESTALE).to_string()),
12627 "{error}"
12628 );
12629 assert_eq!(
12630 std::fs::read_dir(&decoy).unwrap().count(),
12631 0,
12632 "the link's target was written"
12633 );
12634 }
12635
12636 fn derived_interpreter_policy(binary: &str, work: &Path) -> CapabilityPolicy {
12640 let mut policy = CapabilityPolicy {
12641 shell_allow: vec![binary.to_string()],
12642 ..CapabilityPolicy::default()
12643 };
12644 let cache = tempfile::tempdir().unwrap();
12645 policy.shell_derived_interpreter_runtime =
12646 crate::interpreter_runtime::derive_interpreter_runtimes(
12647 &policy.shell_allow,
12648 &[],
12649 &[work],
12650 &crate::host_facts::HostFactsCache::at(cache.path().join("host_facts")),
12651 )
12652 .runtimes;
12653 policy
12654 }
12655
12656 fn unsandboxed_python(program: &str) -> String {
12658 let output = std::process::Command::new("python3")
12659 .args(["-I", "-c", program])
12660 .output()
12661 .unwrap();
12662 assert!(output.status.success(), "{output:?}");
12663 String::from_utf8(output.stdout).unwrap().trim().to_string()
12664 }
12665
12666 fn assert_derives_stdlib_and_sqlite(policy: &CapabilityPolicy) -> PathBuf {
12669 let stdlib = PathBuf::from(unsandboxed_python(
12670 "import encodings, os; print(os.path.dirname(os.path.dirname(encodings.__file__)))",
12671 ));
12672 let dynload = PathBuf::from(unsandboxed_python(
12673 "import _sqlite3, os; print(os.path.dirname(os.path.realpath(_sqlite3.__file__)))",
12674 ));
12675 let derived = &policy.shell_derived_interpreter_runtime;
12676 println!("derived: {derived:#?}");
12677 let dirs: Vec<&PathBuf> = derived.iter().flat_map(|runtime| &runtime.dirs).collect();
12678 let canonical = |path: &Path| std::fs::canonicalize(path).unwrap();
12679 assert!(
12680 dirs.contains(&&canonical(&stdlib)),
12681 "{stdlib:?} not in {dirs:?}"
12682 );
12683 assert!(
12684 dirs.contains(&&canonical(&dynload)),
12685 "{dynload:?} not in {dirs:?}"
12686 );
12687 canonical(&stdlib)
12688 }
12689
12690 fn python_probe_outside(secret: &Path, parent: &Path) -> String {
12693 format!(
12694 "import os\n\
12695 for label, act in (('open', lambda: open({secret:?}).read()), \
12696 ('listdir', lambda: os.listdir({parent:?}))):\n\
12697 \x20 try:\n\
12698 \x20 act(); print(label, 'allowed')\n\
12699 \x20 except Exception as error:\n\
12700 \x20 print(label, type(error).__name__)\n",
12701 secret = secret.to_string_lossy(),
12702 parent = parent.to_string_lossy(),
12703 )
12704 }
12705
12706 fn execute(
12707 binary: &str,
12708 args: &[&str],
12709 accessible: &Path,
12710 policy: &CapabilityPolicy,
12711 enforcement: &ShellEnforcement,
12712 ) -> crate::shell::ShellResult {
12713 let result = crate::shell::execute_shell(
12714 binary,
12715 args,
12716 &[],
12717 &crate::workdir_fs::WorkdirRoot::open(accessible).unwrap(),
12718 policy,
12719 enforcement,
12720 )
12721 .unwrap();
12722 println!(
12723 "exit {}\n--- stdout\n{}--- stderr\n{}---",
12724 result.exit_code, result.stdout, result.stderr
12725 );
12726 result
12727 }
12728
12729 fn trust_store_policy(
12732 shell_allow: &[&str],
12733 network_allow: &[&str],
12734 work: &Path,
12735 ) -> CapabilityPolicy {
12736 let mut policy = CapabilityPolicy {
12737 shell_allow: shell_allow.iter().map(|name| name.to_string()).collect(),
12738 network_allow: network_allow.iter().map(|rule| rule.to_string()).collect(),
12739 ..CapabilityPolicy::default()
12740 };
12741 policy.shell_trust_store = crate::trust_store::derive_trust_store(
12742 &policy.shell_allow,
12743 &policy.network_allow,
12744 &[work],
12745 );
12746 println!("derived trust store: {:#?}", policy.shell_trust_store);
12747 policy
12748 }
12749
12750 fn host_ca_bundle() -> Option<PathBuf> {
12752 crate::trust_store::TRUST_STORE_CANDIDATES
12753 .iter()
12754 .filter(|candidate| !candidate.directory)
12755 .map(|candidate| PathBuf::from(candidate.path))
12756 .find(|path| std::fs::metadata(path).is_ok_and(|m| m.is_file()))
12757 }
12758
12759 #[test]
12762 fn a_scoped_curl_with_network_allow_completes_an_https_request() {
12763 let test = "a_scoped_curl_with_network_allow_completes_an_https_request";
12764 if find_on_path("curl").is_none() {
12765 eprintln!("SKIP — PROVES NOTHING: {test} needs curl on PATH");
12766 return;
12767 }
12768 let unsandboxed = std::process::Command::new("curl")
12769 .args([
12770 "-sS",
12771 "-o",
12772 "/dev/null",
12773 "-w",
12774 "%{http_code}",
12775 "--max-time",
12776 "15",
12777 ])
12778 .arg("https://example.com/")
12779 .output()
12780 .unwrap();
12781 if String::from_utf8_lossy(&unsandboxed.stdout) != "200" {
12782 eprintln!(
12783 "SKIP — PROVES NOTHING: {test} needs https://example.com/ to answer 200 outside \
12784 the sandbox; it gave {unsandboxed:?}"
12785 );
12786 return;
12787 }
12788 let (_base, work, _outside) = work_and_outside();
12789 let policy = trust_store_policy(&["curl"], &["example.com"], &work);
12790 assert!(policy.shell_interpreter_runtime.is_empty());
12791 assert!(
12792 !policy.shell_trust_store.is_empty(),
12793 "this host has a trust store the candidate table names"
12794 );
12795 let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
12796 return;
12797 };
12798
12799 let fetched = execute(
12800 "curl",
12801 &[
12802 "-sS",
12803 "-o",
12804 "/dev/null",
12805 "-w",
12806 "%{http_code}",
12807 "--max-time",
12808 "30",
12809 "https://example.com/",
12810 ],
12811 &work,
12812 &policy,
12813 &enforcement,
12814 );
12815 enforcement.end_session();
12816 assert_eq!(fetched.exit_code, 0, "{}", fetched.stderr);
12817 assert_eq!(fetched.stdout, "200");
12818 }
12819
12820 #[test]
12822 fn a_scoped_command_reads_the_trust_store_only_with_network_allow() {
12823 let test = "a_scoped_command_reads_the_trust_store_only_with_network_allow";
12824 assert!(
12825 crate::trust_store::derive_trust_store(&[], &["example.com".into()], &[]).is_empty()
12826 );
12827 let Some(bundle) = host_ca_bundle() else {
12828 eprintln!("SKIP — PROVES NOTHING: {test} needs a CA bundle the candidate table names");
12829 return;
12830 };
12831 let bundle_arg = bundle.to_str().unwrap();
12832 let (_base, work, _outside) = work_and_outside();
12833
12834 let offline = trust_store_policy(&["cat"], &[], &work);
12835 assert!(offline.shell_trust_store.is_empty());
12836 let online = trust_store_policy(&["cat"], &["127.0.0.1"], &work);
12837 assert!(!online.shell_trust_store.is_empty());
12838 let Some(enforcement) = scoped_landlock_enforcement(test, &offline, 1) else {
12839 return;
12840 };
12841 for derived in &online.shell_trust_store {
12842 assert!(
12843 !enforcement
12844 .landlock_grants
12845 .iter()
12846 .any(|grant| grant.path == derived.path),
12847 "{derived:?} granted without network.allow"
12848 );
12849 }
12850 let denied = execute("cat", &[bundle_arg], &work, &offline, &enforcement);
12851 enforcement.end_session();
12852 assert_ne!(denied.exit_code, 0);
12853 assert!(
12854 denied.stderr.contains("Permission denied"),
12855 "{}",
12856 denied.stderr
12857 );
12858
12859 let Some(enforcement) = scoped_landlock_enforcement(test, &online, 1) else {
12860 return;
12861 };
12862 let read = execute("cat", &[bundle_arg], &work, &online, &enforcement);
12863 enforcement.end_session();
12864 assert_eq!(read.exit_code, 0, "{}", read.stderr);
12865 assert!(
12866 read.stdout.starts_with("-----BEGIN CERTIFICATE-----"),
12867 "{}",
12868 &read.stdout[..read.stdout.len().min(200)]
12869 );
12870 }
12871
12872 #[test]
12875 fn the_trust_store_grant_reaches_nothing_beyond_the_trust_store() {
12876 let test = "the_trust_store_grant_reaches_nothing_beyond_the_trust_store";
12877 let (_base, work, _outside) = work_and_outside();
12878 let policy = trust_store_policy(&["cat", "ls", "bash"], &["127.0.0.1"], &work);
12879 for entry in &policy.shell_trust_store {
12880 for broad in ["/", "/etc", "/etc/ssl", "/etc/pki", "/etc/pki/tls"] {
12881 assert_ne!(entry.path, Path::new(broad));
12882 }
12883 assert!(
12884 !entry.path.components().any(|c| c.as_os_str() == "private"),
12885 "{entry:?}"
12886 );
12887 }
12888 assert!(resolve_trust_store_grants(&policy.shell_trust_store)
12889 .iter()
12890 .all(|grant| !grant.executable));
12891 if std::fs::metadata("/etc/ssl/certs").map_or(true, |m| !m.is_dir()) {
12892 eprintln!("SKIP — PROVES NOTHING: {test} needs /etc/ssl/certs on this host");
12893 return;
12894 }
12895 let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
12896 return;
12897 };
12898
12899 let listed = execute("ls", &["/etc/ssl/certs"], &work, &policy, &enforcement);
12900 assert_eq!(listed.exit_code, 0, "{}", listed.stderr);
12901 let above = execute("ls", &["/etc/ssl"], &work, &policy, &enforcement);
12902 assert_ne!(above.exit_code, 0);
12903 assert!(
12904 above.stderr.contains("Permission denied"),
12905 "{}",
12906 above.stderr
12907 );
12908 if std::fs::metadata("/etc/ssl/openssl.cnf").is_ok() {
12909 let config = execute(
12910 "cat",
12911 &["/etc/ssl/openssl.cnf"],
12912 &work,
12913 &policy,
12914 &enforcement,
12915 );
12916 assert_ne!(config.exit_code, 0);
12917 assert!(
12918 config.stderr.contains("Permission denied"),
12919 "{}",
12920 config.stderr
12921 );
12922 }
12923 let private = execute("ls", &["/etc/ssl/private"], &work, &policy, &enforcement);
12924 assert_ne!(private.exit_code, 0);
12925 let probe = Path::new("/etc/ssl/certs/murmur-probe");
12926 let written = execute(
12927 "bash",
12928 &["-c", "echo x > /etc/ssl/certs/murmur-probe"],
12929 &work,
12930 &policy,
12931 &enforcement,
12932 );
12933 enforcement.end_session();
12934 assert_ne!(written.exit_code, 0);
12935 assert!(std::fs::symlink_metadata(probe).is_err());
12936 }
12937
12938 #[test]
12941 fn a_scoped_command_cannot_write_the_host_facts_cache() {
12942 let test = "a_scoped_command_cannot_write_the_host_facts_cache";
12943 let home = home_tempdir();
12944 let workdir = home_tempdir();
12945 let cache = home.path().join(".murmur").join(
12946 crate::murmur_home::HomeEntryKind::HostFacts
12947 .file_name()
12948 .unwrap(),
12949 );
12950 crate::murmur_home::hold_private_dir(&home.path().join(".murmur")).unwrap();
12951 crate::murmur_home::hold_private_dir(&cache).unwrap();
12952 let policy = coreutils_policy(&[]);
12953 let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
12954 return;
12955 };
12956 let probe = cache.join("probe");
12957 let written = run_bash_in(
12958 workdir.path(),
12959 &format!("printf x > '{}'", probe.display()),
12960 &policy,
12961 &enforcement,
12962 );
12963 enforcement.end_session();
12964 assert_ne!(written.exit_code, 0);
12965 assert!(
12966 written.stderr.contains("Permission denied"),
12967 "{}",
12968 written.stderr
12969 );
12970 assert!(std::fs::symlink_metadata(&probe).is_err());
12971 }
12972
12973 #[test]
12974 fn resolve_trust_store_grants_reads_and_lists_but_never_executes() {
12975 let paths = [
12976 crate::trust_store::TrustStorePath {
12977 path: PathBuf::from("/etc/ssl/certs"),
12978 directory: true,
12979 },
12980 crate::trust_store::TrustStorePath {
12981 path: PathBuf::from("/etc/pki/tls/cacert.pem"),
12982 directory: false,
12983 },
12984 ];
12985 assert_eq!(
12986 resolve_trust_store_grants(&paths),
12987 vec![
12988 LandlockGrant {
12989 path: PathBuf::from("/etc/ssl/certs"),
12990 list_dir: true,
12991 executable: false,
12992 pinned: None,
12993 },
12994 LandlockGrant {
12995 path: PathBuf::from("/etc/pki/tls/cacert.pem"),
12996 list_dir: false,
12997 executable: false,
12998 pinned: None,
12999 },
13000 ]
13001 );
13002 assert!(resolve_trust_store_grants(&[]).is_empty());
13003 }
13004
13005 #[test]
13008 fn shell_enforcement_resolve_adds_the_trust_store() {
13009 use murmur_artifact::ContainmentClass;
13010 let store = vec![
13011 crate::trust_store::TrustStorePath {
13012 path: PathBuf::from("/murmur-test/certs"),
13013 directory: true,
13014 },
13015 crate::trust_store::TrustStorePath {
13016 path: PathBuf::from("/murmur-test/bundle.pem"),
13017 directory: false,
13018 },
13019 ];
13020 let expected = resolve_trust_store_grants(&store);
13021 let with_store = CapabilityPolicy {
13022 shell_trust_store: store.clone(),
13023 ..CapabilityPolicy::default()
13024 };
13025 let without = CapabilityPolicy::default();
13026 for (tier, declared) in [
13027 (EnforcementTier::KernelFull, ContainmentClass::Scoped),
13028 (EnforcementTier::KernelSealed, ContainmentClass::Sealed),
13029 ] {
13030 let decision =
13031 crate::containment::ContainmentDecision::decide(tier, declared, false, false);
13032 let granted = |policy: &CapabilityPolicy| {
13033 let enforcement =
13034 ShellEnforcement::resolve(policy, &decision, HostProbe::probe()).unwrap();
13035 assert_eq!(enforcement.tier, tier);
13036 enforcement.landlock_grants
13037 };
13038 let with_grants = granted(&with_store);
13039 let without_grants = granted(&without);
13040 for grant in &expected {
13041 assert_eq!(
13042 with_grants.iter().filter(|g| *g == grant).count(),
13043 1,
13044 "{tier:?}: {grant:?}"
13045 );
13046 }
13047 assert!(without_grants
13048 .iter()
13049 .all(|grant| !grant.path.starts_with("/murmur-test")));
13050 assert_eq!(with_grants.len(), without_grants.len() + expected.len());
13051 let sealed_etc = resolve_sealed_etc_landlock_grants(tier);
13052 assert_eq!(sealed_etc.is_empty(), tier != EnforcementTier::KernelSealed);
13053 for grant in &sealed_etc {
13054 assert!(with_grants.contains(grant), "{tier:?}: {grant:?}");
13055 }
13056 }
13057 }
13058
13059 #[test]
13063 fn a_scoped_python3_imports_its_stdlib_and_nothing_outside_it() {
13064 let test = "a_scoped_python3_imports_its_stdlib_and_nothing_outside_it";
13065 if find_on_path("python3").is_none() {
13066 println!("[SKIP] {test}: no python3 on PATH");
13067 return;
13068 }
13069 let (_base, work, outside) = work_and_outside();
13070 let policy = derived_interpreter_policy("python3", &work);
13071 let stdlib = assert_derives_stdlib_and_sqlite(&policy);
13072 let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
13073 return;
13074 };
13075
13076 let imported = execute(
13077 "python3",
13078 &["-c", "import json, sqlite3, venv; print('imported')"],
13079 &work,
13080 &policy,
13081 &enforcement,
13082 );
13083 assert_eq!(imported.exit_code, 0, "{}", imported.stderr);
13084 assert_eq!(imported.stdout.trim(), "imported");
13085
13086 let secret = outside.join("secret.txt");
13087 std::fs::write(&secret, "secret").unwrap();
13088 let probe = python_probe_outside(&secret, stdlib.parent().unwrap());
13089 let denied = execute("python3", &["-c", &probe], &work, &policy, &enforcement);
13090 assert_eq!(denied.exit_code, 0, "{}", denied.stderr);
13091 assert_eq!(
13092 denied.stdout.lines().collect::<Vec<_>>(),
13093 vec!["open PermissionError", "listdir PermissionError"]
13094 );
13095 }
13096
13097 #[test]
13100 fn a_sealed_python3_imports_its_stdlib_and_nothing_outside_it() {
13101 let test = "a_sealed_python3_imports_its_stdlib_and_nothing_outside_it";
13102 if crate::network_namespace::skip_without_egress_namespace(test) {
13103 return;
13104 }
13105 if find_on_path("python3").is_none() {
13106 println!("[SKIP] {test}: no python3 on PATH");
13107 return;
13108 }
13109 let probe = HostProbe::probe();
13110 if probe.tier() != EnforcementTier::KernelSealed {
13111 eprintln!(
13112 "SKIP — PROVES NOTHING ABOUT THE SEALED ROOT ON THIS HOST: {test} needs a host \
13113 that reaches KernelSealed; detected {:?}. No composed root is built in this run.",
13114 probe.tier()
13115 );
13116 return;
13117 }
13118 let (_base, work, outside) = work_and_outside();
13119 let policy = derived_interpreter_policy("python3", &work);
13120 assert_derives_stdlib_and_sqlite(&policy);
13121 let runtime = tempfile::tempdir().unwrap();
13122 let enforcement = ShellEnforcement::resolve_declared(
13123 &policy,
13124 murmur_artifact::ContainmentClass::Sealed,
13125 probe,
13126 )
13127 .expect("a sealed-capable host resolves sealed enforcement")
13128 .with_runtime_dir(test_runtime_dir(runtime.path()), None);
13129 assert_eq!(enforcement.tier, EnforcementTier::KernelSealed);
13130
13131 let imported = execute(
13132 "python3",
13133 &["-c", "import json, sqlite3, venv; print('imported')"],
13134 &work,
13135 &policy,
13136 &enforcement,
13137 );
13138 assert_eq!(imported.exit_code, 0, "{}", imported.stderr);
13139 assert_eq!(imported.stdout.trim(), "imported");
13140
13141 let secret = outside.join("secret.txt");
13142 std::fs::write(&secret, "secret").unwrap();
13143 let program = format!(
13144 "try:\n open({:?}).read(); print('allowed')\nexcept Exception as error:\n \
13145 print(type(error).__name__)\n",
13146 secret.to_string_lossy()
13147 );
13148 let denied = execute("python3", &["-c", &program], &work, &policy, &enforcement);
13149 assert_eq!(denied.exit_code, 0, "{}", denied.stderr);
13150 let outcome = denied.stdout.trim();
13151 assert!(
13152 outcome == "PermissionError" || outcome == "FileNotFoundError",
13153 "{outcome}"
13154 );
13155 }
13156
13157 #[test]
13160 fn a_scoped_perl_loads_posix_from_its_inc() {
13161 let test = "a_scoped_perl_loads_posix_from_its_inc";
13162 if find_on_path("perl").is_none() {
13163 println!("[SKIP] {test}: no perl on PATH");
13164 return;
13165 }
13166 let (_base, work, _outside) = work_and_outside();
13167 let policy = derived_interpreter_policy("perl", &work);
13168 assert!(
13169 !policy.shell_derived_interpreter_runtime.is_empty(),
13170 "perl was not recognised or reported nothing"
13171 );
13172 let Some(enforcement) = scoped_landlock_enforcement(test, &policy, 1) else {
13173 return;
13174 };
13175 let result = execute(
13176 "perl",
13177 &["-MPOSIX", "-e", "print qq{loaded\n}"],
13178 &work,
13179 &policy,
13180 &enforcement,
13181 );
13182 assert_eq!(result.exit_code, 0, "{}", result.stderr);
13183 assert_eq!(result.stdout.trim(), "loaded");
13184 }
13185
13186 #[test]
13191 fn host_path_landlock_rights_are_read_list_or_the_workdir_set_without_execute() {
13192 use landlock::AccessFs;
13193 assert_eq!(
13194 linux_enforce::host_path_access_rights(false),
13195 AccessFs::ReadFile | AccessFs::ReadDir
13196 );
13197 assert_eq!(
13198 linux_enforce::host_path_access_rights(true),
13199 linux_enforce::workdir_access_rights(false)
13200 );
13201 for read_write in [false, true] {
13202 assert!(!linux_enforce::host_path_access_rights(read_write).contains(AccessFs::Execute));
13203 }
13204 }
13205
13206 struct HostPathDirs {
13209 _base: tempfile::TempDir,
13210 ro: PathBuf,
13211 rw: PathBuf,
13212 outside: PathBuf,
13213 }
13214
13215 impl HostPathDirs {
13216 fn new() -> Self {
13217 let base = home_tempdir();
13218 let root = std::fs::canonicalize(base.path()).unwrap();
13219 let (ro, rw, outside) = (root.join("ro"), root.join("rw"), root.join("outside"));
13220 for dir in [&ro, &rw, &outside] {
13221 std::fs::create_dir(dir).unwrap();
13222 }
13223 std::fs::write(ro.join("in.txt"), "lent\n").unwrap();
13224 std::fs::write(outside.join("secret.txt"), "secret\n").unwrap();
13225 Self {
13226 _base: base,
13227 ro,
13228 rw,
13229 outside,
13230 }
13231 }
13232
13233 fn host_paths(&self) -> Vec<crate::host_paths::HostPath> {
13234 use crate::host_paths::{HostPath, HostPathAccess};
13235 vec![
13236 HostPath {
13237 declared: self.ro.display().to_string(),
13238 path: self.ro.clone(),
13239 access: HostPathAccess::Read,
13240 credential_locations: Vec::new(),
13241 },
13242 HostPath {
13243 declared: self.rw.display().to_string(),
13244 path: self.rw.clone(),
13245 access: HostPathAccess::ReadWrite,
13246 credential_locations: Vec::new(),
13247 },
13248 ]
13249 }
13250
13251 fn assert_reach(&self, enforcement: &ShellEnforcement, policy: &CapabilityPolicy) {
13254 let workdir = tempfile::tempdir().unwrap();
13255 let read = run_bash_in(
13256 workdir.path(),
13257 &format!(
13258 "read -r line < '{}/in.txt' && echo \"$line\"",
13259 self.ro.display()
13260 ),
13261 policy,
13262 enforcement,
13263 );
13264 assert_eq!(read.exit_code, 0, "{}", read.stderr);
13265 assert!(
13266 read.stdout.lines().any(|line| line == "lent"),
13267 "{}",
13268 read.stdout
13269 );
13270
13271 let write_ro = run_bash_in(
13272 workdir.path(),
13273 &format!("echo x > '{}/new'", self.ro.display()),
13274 policy,
13275 enforcement,
13276 );
13277 assert_ne!(write_ro.exit_code, 0);
13278 assert!(
13279 write_ro.stderr.contains("Permission denied")
13280 || write_ro.stderr.contains("Read-only file system"),
13281 "{}",
13282 write_ro.stderr
13283 );
13284 assert!(!self.ro.join("new").exists());
13285
13286 let write_rw = run_bash_in(
13287 workdir.path(),
13288 &format!("echo written > '{}/new'", self.rw.display()),
13289 policy,
13290 enforcement,
13291 );
13292 assert_eq!(write_rw.exit_code, 0, "{}", write_rw.stderr);
13293 assert_eq!(
13294 std::fs::read_to_string(self.rw.join("new")).unwrap(),
13295 "written\n"
13296 );
13297
13298 let outside = run_bash_in(
13299 workdir.path(),
13300 &format!("read -r line < '{}/secret.txt'", self.outside.display()),
13301 policy,
13302 enforcement,
13303 );
13304 assert_ne!(outside.exit_code, 0, "outside/secret.txt was read");
13305 }
13306 }
13307
13308 fn host_path_enforcement(
13311 test: &str,
13312 policy: &CapabilityPolicy,
13313 class: murmur_artifact::ContainmentClass,
13314 tier: EnforcementTier,
13315 ) -> Option<(ShellEnforcement, tempfile::TempDir)> {
13316 if crate::network_namespace::skip_without_egress_namespace(test) {
13317 return None;
13318 }
13319 let probe = HostProbe::probe();
13320 let reaches = match tier {
13321 EnforcementTier::KernelSealed => probe.tier() == EnforcementTier::KernelSealed,
13322 _ => matches!(
13323 probe.tier(),
13324 EnforcementTier::KernelFull | EnforcementTier::KernelSealed
13325 ),
13326 };
13327 if !reaches {
13328 eprintln!(
13329 "SKIP — PROVES NOTHING ON THIS HOST: {test} needs {tier:?}; detected {:?}",
13330 probe.tier()
13331 );
13332 return None;
13333 }
13334 let runtime = home_tempdir();
13335 let enforcement = ShellEnforcement::resolve_declared(policy, class, probe)
13336 .expect("the enforcement resolves")
13337 .with_runtime_dir(test_runtime_dir(runtime.path()), None);
13338 assert_eq!(enforcement.tier, tier);
13339 eprintln!("{test}: tier {tier:?}");
13340 Some((enforcement, runtime))
13341 }
13342
13343 fn resolve_on_tier(
13346 policy: &CapabilityPolicy,
13347 tier: EnforcementTier,
13348 class: murmur_artifact::ContainmentClass,
13349 ) -> Result<ShellEnforcement, String> {
13350 let decision = crate::containment::ContainmentDecision::decide(tier, class, false, false);
13351 let probe = HostProbe::from_parts(
13352 None,
13353 crate::namespace_probe::NamespaceReading {
13354 network: Err(crate::network_namespace::NamespaceAttemptError::Creator(
13355 "not probed".to_string(),
13356 )),
13357 mount: crate::namespace_probe::MountAttempt::NotAttempted,
13358 sealed_root: crate::namespace_probe::MountAttempt::NotAttempted,
13359 userns_grant: crate::sealed::UsernsGrant::Withheld,
13360 attachment: crate::sealed::ProfileAttachment::NotReported,
13361 },
13362 );
13363 ShellEnforcement::resolve(policy, &decision, probe)
13364 }
13365
13366 #[test]
13367 fn host_paths_on_kernel_full_read_write_and_outside() {
13368 const TEST: &str = "host_paths_on_kernel_full_read_write_and_outside";
13369 let dirs = HostPathDirs::new();
13370 let policy = CapabilityPolicy {
13371 shell_allow: vec!["bash".to_string()],
13372 host_paths: dirs.host_paths(),
13373 ..CapabilityPolicy::default()
13374 };
13375 let Some((enforcement, _runtime)) = host_path_enforcement(
13376 TEST,
13377 &policy,
13378 murmur_artifact::ContainmentClass::Scoped,
13379 EnforcementTier::KernelFull,
13380 ) else {
13381 return;
13382 };
13383 dirs.assert_reach(&enforcement, &policy);
13384 }
13385
13386 #[test]
13387 fn host_paths_on_kernel_sealed_are_bound_at_their_own_path() {
13388 const TEST: &str = "host_paths_on_kernel_sealed_are_bound_at_their_own_path";
13389 let dirs = HostPathDirs::new();
13390 let policy = CapabilityPolicy {
13391 shell_allow: vec!["bash".to_string()],
13392 host_paths: dirs.host_paths(),
13393 ..CapabilityPolicy::default()
13394 };
13395 let Some((enforcement, _runtime)) = host_path_enforcement(
13396 TEST,
13397 &policy,
13398 murmur_artifact::ContainmentClass::Sealed,
13399 EnforcementTier::KernelSealed,
13400 ) else {
13401 return;
13402 };
13403 dirs.assert_reach(&enforcement, &policy);
13404 let workdir = tempfile::tempdir().unwrap();
13406 let absent = run_bash_in(
13407 workdir.path(),
13408 &format!(
13409 "[ -e '{}/secret.txt' ] && echo present || echo absent",
13410 dirs.outside.display()
13411 ),
13412 &policy,
13413 &enforcement,
13414 );
13415 assert!(
13416 absent.stdout.lines().any(|line| line == "absent"),
13417 "{}",
13418 absent.stdout
13419 );
13420 }
13421
13422 #[test]
13426 fn a_read_write_host_path_over_an_execute_root_is_refused_scoped_and_protected_sealed() {
13427 const TEST: &str =
13428 "a_read_write_host_path_over_an_execute_root_is_refused_scoped_and_protected_sealed";
13429 let base = home_tempdir();
13430 let tools = std::fs::canonicalize(base.path()).unwrap().join("tools");
13431 let bin = tools.join("bin");
13432 std::fs::create_dir_all(&bin).unwrap();
13433 std::fs::write(bin.join("tool"), "#!/bin/bash\necho tool ran\n").unwrap();
13434 let policy = CapabilityPolicy {
13435 shell_allow: vec!["bash".to_string()],
13436 shell_interpreter_runtime: vec![InterpreterRuntimeGrant {
13437 binary: "bash".to_string(),
13438 dirs: vec![murmur_artifact::InterpreterRuntimeDir {
13439 path: bin.display().to_string(),
13440 list_dir: true,
13441 }],
13442 }],
13443 host_paths: vec![crate::host_paths::HostPath {
13444 declared: tools.display().to_string(),
13445 path: tools.clone(),
13446 access: crate::host_paths::HostPathAccess::ReadWrite,
13447 credential_locations: Vec::new(),
13448 }],
13449 ..CapabilityPolicy::default()
13450 };
13451 let error = resolve_on_tier(
13452 &policy,
13453 EnforcementTier::KernelFull,
13454 murmur_artifact::ContainmentClass::Scoped,
13455 )
13456 .expect_err("scoped refuses a read-write host path over an execute root");
13457 assert!(error.contains(&bin.display().to_string()), "{error}");
13458
13459 let Some((enforcement, _runtime)) = host_path_enforcement(
13460 TEST,
13461 &policy,
13462 murmur_artifact::ContainmentClass::Sealed,
13463 EnforcementTier::KernelSealed,
13464 ) else {
13465 return;
13466 };
13467 assert_eq!(enforcement.host_path_read_only_binds, vec![bin.clone()]);
13468 let workdir = tempfile::tempdir().unwrap();
13469 let rewrite = run_bash_in(
13470 workdir.path(),
13471 &format!("echo x > '{}/tool'", bin.display()),
13472 &policy,
13473 &enforcement,
13474 );
13475 assert_ne!(rewrite.exit_code, 0);
13476 assert!(
13477 rewrite.stderr.contains("Read-only file system"),
13478 "{}",
13479 rewrite.stderr
13480 );
13481 let add = run_bash_in(
13482 workdir.path(),
13483 &format!("echo x > '{}/new'", bin.display()),
13484 &policy,
13485 &enforcement,
13486 );
13487 assert_ne!(add.exit_code, 0);
13488 assert!(
13489 add.stderr.contains("Read-only file system"),
13490 "{}",
13491 add.stderr
13492 );
13493 assert_eq!(
13494 std::fs::read_to_string(bin.join("tool")).unwrap(),
13495 "#!/bin/bash\necho tool ran\n"
13496 );
13497 assert!(!bin.join("new").exists());
13498 let elsewhere = run_bash_in(
13499 workdir.path(),
13500 &format!("echo kept > '{}/notes'", tools.display()),
13501 &policy,
13502 &enforcement,
13503 );
13504 assert_eq!(elsewhere.exit_code, 0, "{}", elsewhere.stderr);
13505 assert_eq!(
13506 std::fs::read_to_string(tools.join("notes")).unwrap(),
13507 "kept\n"
13508 );
13509 }
13510
13511 #[test]
13513 fn a_vanished_host_path_fails_the_spawn_naming_it() {
13514 let base = home_tempdir();
13515 let gone = std::fs::canonicalize(base.path()).unwrap().join("gone");
13516 std::fs::create_dir(&gone).unwrap();
13517 let host_paths = vec![crate::host_paths::HostPath {
13518 declared: gone.display().to_string(),
13519 path: gone.clone(),
13520 access: crate::host_paths::HostPathAccess::Read,
13521 credential_locations: Vec::new(),
13522 }];
13523 std::fs::remove_dir(&gone).unwrap();
13524 let workdir = tempfile::tempdir().unwrap();
13525 for tier in [EnforcementTier::KernelFull, EnforcementTier::KernelSealed] {
13526 let enforcement = ShellEnforcement {
13527 tier,
13528 host_paths: host_paths.clone(),
13529 ..host_bounding_base().with_test_session_dirs()
13530 };
13531 let mut command = std::process::Command::new("bash");
13532 let Err(error) = prepare_enforcement(&mut command, &enforcement, workdir.path()) else {
13533 panic!("{tier:?}: a vanished host path must fail the spawn");
13534 };
13535 assert!(
13536 error.contains(&gone.display().to_string()),
13537 "{tier:?}: {error}"
13538 );
13539 }
13540 }
13541
13542 #[test]
13544 fn no_host_paths_add_nothing_to_the_enforcement() {
13545 let policy = CapabilityPolicy {
13546 shell_allow: vec!["bash".to_string()],
13547 ..CapabilityPolicy::default()
13548 };
13549 for tier in [EnforcementTier::KernelFull, EnforcementTier::KernelSealed] {
13550 let class = if tier == EnforcementTier::KernelSealed {
13551 murmur_artifact::ContainmentClass::Sealed
13552 } else {
13553 murmur_artifact::ContainmentClass::Scoped
13554 };
13555 let enforcement = resolve_on_tier(&policy, tier, class).unwrap();
13556 assert_eq!(enforcement.tier, tier);
13557 assert!(enforcement.host_paths.is_empty());
13558 assert!(enforcement.host_path_read_only_binds.is_empty());
13559 }
13560 }
13561}