Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/heimdall

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"
}