#![forbid(unsafe_code)]
use std::sync::LazyLock;
use memchr::arch::all::is_equal;
use nix::{
errno::Errno,
fcntl::{open, OFlag},
sys::stat::Mode,
};
use crate::{
hash::{SydHashMap, SydHashSet, SydIndexMap},
landlock::{
Access, AccessFs, AccessNet, CompatLevel, Compatible, CreateRulesetError, Errata, NetPort,
PathBeneath, PathFd, RestrictSelfFlags, RestrictionStatus, Ruleset, RulesetAttr,
RulesetCreatedAttr, RulesetError, Scope, ABI,
},
parsers::sandbox::{
str2u32, LandlockCmd, LandlockOp, LandlockRule, PathSet, PortRange, PortSet,
},
path::{XPath, XPathBuf},
sandbox::Sandbox,
};
pub static LANDLOCK_ACCESS_FS: LazyLock<SydIndexMap<&str, AccessFs>> = LazyLock::new(|| {
SydIndexMap::from_iter([
("read", AccessFs::ReadFile),
("write", AccessFs::WriteFile),
("exec", AccessFs::Execute),
("ioctl", AccessFs::IoctlDev),
("create", AccessFs::MakeReg),
("delete", AccessFs::RemoveFile),
("rename", AccessFs::Refer),
("symlink", AccessFs::MakeSym),
("truncate", AccessFs::Truncate),
("readdir", AccessFs::ReadDir),
("mkdir", AccessFs::MakeDir),
("rmdir", AccessFs::RemoveDir),
("mkbdev", AccessFs::MakeBlock),
("mkcdev", AccessFs::MakeChar),
("mkfifo", AccessFs::MakeFifo),
("bind", AccessFs::MakeSock),
("all", LandlockPolicy::access_fs_from_set("all")),
("rpath", LandlockPolicy::access_fs_from_set("rpath")),
("wpath", LandlockPolicy::access_fs_from_set("wpath")),
("cpath", LandlockPolicy::access_fs_from_set("cpath")),
("dpath", LandlockPolicy::access_fs_from_set("dpath")),
("spath", LandlockPolicy::access_fs_from_set("spath")),
("tpath", LandlockPolicy::access_fs_from_set("tpath")),
("bnet", LandlockPolicy::access_fs_from_set("bnet")),
])
});
pub static LANDLOCK_ACCESS_NET: LazyLock<SydIndexMap<&str, AccessNet>> = LazyLock::new(|| {
SydIndexMap::from_iter([
("bind", AccessNet::BindTcp),
("connect", AccessNet::ConnectTcp),
("net", LandlockPolicy::access_net_from_set("net")),
("inet", LandlockPolicy::access_net_from_set("inet")),
("bnet", LandlockPolicy::access_net_from_set("bnet")),
("cnet", LandlockPolicy::access_net_from_set("cnet")),
])
});
#[derive(Clone, Debug, Default)]
pub struct LandlockPolicy {
pub compat_level: Option<CompatLevel>,
pub read_pathset: Option<PathSet>,
pub write_pathset: Option<PathSet>,
pub exec_pathset: Option<PathSet>,
pub ioctl_pathset: Option<PathSet>,
pub create_pathset: Option<PathSet>,
pub delete_pathset: Option<PathSet>,
pub rename_pathset: Option<PathSet>,
pub symlink_pathset: Option<PathSet>,
pub truncate_pathset: Option<PathSet>,
pub readdir_pathset: Option<PathSet>,
pub mkdir_pathset: Option<PathSet>,
pub rmdir_pathset: Option<PathSet>,
pub mkbdev_pathset: Option<PathSet>,
pub mkcdev_pathset: Option<PathSet>,
pub mkfifo_pathset: Option<PathSet>,
pub bind_pathset: Option<PathSet>,
pub bind_portset: Option<PortSet>,
pub conn_portset: Option<PortSet>,
pub scoped_abs: bool,
pub scoped_sig: bool,
pub restrict_self_flags: RestrictSelfFlags,
}
impl LandlockPolicy {
pub fn edit(&mut self, cmd: LandlockCmd, sandbox: Option<&Sandbox>) -> Result<(), Errno> {
for rule in cmd.filter {
match rule {
LandlockRule::Fs((access_fs, pat)) => {
let pat = if let Some(sandbox) = sandbox {
sandbox.expand_env(&pat)?
} else {
pat.into()
};
let pat = XPath::from_bytes(pat.as_bytes());
if cmd.op == LandlockOp::Add {
self.rule_add_fs(access_fs, pat)?;
} else {
if sandbox.is_some()
&& access_fs.intersects(AccessFs::ReadFile | AccessFs::ReadDir)
&& pat.is_equal(b"/proc")
{
return Err(Errno::EACCES);
}
if sandbox.is_some()
&& access_fs.intersects(
AccessFs::ReadFile | AccessFs::WriteFile | AccessFs::Truncate,
)
&& pat.is_equal(b"/dev/null")
{
return Err(Errno::EACCES);
}
self.rule_del_fs(access_fs, pat)?;
}
}
LandlockRule::Net((access_net, ports)) => {
if cmd.op == LandlockOp::Add {
self.rule_add_net(access_net, ports)?;
} else {
self.rule_del_net(access_net, ports)?;
}
}
}
}
Ok(())
}
pub fn rule_add_fs(&mut self, access: AccessFs, pat: &XPath) -> Result<(), Errno> {
if access.is_empty() {
return Err(Errno::EINVAL);
}
for access in access.iter() {
let set = self.get_pathset_mut(access);
if let Some(ref mut set) = set {
set.insert(pat.to_owned());
} else {
let mut new_set = SydHashSet::default();
new_set.insert(pat.to_owned());
*set = Some(new_set);
}
}
Ok(())
}
pub fn rule_del_fs(&mut self, access: AccessFs, pat: &XPath) -> Result<(), Errno> {
if access.is_empty() {
return Err(Errno::EINVAL);
}
for access in access.iter() {
let set = self.get_pathset_mut(access);
if let Some(ref mut set_ref) = set {
set_ref.remove(pat);
if set_ref.is_empty() {
*set = None;
}
}
}
Ok(())
}
pub fn rule_add_net(&mut self, access: AccessNet, ports: PortRange) -> Result<(), Errno> {
if access.is_empty() {
return Err(Errno::EINVAL);
}
let mut port0 = (*ports.start()).into();
let mut port1 = (*ports.end()).into();
if port0 > port1 {
std::mem::swap(&mut port0, &mut port1);
}
#[expect(clippy::arithmetic_side_effects)]
let ports = port0..(port1 + 1);
for access in access.iter() {
let set = self.get_portset_mut(access);
if let Some(ref mut set) = set {
set.insert_range(ports.clone());
} else {
let mut new_set = PortSet::with_capacity(0x10000);
new_set.insert_range(ports.clone());
*set = Some(new_set);
}
}
Ok(())
}
pub fn rule_del_net(&mut self, access: AccessNet, ports: PortRange) -> Result<(), Errno> {
if access.is_empty() {
return Err(Errno::EINVAL);
}
let mut port0 = (*ports.start()).into();
let mut port1 = (*ports.end()).into();
if port0 > port1 {
std::mem::swap(&mut port0, &mut port1);
}
#[expect(clippy::arithmetic_side_effects)]
let ports = port0..(port1 + 1);
for access in access.iter() {
let set = self.get_portset_mut(access);
if let Some(ref mut set_ref) = set {
set_ref.remove_range(ports.clone());
if set_ref.is_clear() {
*set = None;
}
}
}
Ok(())
}
pub fn parse_errata(errata: &[u8]) -> Result<Errata, Errno> {
let mut e = Errata::empty();
for fix in errata.split(|b| *b == b',') {
if let Ok(flag) = str2u32(fix).map(Errata::from_bits_retain) {
e.insert(flag);
continue;
}
if is_equal(fix, b"tcp_socket_identification") {
e.insert(Errata::TCP_SOCKET_IDENTIFICATION);
} else if is_equal(fix, b"scoped_signal_same_tgid") {
e.insert(Errata::SCOPED_SIGNAL_SAME_TGID);
} else {
return Err(Errno::EINVAL);
}
}
if !e.is_empty() {
Ok(e)
} else {
Err(Errno::EINVAL)
}
}
pub fn parse_restrict_self_flags(
flags: &[u8],
numeric: bool,
) -> Result<RestrictSelfFlags, Errno> {
let mut f = RestrictSelfFlags::empty();
for flag in flags.split(|b| *b == b',') {
if numeric {
if let Ok(flag) =
str2u32(flag).and_then(|f| RestrictSelfFlags::from_bits(f).ok_or(Errno::EINVAL))
{
f.insert(flag);
continue;
}
}
const LOG_SAME_EXEC_OFF_NAMES: &[&[u8]] = &[b"same_exec_off", b"log_same_exec_off"];
const LOG_NEW_EXEC_ON_NAMES: &[&[u8]] = &[b"new_exec_on", b"log_new_exec_on"];
const LOG_SUBDOMAINS_OFF_NAMES: &[&[u8]] = &[b"subdomains_off", b"log_subdomains_off"];
if LOG_SAME_EXEC_OFF_NAMES.iter().any(|f| is_equal(flag, f)) {
f.insert(RestrictSelfFlags::LOG_SAME_EXEC_OFF);
} else if LOG_NEW_EXEC_ON_NAMES.iter().any(|f| is_equal(flag, f)) {
f.insert(RestrictSelfFlags::LOG_NEW_EXEC_ON);
} else if LOG_SUBDOMAINS_OFF_NAMES.iter().any(|f| is_equal(flag, f)) {
f.insert(RestrictSelfFlags::LOG_SUBDOMAINS_OFF);
} else {
return Err(Errno::EINVAL);
}
}
if !f.is_empty() {
Ok(f)
} else {
Err(Errno::EINVAL)
}
}
pub fn access(access_str: &str) -> Result<(AccessFs, AccessNet), Errno> {
let mut access_fs = AccessFs::EMPTY;
let mut access_net = AccessNet::EMPTY;
for access in access_str.split(',') {
let my_access_fs = LANDLOCK_ACCESS_FS
.get(access)
.copied()
.unwrap_or(AccessFs::EMPTY);
let my_access_net = LANDLOCK_ACCESS_NET
.get(access)
.copied()
.unwrap_or(AccessNet::EMPTY);
if my_access_fs.is_empty() && my_access_net.is_empty() {
return Err(Errno::EINVAL);
}
access_fs |= my_access_fs;
access_net |= my_access_net;
}
Ok((access_fs, access_net))
}
pub fn access_fs_from_set(set: &str) -> AccessFs {
let s = set.as_bytes();
if is_equal(s, b"all") {
AccessFs::all()
} else if is_equal(s, b"rpath") {
AccessFs::ReadFile | AccessFs::ReadDir
} else if is_equal(s, b"wpath") {
AccessFs::WriteFile | AccessFs::Truncate
} else if is_equal(s, b"cpath") {
AccessFs::MakeReg | AccessFs::RemoveFile | AccessFs::Refer
} else if is_equal(s, b"dpath") {
AccessFs::MakeBlock | AccessFs::MakeChar
} else if is_equal(s, b"spath") {
AccessFs::MakeFifo | AccessFs::MakeSym
} else if is_equal(s, b"tpath") {
AccessFs::MakeDir | AccessFs::RemoveDir
} else if is_equal(s, b"bnet") {
AccessFs::MakeSock
} else {
unreachable!("BUG: Invalid landlock(7) filesystem access right {set}, report a bug!");
}
}
pub fn access_net_from_set(set: &str) -> AccessNet {
let s = set.as_bytes();
if is_equal(s, b"all") {
AccessNet::all()
} else if is_equal(s, b"bnet") {
AccessNet::BindTcp
} else if is_equal(s, b"cnet") {
AccessNet::ConnectTcp
} else if is_equal(s, b"net") || is_equal(s, b"inet") {
AccessNet::BindTcp | AccessNet::ConnectTcp
} else {
unreachable!("BUG: Invalid landlock(7) network access right {set}, report a bug!");
}
}
#[expect(clippy::cognitive_complexity)]
pub fn restrict_self(&self, abi: ABI) -> Result<RestrictionStatus, RulesetError> {
let mut ruleset = Ruleset::default().handle_access(AccessFs::from_all(abi))?;
let ruleset_ref = &mut ruleset;
let level = if let Some(compat_level) = self.compat_level {
ruleset_ref.set_compatibility(compat_level);
compat_level
} else {
CompatLevel::BestEffort
};
let mut network_rules_bind = PortSet::with_capacity(0x10000);
let mut network_rules_conn = PortSet::with_capacity(0x10000);
if abi >= ABI::V4 {
if let Some(ref port_set) = self.bind_portset {
network_rules_bind = port_set.clone();
}
if network_rules_bind.is_full() {
network_rules_bind.clear();
} else {
ruleset_ref.handle_access(AccessNet::BindTcp)?;
}
if let Some(ref port_set) = self.conn_portset {
network_rules_conn = port_set.clone();
}
if network_rules_conn.is_full() {
network_rules_conn.clear();
} else {
ruleset_ref.handle_access(AccessNet::ConnectTcp)?;
}
}
if abi >= ABI::V6 {
if self.scoped_abs {
ruleset_ref.scope(Scope::AbstractUnixSocket)?;
}
if self.scoped_sig {
ruleset_ref.scope(Scope::Signal)?;
}
}
let mut all_pathset: SydHashSet<XPathBuf> = SydHashSet::default();
if let Some(ref pathset) = self.read_pathset {
all_pathset.extend(pathset.iter().cloned());
}
if let Some(ref pathset) = self.write_pathset {
all_pathset.extend(pathset.iter().cloned());
}
if let Some(ref pathset) = self.exec_pathset {
all_pathset.extend(pathset.iter().cloned());
}
if let Some(ref pathset) = self.ioctl_pathset {
all_pathset.extend(pathset.iter().cloned());
}
if let Some(ref pathset) = self.create_pathset {
all_pathset.extend(pathset.iter().cloned());
}
if let Some(ref pathset) = self.delete_pathset {
all_pathset.extend(pathset.iter().cloned());
}
if let Some(ref pathset) = self.rename_pathset {
all_pathset.extend(pathset.iter().cloned());
}
if let Some(ref pathset) = self.symlink_pathset {
all_pathset.extend(pathset.iter().cloned());
}
if let Some(ref pathset) = self.truncate_pathset {
all_pathset.extend(pathset.iter().cloned());
}
if let Some(ref pathset) = self.readdir_pathset {
all_pathset.extend(pathset.iter().cloned());
}
if let Some(ref pathset) = self.mkdir_pathset {
all_pathset.extend(pathset.iter().cloned());
}
if let Some(ref pathset) = self.rmdir_pathset {
all_pathset.extend(pathset.iter().cloned());
}
if let Some(ref pathset) = self.mkbdev_pathset {
all_pathset.extend(pathset.iter().cloned());
}
if let Some(ref pathset) = self.mkcdev_pathset {
all_pathset.extend(pathset.iter().cloned());
}
if let Some(ref pathset) = self.mkfifo_pathset {
all_pathset.extend(pathset.iter().cloned());
}
if let Some(ref pathset) = self.bind_pathset {
all_pathset.extend(pathset.iter().cloned());
}
let mut acl: SydHashMap<AccessFs, Vec<XPathBuf>> = SydHashMap::default();
for path in all_pathset {
let mut access = AccessFs::EMPTY;
if self
.read_pathset
.as_ref()
.map(|set| set.contains(&path))
.unwrap_or(false)
{
access |= AccessFs::ReadFile;
}
if self
.write_pathset
.as_ref()
.map(|set| set.contains(&path))
.unwrap_or(false)
{
access |= AccessFs::WriteFile;
}
if self
.exec_pathset
.as_ref()
.map(|set| set.contains(&path))
.unwrap_or(false)
{
access |= AccessFs::Execute;
}
if abi >= ABI::V5
&& self
.ioctl_pathset
.as_ref()
.map(|set| set.contains(&path))
.unwrap_or(false)
{
access |= AccessFs::IoctlDev;
}
if self
.create_pathset
.as_ref()
.map(|set| set.contains(&path))
.unwrap_or(false)
{
access |= AccessFs::MakeReg;
}
if self
.delete_pathset
.as_ref()
.map(|set| set.contains(&path))
.unwrap_or(false)
{
access |= AccessFs::RemoveFile;
}
if abi >= ABI::V2
&& self
.rename_pathset
.as_ref()
.map(|set| set.contains(&path))
.unwrap_or(false)
{
access |= AccessFs::Refer;
}
if self
.symlink_pathset
.as_ref()
.map(|set| set.contains(&path))
.unwrap_or(false)
{
access |= AccessFs::MakeSym;
}
if abi >= ABI::V3
&& self
.truncate_pathset
.as_ref()
.map(|set| set.contains(&path))
.unwrap_or(false)
{
access |= AccessFs::Truncate;
}
if self
.readdir_pathset
.as_ref()
.map(|set| set.contains(&path))
.unwrap_or(false)
{
access |= AccessFs::ReadDir;
}
if self
.mkdir_pathset
.as_ref()
.map(|set| set.contains(&path))
.unwrap_or(false)
{
access |= AccessFs::MakeDir;
}
if self
.rmdir_pathset
.as_ref()
.map(|set| set.contains(&path))
.unwrap_or(false)
{
access |= AccessFs::RemoveDir;
}
if self
.mkbdev_pathset
.as_ref()
.map(|set| set.contains(&path))
.unwrap_or(false)
{
access |= AccessFs::MakeBlock;
}
if self
.mkcdev_pathset
.as_ref()
.map(|set| set.contains(&path))
.unwrap_or(false)
{
access |= AccessFs::MakeChar;
}
if self
.mkfifo_pathset
.as_ref()
.map(|set| set.contains(&path))
.unwrap_or(false)
{
access |= AccessFs::MakeFifo;
}
if self
.bind_pathset
.as_ref()
.map(|set| set.contains(&path))
.unwrap_or(false)
{
access |= AccessFs::MakeSock;
}
if access.is_empty() {
continue;
}
acl.entry(access).or_default().push(path);
}
let mut ruleset = ruleset.create()?;
for (access, paths) in &acl {
ruleset = ruleset.add_rules(landlock_path_beneath_rules(level, paths, *access))?;
}
#[expect(clippy::cast_possible_truncation)]
ruleset
.add_rules(network_rules_bind.ones().map(|port| {
Ok::<NetPort, RulesetError>(NetPort::new(port as u16, AccessNet::BindTcp))
}))?
.add_rules(network_rules_conn.ones().map(|port| {
Ok::<NetPort, RulesetError>(NetPort::new(port as u16, AccessNet::ConnectTcp))
}))?
.restrict_self(self.restrict_self_flags)
}
#[inline]
fn get_pathset_mut(&mut self, access: AccessFs) -> &mut Option<PathSet> {
match access {
AccessFs::ReadFile => &mut self.read_pathset,
AccessFs::WriteFile => &mut self.write_pathset,
AccessFs::Execute => &mut self.exec_pathset,
AccessFs::IoctlDev => &mut self.ioctl_pathset,
AccessFs::MakeReg => &mut self.create_pathset,
AccessFs::RemoveFile => &mut self.delete_pathset,
AccessFs::Refer => &mut self.rename_pathset,
AccessFs::MakeSym => &mut self.symlink_pathset,
AccessFs::Truncate => &mut self.truncate_pathset,
AccessFs::ReadDir => &mut self.readdir_pathset,
AccessFs::MakeDir => &mut self.mkdir_pathset,
AccessFs::RemoveDir => &mut self.rmdir_pathset,
AccessFs::MakeBlock => &mut self.mkbdev_pathset,
AccessFs::MakeChar => &mut self.mkcdev_pathset,
AccessFs::MakeFifo => &mut self.mkfifo_pathset,
AccessFs::MakeSock => &mut self.bind_pathset,
_ => unreachable!("BUG: unhandled Landlock filesystem access right {access:?}!"),
}
}
#[inline]
fn get_portset_mut(&mut self, access: AccessNet) -> &mut Option<PortSet> {
match access {
AccessNet::BindTcp => &mut self.bind_portset,
AccessNet::ConnectTcp => &mut self.conn_portset,
_ => unreachable!("BUG: unhandled Landlock network access right {access:?}!"),
}
}
}
#[expect(clippy::cognitive_complexity)]
#[expect(clippy::disallowed_methods)]
fn landlock_path_beneath_rules<I, P>(
level: CompatLevel,
paths: I,
access: AccessFs,
) -> impl Iterator<Item = Result<PathBeneath<PathFd>, RulesetError>>
where
I: IntoIterator<Item = P>,
P: AsRef<XPath>,
{
let compat_level = match level {
CompatLevel::HardRequirement => "hard-requirement",
CompatLevel::SoftRequirement => "soft-requirement",
CompatLevel::BestEffort => "best-effort",
};
paths.into_iter().filter_map(move |p| {
let p = p.as_ref();
match open(p, OFlag::O_PATH | OFlag::O_CLOEXEC, Mode::empty()) {
Ok(fd) => Some(Ok(PathBeneath::new(PathFd { fd }, access))),
Err(errno @ Errno::ENOENT) if level == CompatLevel::BestEffort => {
crate::info!("ctx": "init", "op": "landlock_create_ruleset",
"path": p, "access": access,
"cmp": compat_level, "err": errno as i32,
"msg": format!("open path `{p}' for Landlock failed: {errno}"));
None
}
Err(errno) => {
crate::error!("ctx": "init", "op": "landlock_create_ruleset",
"path": p, "access": access,
"cmp": compat_level, "err": errno as i32,
"msg": format!("open path `{p}' for Landlock failed: {errno}"),
"tip": "set `default/lock:warn' to ignore file-not-found errors for Landlock");
Some(Err(RulesetError::CreateRuleset(
CreateRulesetError::CreateRulesetCall {
source: errno.into(),
},
)))
}
}
})
}