use std::collections::BTreeSet;
use std::ffi::OsStr;
use std::os::unix::ffi::OsStrExt;
use std::path::{Path, PathBuf};
use procage_policy::linux::{Scopes, SocketPaths};
use procage_policy::{
AbsPath, CapabilityGap, Construct, Degradation, GapKind, LinuxPlan, Target, Tier,
};
use super::classes::Cover;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) enum Found {
Path(PathBuf),
Dir(PathBuf, bool),
Abstract(String),
Unlisted,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) struct Finding {
pub(crate) construct: Construct,
pub(crate) found: Found,
}
pub(crate) fn runtime_dirs(extra: &[PathBuf]) -> Vec<PathBuf> {
let mut out = Vec::new();
if let Ok(entries) = std::fs::read_dir("/run/user") {
out.extend(
entries
.flatten()
.map(|e| e.path())
.filter(|p| std::fs::read_dir(p).is_ok()),
);
}
if let Some(dir) = std::env::var_os("XDG_RUNTIME_DIR").map(PathBuf::from)
&& dir.is_absolute()
{
out.push(dir);
}
out.extend(extra.iter().cloned());
let mut out: Vec<PathBuf> = out
.into_iter()
.map(|p| std::fs::canonicalize(&p).unwrap_or(p))
.filter(|p| p != Path::new("/"))
.collect();
out.sort();
out.dedup();
out
}
fn to_path(p: &AbsPath) -> PathBuf {
PathBuf::from(OsStr::from_bytes(p.as_bytes()))
}
fn existing(path: &Path) -> Vec<PathBuf> {
if let Err(e) = std::fs::symlink_metadata(path)
&& e.kind() == std::io::ErrorKind::NotFound
{
return Vec::new();
}
let mut out = vec![path.to_path_buf()];
if let Ok(real) = std::fs::canonicalize(path)
&& real != path
{
out.push(real);
}
out
}
fn holds_a_socket(dir: &Path) -> bool {
use std::os::unix::fs::FileTypeExt;
match std::fs::read_dir(dir) {
Ok(entries) => entries
.flatten()
.any(|e| e.file_type().map_or(true, |t| t.is_socket())),
Err(_) => true,
}
}
fn abstract_names(listing: &str) -> Vec<String> {
listing
.lines()
.skip(1)
.filter_map(|line| {
let fields: Vec<&str> = line.split_whitespace().collect();
if fields.len() < 8
|| !fields[1..7]
.iter()
.all(|f| f.chars().all(|c| c.is_ascii_hexdigit()))
{
return None;
}
let name = fields[7..].join(" ");
name.strip_prefix('@')
.filter(|n| !n.is_empty())
.map(str::to_string)
})
.collect()
}
pub(crate) fn find(
sockets: &[SocketPaths],
runtime: &[PathBuf],
abstract_visible: bool,
) -> Vec<Finding> {
let listing = abstract_visible.then(|| std::fs::read_to_string("/proc/net/unix"));
let mut out = BTreeSet::new();
for class in sockets {
let construct = Construct::of_class(class.class);
let mut push = |found| {
out.insert(Finding { construct, found });
};
let files = class.system.iter().map(to_path).chain(
runtime
.iter()
.flat_map(|dir| class.runtime.iter().map(move |rel| dir.join(rel))),
);
for name in files {
for p in existing(&name) {
push(Found::Path(p));
}
}
let dirs = class.system_dirs.iter().map(to_path).chain(
runtime
.iter()
.flat_map(|dir| class.runtime_dirs.iter().map(move |rel| dir.join(rel))),
);
for name in dirs {
for p in existing(&name) {
if p.is_dir() {
let socket = holds_a_socket(&p);
push(Found::Dir(p, socket));
} else {
push(Found::Path(p));
}
}
}
if class.abstract_prefixes.is_empty() {
continue;
}
match &listing {
None => {}
Some(Err(_)) => push(Found::Unlisted),
Some(Ok(text)) => {
for name in abstract_names(text) {
if class
.abstract_prefixes
.iter()
.any(|prefix| name.starts_with(prefix.as_str()))
{
push(Found::Abstract(name));
}
}
}
}
}
out.into_iter().collect()
}
#[derive(Debug, Default)]
pub(crate) struct Outcome {
pub(crate) covers: Vec<(PathBuf, Cover)>,
pub(crate) degradations: Vec<Degradation>,
pub(crate) scope: bool,
pub(crate) gaps: Vec<CapabilityGap>,
}
const SNAPSHOT: &str = "bus, service-manager and engine sockets that exist at spawn are covered, and \
the classes' directories as a whole; a socket bound later elsewhere is not";
pub(crate) fn decide(
plan: &LinuxPlan,
findings: &[Finding],
allowed: &BTreeSet<Construct>,
) -> Outcome {
let target = Target::Linux {
tier: plan.tier,
landlock_abi: plan.landlock_abi,
};
let mut out = Outcome::default();
let gap = |construct, kind, subject: String| CapabilityGap {
construct,
target,
kind,
subject: Some(subject),
};
for f in findings {
match &f.found {
Found::Dir(_, false) if plan.tier == Tier::T2 => {}
Found::Path(p) | Found::Dir(p, _) => match plan.tier {
Tier::T1 if allowed.contains(&f.construct) => {
out.covers.push((p.clone(), Cover::Hide));
if !out.degradations.iter().any(|d| d.construct == f.construct) {
out.degradations.push(Degradation {
construct: f.construct,
target,
note: SNAPSHOT,
});
}
}
Tier::T1 => out.gaps.push(gap(
f.construct,
GapKind::Approximation,
p.display().to_string(),
)),
Tier::T2 => out.gaps.push(gap(
f.construct,
GapKind::Unsupported,
p.display().to_string(),
)),
},
Found::Abstract(name) if plan.landlock_abi >= 6 => {
let _ = name;
out.scope = true;
}
Found::Abstract(name) => {
out.gaps
.push(gap(f.construct, GapKind::Unsupported, format!("@{name}")))
}
Found::Unlisted => out.gaps.push(gap(
f.construct,
GapKind::Unsupported,
"/proc/net/unix (unreadable)".into(),
)),
}
}
out.gaps.sort();
out.gaps.dedup();
out.degradations.sort();
out
}
pub(crate) fn scoped(plan: &LinuxPlan) -> bool {
plan.landlock.scopes.0 & Scopes::ABSTRACT_UNIX_SOCKET.0 != 0
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn abstract_names_are_parsed_field_by_field() {
let listing = "Num RefCount Protocol Flags Type St Inode Path\n\
0000000000000000: 00000002 00000000 00010000 0001 01 12345 @/tmp/dbus-AbCd\n\
0000000000000000: 00000002 00000000 00010000 0001 01 12346 /run/user/1000/bus\n\
0000000000000000: 00000002 00000000 00010000 0001 01 12347 @/tmp/dbus-with\n\
newline @/tmp/dbus-forged\n\
0000000000000000: 00000002 00000000 00010000 0001 01 12348 @/tmp/.X11-unix/X0\n";
assert_eq!(
abstract_names(listing),
["/tmp/dbus-AbCd", "/tmp/dbus-with", "/tmp/.X11-unix/X0"]
);
}
#[test]
fn extra_runtime_dirs_add() {
let dir = std::env::temp_dir().join(format!("procage-rt-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let found = runtime_dirs(&[]);
let with = runtime_dirs(std::slice::from_ref(&dir));
assert!(
with.contains(&std::fs::canonicalize(&dir).unwrap()),
"{with:?}"
);
assert!(found.iter().all(|d| with.contains(d)), "{found:?} {with:?}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn system_paths_are_found() {
use std::os::unix::net::UnixListener;
let dir = std::env::temp_dir().join(format!("procage-sys-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let real = std::fs::canonicalize(&dir).unwrap();
let _bus = UnixListener::bind(real.join("system_bus_socket")).unwrap();
let mut sockets = procage_policy::presets::linux_socket_paths(
procage_policy::HardDenyClass::ServiceHandoff,
)
.unwrap();
sockets.system =
vec![AbsPath::new(real.join("system_bus_socket").to_str().unwrap()).unwrap()];
let found = find(&[sockets], &[], false);
assert!(
found
.iter()
.any(|f| f.found == Found::Path(real.join("system_bus_socket"))),
"{found:?}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn what_cannot_be_inspected_counts() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("procage-closed-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o000)).unwrap();
let unreadable = std::fs::read_dir(&dir).is_err();
if unreadable {
assert!(holds_a_socket(&dir));
}
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap();
let _ = std::fs::remove_dir_all(&dir);
let mut plan = procage_policy::lower(
&{
let mut p = procage_policy::Policy::default();
p.net = procage_policy::Network::All;
p
},
Target::Linux {
tier: Tier::T2,
landlock_abi: 8,
},
&procage_policy::LowerOptions::default(),
)
.map(|plan| match plan {
procage_policy::Plan::Linux(p) => p,
_ => unreachable!(),
})
.unwrap();
plan.tier = Tier::T2;
let unlisted = [Finding {
construct: Construct::HdServiceHandoff,
found: Found::Unlisted,
}];
let outcome = decide(&plan, &unlisted, &BTreeSet::new());
assert_eq!(outcome.gaps.len(), 1, "{:?}", outcome.gaps);
assert_eq!(outcome.gaps[0].kind, GapKind::Unsupported);
}
#[test]
fn listed_names_are_found_in_any_form() {
use std::os::unix::net::UnixListener;
let dir = std::env::temp_dir().join(format!("procage-services-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("systemd")).unwrap();
let _bus = UnixListener::bind(dir.join("elsewhere.sock")).unwrap();
std::os::unix::fs::symlink(dir.join("elsewhere.sock"), dir.join("bus")).unwrap();
let _private = UnixListener::bind(dir.join("systemd/private")).unwrap();
let sockets = [procage_policy::presets::linux_socket_paths(
procage_policy::HardDenyClass::ServiceHandoff,
)
.unwrap()];
let real = std::fs::canonicalize(&dir).unwrap();
let found = find(&sockets, std::slice::from_ref(&real), false);
let paths: Vec<&Found> = found.iter().map(|f| &f.found).collect();
assert!(paths.contains(&&Found::Path(real.join("bus"))), "{found:?}");
assert!(
paths.contains(&&Found::Path(real.join("elsewhere.sock"))),
"{found:?}"
);
assert!(
paths.contains(&&Found::Path(real.join("systemd/private"))),
"{found:?}"
);
assert!(
paths.contains(&&Found::Dir(real.join("systemd"), true)),
"{found:?}"
);
let _ = std::fs::remove_dir_all(&dir);
}
}