crates/heimdall-trace/src/classify.rs
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Ignalina ApS and Vetra Automation AB
//! The tracer's pure decisions: which calls are file or network calls, which
//! are writes, how a path is normalised, how a socket address reads, and
//! what the tracer refuses. Heimdall is a defensive sandbox: containment is
//! Landlock's and the job's seccomp profile's; the tracer's refusals make the
//! trace say "refused" for certain and keep `/out` for the trace alone.
/// The open flags that make an open a write (`O_TMPFILE` too: it holds the
/// `O_DIRECTORY` bit, so [`open_writes`] checks it as a whole).
pub const WRITE_OPEN_FLAGS: u64 =
(libc::O_WRONLY | libc::O_RDWR | libc::O_CREAT | libc::O_TRUNC | libc::O_APPEND) as u64;
/// The flags `creat(2)` implies.
pub const CREAT_FLAGS: u64 = (libc::O_CREAT | libc::O_WRONLY | libc::O_TRUNC) as u64;
/// Whether open `flags` write.
pub fn open_writes(flags: u64) -> bool {
let tmpfile = libc::O_TMPFILE as u64;
flags & WRITE_OPEN_FLAGS != 0 || flags & tmpfile == tmpfile
}
/// Where a path argument sits in a file call.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub struct PathArg {
/// The argument with the directory fd (`None`: relative to the cwd).
pub dirfd: Option<u8>,
/// The argument with the path pointer.
pub path: u8,
}
/// How a file call is decoded.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FileCall {
pub paths: Vec<PathArg>,
/// The argument with the open flags (`openat2`: the `open_how` pointer).
pub flags: Option<u8>,
/// `openat2`: the flags are the first u64 of the struct the argument
/// points at.
pub flags_in_struct: bool,
/// Fixed flags (`creat`).
pub fixed_flags: Option<u64>,
/// Always a write (mkdir, unlink, rename, ...); else by the flags.
pub always_write: bool,
/// The argument whose `AT_EMPTY_PATH` makes an empty path name the
/// dirfd itself.
pub empty_path_flag: Option<u8>,
}
const fn p(dirfd: Option<u8>, path: u8) -> PathArg {
PathArg { dirfd, path }
}
/// The file calls the tracer records, by name.
pub fn file_call(name: &str) -> Option<FileCall> {
let w = |paths: Vec<PathArg>| FileCall {
paths,
flags: None,
flags_in_struct: false,
fixed_flags: None,
always_write: true,
empty_path_flag: None,
};
Some(match name {
"open" => FileCall {
flags: Some(1),
always_write: false,
..w(vec![p(None, 0)])
},
"openat" => FileCall {
flags: Some(2),
always_write: false,
..w(vec![p(Some(0), 1)])
},
"openat2" => FileCall {
flags: Some(2),
flags_in_struct: true,
always_write: false,
..w(vec![p(Some(0), 1)])
},
"creat" => FileCall {
fixed_flags: Some(CREAT_FLAGS),
..w(vec![p(None, 0)])
},
"mkdir" | "rmdir" | "unlink" | "chmod" | "chown" | "lchown" | "truncate" | "mknod" => {
w(vec![p(None, 0)])
}
"mkdirat" | "unlinkat" | "mknodat" | "fchmodat" | "fchmodat2" => w(vec![p(Some(0), 1)]),
"fchownat" => FileCall {
empty_path_flag: Some(4),
..w(vec![p(Some(0), 1)])
},
"rename" | "link" => w(vec![p(None, 0), p(None, 1)]),
"renameat" | "renameat2" | "linkat" => w(vec![p(Some(0), 1), p(Some(2), 3)]),
// The target of a symlink is data; only the new link is a path.
"symlink" => w(vec![p(None, 1)]),
"symlinkat" => w(vec![p(Some(1), 2)]),
_ => return None,
})
}
/// The network calls the tracer records: (address arg, length arg).
/// `sendmsg`'s address is inside the `msghdr` its argument 1 points at.
pub fn net_call(name: &str) -> Option<(u8, u8)> {
match name {
"connect" | "bind" => Some((1, 2)),
"sendto" => Some((4, 5)),
"sendmsg" => Some((1, 1)),
_ => None,
}
}
/// Lexically normalise an absolute path: `.`, `..` and repeated `/` go.
pub fn normalize(path: &str) -> String {
let mut parts: Vec<&str> = Vec::new();
for c in path.split('/') {
match c {
"" | "." => {}
".." => {
parts.pop();
}
c => parts.push(c),
}
}
let mut s = String::with_capacity(path.len());
for c in &parts {
s.push('/');
s.push_str(c);
}
if s.is_empty() {
s.push('/');
}
s
}
/// `rel` against `base` (absolute), normalised; an absolute `rel` stands alone.
pub fn join(base: &str, rel: &str) -> String {
if rel.starts_with('/') {
normalize(rel)
} else {
normalize(&format!("{base}/{rel}"))
}
}
/// `path` is `prefix` or below it.
pub fn under(path: &str, prefix: &str) -> bool {
let prefix = prefix.trim_end_matches('/');
if prefix.is_empty() {
return true;
}
path == prefix || (path.starts_with(prefix) && path.as_bytes().get(prefix.len()) == Some(&b'/'))
}
/// Whether the tracer refuses a write to `path` (`None`: unreadable):
/// anything in `/out`, anything not under one of `writable`, apart from
/// `/dev/null`.
pub fn refuse_write(path: Option<&str>, writable: &[String]) -> bool {
let Some(path) = path else { return true };
if under(path, "/out") {
return true;
}
if path == "/dev/null" {
return false;
}
!writable.iter().any(|w| under(path, w))
}
/// A write outside `/tmp` (`/dev/null` does not count).
pub fn outside_tmp(path: &str) -> bool {
!under(path, "/tmp") && path != "/dev/null"
}
/// A socket address: (family, address).
pub fn sockaddr(b: &[u8]) -> (String, String) {
if b.len() < 2 {
return ("unreadable".into(), String::new());
}
let fam = i32::from(u16::from_ne_bytes([b[0], b[1]]));
match fam {
libc::AF_INET if b.len() >= 8 => {
let port = u16::from_be_bytes([b[2], b[3]]);
let ip = std::net::Ipv4Addr::new(b[4], b[5], b[6], b[7]);
("inet".into(), format!("{ip}:{port}"))
}
libc::AF_INET6 if b.len() >= 24 => {
let port = u16::from_be_bytes([b[2], b[3]]);
let mut o = [0u8; 16];
o.copy_from_slice(&b[8..24]);
let ip = std::net::Ipv6Addr::from(o);
("inet6".into(), format!("[{ip}]:{port}"))
}
libc::AF_UNIX => {
let p = &b[2..];
if p.is_empty() {
("unix".into(), String::new())
} else if p[0] == 0 {
let name = String::from_utf8_lossy(&p[1..]);
("unix".into(), format!("@{}", name.trim_end_matches('\0')))
} else {
let end = p.iter().position(|&c| c == 0).unwrap_or(p.len());
(
"unix".into(),
String::from_utf8_lossy(&p[..end]).into_owned(),
)
}
}
libc::AF_NETLINK if b.len() >= 12 => {
let pid = u32::from_ne_bytes([b[4], b[5], b[6], b[7]]);
let groups = u32::from_ne_bytes([b[8], b[9], b[10], b[11]]);
("netlink".into(), format!("pid {pid} groups {groups}"))
}
libc::AF_PACKET => ("packet".into(), String::new()),
f => (format!("family {f}"), String::new()),
}
}
/// Whether the tracer refuses a network call of `family`: everything but
/// AF_UNIX (a trace job has no network; unix sockets stay inside the job).
pub fn refuse_net(family: &str) -> bool {
family != "unix"
}