use std::collections::{HashSet, VecDeque};
use std::path::{Path, PathBuf};
use anyhow::{Result, anyhow, bail};
use super::policy::{ResourceLimits, SandboxPolicy};
const MAX_LANDLOCK_RULES: usize = 4096;
pub fn landlock_supported() -> bool {
static SUPPORTED: std::sync::LazyLock<bool> = std::sync::LazyLock::new(|| probe_landlock_abi().is_some());
*SUPPORTED
}
fn probe_landlock_abi() -> Option<u32> {
const LANDLOCK_CREATE_RULESET_VERSION: libc::c_ulong = 1 << 0;
let version = unsafe {
libc::syscall(
libc::SYS_landlock_create_ruleset,
std::ptr::null::<libc::c_void>(),
0usize,
LANDLOCK_CREATE_RULESET_VERSION,
)
};
if version < 0 { None } else { u32::try_from(version).ok() }
}
pub fn apply_sandbox_restrictions(
policy: &SandboxPolicy,
seccomp: &super::policy::SeccompProfile,
limits: &ResourceLimits,
policy_cwd: &Path,
) -> Result<()> {
if policy.has_network_allowlist() {
bail!(
"hostname network allowlists cannot be enforced exactly by the Linux sandbox; refusing unrestricted network"
);
}
apply_resource_limits(limits)?;
apply_landlock(policy, policy_cwd)?;
super::linux_seccomp::apply_seccomp_filter(seccomp)?;
Ok(())
}
fn apply_resource_limits(limits: &ResourceLimits) -> Result<()> {
use nix::sys::resource::{Resource, setrlimit};
let mib = |mb: u64| mb.saturating_mul(1024 * 1024);
if limits.max_memory_mb > 0 {
setrlimit(Resource::RLIMIT_AS, mib(limits.max_memory_mb), mib(limits.max_memory_mb))
.map_err(|error| anyhow!("RLIMIT_AS failed: {error}"))?;
}
if limits.max_pids > 0 {
let pids = u64::from(limits.max_pids);
setrlimit(Resource::RLIMIT_NPROC, pids, pids).map_err(|error| anyhow!("RLIMIT_NPROC failed: {error}"))?;
}
if limits.max_disk_mb > 0 {
setrlimit(Resource::RLIMIT_FSIZE, mib(limits.max_disk_mb), mib(limits.max_disk_mb))
.map_err(|error| anyhow!("RLIMIT_FSIZE failed: {error}"))?;
}
if limits.cpu_time_secs > 0 {
let secs = limits.cpu_time_secs;
setrlimit(Resource::RLIMIT_CPU, secs, secs).map_err(|error| anyhow!("RLIMIT_CPU failed: {error}"))?;
}
Ok(())
}
pub fn apply_landlock(policy: &SandboxPolicy, policy_cwd: &Path) -> Result<()> {
use landlock::{ABI, PathBeneath, PathFd, Ruleset, RulesetAttr, RulesetCreatedAttr, RulesetStatus};
let Some(version) = probe_landlock_abi() else {
bail!("Landlock is not supported by this kernel (Linux 5.13+ required); refusing to run unsandboxed");
};
let abi = ABI::from(i32::try_from(version).unwrap_or(0));
if abi == ABI::Unsupported {
bail!("Landlock ABI version {version} is not usable");
}
let handled = handled_fs_access(abi);
let rules = compute_rules(policy, policy_cwd, abi, handled)?;
let mut created = Ruleset::default()
.handle_access(handled)
.map_err(|error| anyhow!("Landlock ruleset setup failed: {error}"))?
.create()
.map_err(|error| anyhow!("Landlock ruleset creation failed: {error}"))?;
for rule in &rules {
let fd = PathFd::new(&rule.path)
.map_err(|error| anyhow!("Landlock cannot open rule path {}: {error}", rule.path.display()))?;
created = created
.add_rule(PathBeneath::new(fd, rule.access))
.map_err(|error| anyhow!("Landlock rule for {} failed: {error}", rule.path.display()))?;
}
let status = created
.restrict_self()
.map_err(|error| anyhow!("Landlock self-restriction failed: {error}"))?;
if status.ruleset != RulesetStatus::FullyEnforced {
bail!("Landlock restrictions were only partially enforced ({:?}); refusing to exec", status.ruleset);
}
Ok(())
}
struct LandlockRule {
path: PathBuf,
access: landlock::BitFlags<landlock::AccessFs>,
}
fn handled_fs_access(abi: landlock::ABI) -> landlock::BitFlags<landlock::AccessFs> {
use landlock::{AccessFs, BitFlags};
let abi_access: BitFlags<AccessFs> = AccessFs::from_read(abi) | AccessFs::from_write(abi);
abi_access & !(AccessFs::Execute | AccessFs::IoctlDev)
}
fn compute_rules(
policy: &SandboxPolicy,
policy_cwd: &Path,
abi: landlock::ABI,
handled: landlock::BitFlags<landlock::AccessFs>,
) -> Result<Vec<LandlockRule>> {
let mut rules = Vec::new();
for path in compute_read_rule_paths(policy, policy_cwd)? {
rules.push(LandlockRule {
path,
access: landlock::AccessFs::from_read(abi) & handled,
});
}
for path in compute_write_rule_paths(policy, policy_cwd) {
rules.push(LandlockRule {
path,
access: landlock::AccessFs::from_write(abi) & handled,
});
}
Ok(rules)
}
fn read_blocked_paths(policy: &SandboxPolicy, policy_cwd: &Path) -> Vec<PathBuf> {
policy
.sensitive_paths_for_execution(policy_cwd)
.into_iter()
.filter(|sp| sp.block_read)
.map(|sp| sp.expand_path())
.collect()
}
fn compute_read_rule_paths(policy: &SandboxPolicy, policy_cwd: &Path) -> Result<Vec<PathBuf>> {
let sensitive = read_blocked_paths(policy, policy_cwd);
if sensitive.is_empty() {
return Ok(vec![PathBuf::from("/")]);
}
let mut roots = vec![PathBuf::from("/")];
if let Some(home) = dirs::home_dir()
&& home != Path::new("/")
{
roots.push(home);
}
let grants = enumerate_read_grants(&roots, &sensitive)?;
if grants.len() > MAX_LANDLOCK_RULES {
bail!(
"Landlock read enumeration produced {} rules (cap {MAX_LANDLOCK_RULES}); refusing to continue",
grants.len()
);
}
Ok(grants)
}
fn path_within(path: &Path, ancestor: &Path) -> bool {
super::policy::path_starts_with_case_insensitive(path, ancestor)
}
fn enumerate_read_grants(roots: &[PathBuf], sensitive: &[PathBuf]) -> Result<Vec<PathBuf>> {
let mut grants = Vec::new();
let mut queued: HashSet<PathBuf> = HashSet::new();
let mut queue: VecDeque<PathBuf> = VecDeque::new();
for root in roots {
if queued.insert(root.clone()) {
queue.push_back(root.clone());
}
}
while let Some(dir) = queue.pop_front() {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if sensitive.iter().any(|sp| path_within(&path, sp)) {
continue;
}
let Ok(file_type) = entry.file_type() else { continue };
if file_type.is_dir() {
if sensitive.iter().any(|sp| path_within(sp, &path)) {
if queued.insert(path.clone()) {
queue.push_back(path);
}
} else {
grants.push(path);
}
} else if file_type.is_symlink() {
if let Ok(target) = std::fs::canonicalize(&path)
&& !sensitive.iter().any(|sp| path_within(&target, sp) || path_within(sp, &target))
{
grants.push(path);
}
} else {
grants.push(path);
}
}
}
Ok(grants)
}
fn compute_write_rule_paths(policy: &SandboxPolicy, policy_cwd: &Path) -> Vec<PathBuf> {
match policy {
SandboxPolicy::ReadOnly { .. } => vec![PathBuf::from("/dev/null")],
SandboxPolicy::WorkspaceWrite { .. } => policy
.get_writable_roots_with_cwd(policy_cwd)
.into_iter()
.map(|root| root.root)
.collect(),
SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => Vec::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn sorted(mut paths: Vec<PathBuf>) -> Vec<String> {
paths.sort();
paths.into_iter().map(|p| p.display().to_string()).collect()
}
#[test]
fn read_grants_exclude_sensitive_subtrees_and_files() {
let root = TempDir::new().unwrap();
let root = root.path();
fs::create_dir_all(root.join("src")).unwrap();
fs::create_dir_all(root.join(".ssh")).unwrap();
fs::create_dir_all(root.join("deep/with/.config/gcloud")).unwrap();
fs::create_dir_all(root.join("deep/with/.config/git")).unwrap();
fs::write(root.join("readme.md"), "x").unwrap();
fs::write(root.join(".npmrc"), "token").unwrap();
let sensitive = vec![
root.join(".ssh"),
root.join(".npmrc"),
root.join("deep/with/.config/gcloud"),
];
let grants = sorted(enumerate_read_grants(&[root.to_path_buf()], &sensitive).unwrap());
assert!(grants.iter().any(|g| g.ends_with("src")), "wholesale dir grant: {grants:?}");
assert!(grants.iter().any(|g| g.ends_with("readme.md")));
assert!(grants.iter().any(|g| g.ends_with(".config/git")));
assert!(!grants.iter().any(|g| g.contains(".ssh")));
assert!(!grants.iter().any(|g| g.contains(".npmrc")));
assert!(!grants.iter().any(|g| g.contains("gcloud")));
assert!(!grants.iter().any(|g| g.as_str() == root.display().to_string()));
}
#[cfg(unix)]
#[test]
fn read_grants_exclude_symlinks_into_sensitive_paths() {
let root = TempDir::new().unwrap();
let root = root.path();
fs::create_dir_all(root.join(".ssh")).unwrap();
fs::create_dir_all(root.join("work")).unwrap();
std::os::unix::fs::symlink(root.join(".ssh"), root.join("ssh-link")).unwrap();
std::os::unix::fs::symlink(root.join("work"), root.join("work-link")).unwrap();
let sensitive = vec![root.join(".ssh")];
let grants = sorted(enumerate_read_grants(&[root.to_path_buf()], &sensitive).unwrap());
assert!(
!grants.iter().any(|g| g.ends_with("ssh-link")),
"symlink into sensitive must be excluded: {grants:?}"
);
assert!(grants.iter().any(|g| g.ends_with("work")));
assert!(grants.iter().any(|g| g.ends_with("work-link")));
}
#[cfg(unix)]
#[test]
fn read_grants_exclude_symlinks_to_sensitive_ancestors() {
let root = TempDir::new().unwrap();
let root = root.path();
fs::create_dir_all(root.join(".ssh")).unwrap();
fs::create_dir_all(root.join("work")).unwrap();
std::os::unix::fs::symlink(root, root.join("root-link")).unwrap();
std::os::unix::fs::symlink(root.parent().unwrap(), root.join("parent-link")).unwrap();
std::os::unix::fs::symlink(root.join("work"), root.join("work-link")).unwrap();
let sensitive = vec![root.join(".ssh")];
let grants = sorted(enumerate_read_grants(&[root.to_path_buf()], &sensitive).unwrap());
assert!(
!grants.iter().any(|g| g.ends_with("root-link") || g.ends_with("parent-link")),
"symlink to a sensitive ancestor must be excluded: {grants:?}"
);
assert!(grants.iter().any(|g| g.ends_with("work")));
assert!(grants.iter().any(|g| g.ends_with("work-link")));
}
#[test]
fn handled_fs_access_excludes_execute_and_ioctl_dev() {
use landlock::{ABI, AccessFs};
let abis = [
ABI::V1,
ABI::V2,
ABI::V3,
ABI::V4,
ABI::V5,
ABI::V6,
ABI::V7,
ABI::V8,
ABI::V9,
];
for abi in abis {
let handled = handled_fs_access(abi);
assert!(!handled.contains(AccessFs::Execute), "Execute must stay unhandled at {abi:?}");
assert!(!handled.contains(AccessFs::IoctlDev), "IoctlDev must stay unhandled at {abi:?}");
assert!(handled.contains(AccessFs::ReadFile), "read handling lost at {abi:?}");
assert!(handled.contains(AccessFs::WriteFile), "write handling lost at {abi:?}");
if matches!(abi, ABI::V3 | ABI::V4 | ABI::V5 | ABI::V6 | ABI::V7 | ABI::V8 | ABI::V9) {
assert!(handled.contains(AccessFs::Truncate), "Truncate must stay handled at {abi:?}");
}
assert!(!(AccessFs::from_read(abi) & handled).contains(AccessFs::Execute));
assert!(!(AccessFs::from_write(abi) & handled).contains(AccessFs::IoctlDev));
}
}
#[test]
fn write_grants_read_only_is_dev_null_only() {
let paths = compute_write_rule_paths(&SandboxPolicy::read_only(), Path::new("/tmp"));
assert_eq!(paths, vec![PathBuf::from("/dev/null")]);
}
#[test]
fn write_grants_workspace_roots() {
let workspace = TempDir::new().unwrap();
let cwd = workspace.path().to_path_buf();
let policy = SandboxPolicy::workspace_write(vec![cwd.clone()]);
let paths = compute_write_rule_paths(&policy, &cwd);
assert_eq!(paths, vec![cwd]);
}
#[test]
fn probe_landlock_abi_is_none_or_positive() {
if let Some(version) = probe_landlock_abi() {
assert!(version >= 1);
}
}
#[test]
fn apply_sandbox_restrictions_rejects_hostname_allowlists() {
let policy = SandboxPolicy::read_only_with_network(vec![super::super::policy::NetworkAllowlistEntry::https(
"api.example.com",
)]);
let error = apply_sandbox_restrictions(
&policy,
&super::super::policy::SeccompProfile::strict(),
&ResourceLimits::unlimited(),
Path::new("/tmp"),
)
.expect_err("allowlist must fail closed at the launcher");
assert!(error.to_string().contains("allowlist"), "got {error}");
}
}