//
// pandora: Syd's Dump Inspector & Profile Writer
// pandora.rs: Main entry point
//
// Copyright (c) 2021, 2024, 2025, 2026 Ali Polatel <alip@chesswob.org>
//
// SPDX-License-Identifier: GPL-3.0

#[cfg(all(feature = "rust-dns", feature = "system-dns"))]
compile_error!("features `rust-dns` and `system-dns` are mutually exclusive");

#[cfg(feature = "rust-dns")]
use std::convert::TryFrom;
#[cfg(feature = "rust-dns")]
use std::net::{SocketAddr, SocketAddrV4, SocketAddrV6, UdpSocket};
use std::{
    borrow::Cow,
    cmp::Ordering,
    collections::BTreeSet,
    convert::TryInto,
    env,
    ffi::OsString,
    fmt,
    fmt::Write as FmtWrite,
    fs::{metadata, File, OpenOptions},
    hash::{Hash, Hasher},
    io::{self, stderr, stdin, BufRead, BufReader, IoSliceMut, Read, Write as IoWrite, Write},
    iter::FromIterator,
    net::IpAddr,
    os::{
        fd::{AsFd, AsRawFd, FromRawFd, OwnedFd},
        unix::ffi::{OsStrExt, OsStringExt},
    },
    path::{Path, PathBuf},
    process::{exit, Command, ExitCode},
    str,
    sync::{
        atomic::{AtomicBool, Ordering as AtomicOrdering},
        Arc, Mutex,
    },
    thread,
    time::Duration,
};

use btoi::btoi;
use console::style;
use data_encoding::{HEXLOWER, HEXLOWER_PERMISSIVE};
use fixedbitset::FixedBitSet;
use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle};
use libc::pid_t;
use memchr::arch::all::is_equal;
use nix::{
    errno::Errno,
    fcntl::{
        fcntl, openat2, splice, FcntlArg, FdFlag, OFlag, OpenHow, ResolveFlag, SpliceFFlags,
        AT_FDCWD,
    },
    sys::{
        sendfile::sendfile64,
        signal::{kill, sigprocmask, SigmaskHow, Signal},
        signalfd::SigSet,
        socket::{
            accept4, bind, recvmsg, send, socket, AddressFamily, AlgAddr, MsgFlags, SockFlag,
            SockType,
        },
        stat::Mode,
    },
    unistd::{getcwd, pipe2, Gid, Pid, Uid, User},
};
use patricia_tree::StringPatriciaSet;
use rayon::{
    iter::{IntoParallelRefIterator, ParallelIterator},
    ThreadPoolBuilder,
};
use serde::{
    de::{MapAccess, SeqAccess, Visitor},
    Deserialize, Deserializer, Serialize, Serializer,
};

const PKG_NAME: &str = "pandora";
const PKG_VERSION: &str = env!("CARGO_PKG_VERSION");
const PKG_DESCRIPTION: &str = env!("CARGO_PKG_DESCRIPTION");
const PKG_AUTHORS: &str = env!("CARGO_PKG_AUTHORS");
const PKG_LICENSE: &str = env!("CARGO_PKG_LICENSE");

const CAPSET_LPATH: &[&str] = &["walk", "stat", "chdir", "notify"];
const CAPSET_RPATH: &[&str] = &["read", "readdir"];
const CAPSET_WPATH: &[&str] = &["write", "truncate"];
const CAPSET_CPATH: &[&str] = &["create", "delete", "rename"];
const CAPSET_DPATH: &[&str] = &["mkbdev", "mkcdev"];
const CAPSET_SPATH: &[&str] = &["mkfifo", "symlink"];
const CAPSET_TPATH: &[&str] = &["mkdir", "rmdir"];
const CAPSET_FOWN: &[&str] = &["chown", "chgrp"];
const CAPSET_FATTR: &[&str] = &["chmod", "chattr", "utime"];
const CAPSET_NET: &[&str] = &["net/bind", "net/connect", "net/sendfd"];
const CAPSET_INET: &[&str] = &["net/bind", "net/connect"];
const CAPSET_BNET: &[&str] = &["net/bind"];
const CAPSET_CNET: &[&str] = &["net/connect"];
const CAPSET_SNET: &[&str] = &["net/sendfd"];

const CAPSETS: &[(&str, &[&str])] = &[
    ("lpath", CAPSET_LPATH),
    ("rpath", CAPSET_RPATH),
    ("wpath", CAPSET_WPATH),
    ("cpath", CAPSET_CPATH),
    ("dpath", CAPSET_DPATH),
    ("spath", CAPSET_SPATH),
    ("tpath", CAPSET_TPATH),
    ("fown", CAPSET_FOWN),
    ("fattr", CAPSET_FATTR),
    ("net", CAPSET_NET),
    ("inet", CAPSET_INET),
    ("bnet", CAPSET_BNET),
    ("cnet", CAPSET_CNET),
    ("snet", CAPSET_SNET),
];

const CAP_ORDER: &[&str] = &[
    // aliases (keep before base)
    "lpath",
    "rpath",
    "wpath",
    "cpath",
    "dpath",
    "spath",
    "tpath",
    "fown",
    "fattr",
    "net",
    "inet",
    "bnet",
    "cnet",
    "snet",
    // base capabilities
    "fs",
    "walk",
    "stat",
    "read",
    "write",
    "exec",
    "create",
    "delete",
    "rename",
    "symlink",
    "truncate",
    "chdir",
    "readdir",
    "mkdir",
    "rmdir",
    "chown",
    "chgrp",
    "chmod",
    "chattr",
    "chroot",
    "notify",
    "utime",
    "mkbdev",
    "mkcdev",
    "mkfifo",
    "mktemp",
    "net/bind",
    "net/connect",
    "net/sendfd",
];

// landlock(7) category ordering for output.
const LOCK_CAP_ORDER: &[&str] = &[
    "read", "write", "exec", "ioctl", "create", "delete", "rename", "symlink", "truncate",
    "readdir", "mkdir", "rmdir", "mkbdev", "mkcdev", "mkfifo", "bind",
];

// landlock(7) category set aliases.
const LOCK_CAPSETS: &[(&str, &[&str])] = &[
    (
        "all",
        &[
            "read", "write", "exec", "ioctl", "create", "delete", "rename", "symlink", "truncate",
            "readdir", "mkdir", "rmdir", "mkbdev", "mkcdev", "mkfifo", "bind",
        ],
    ),
    (
        "all-x",
        &[
            "read", "write", "ioctl", "create", "delete", "rename", "symlink", "truncate",
            "readdir", "mkdir", "rmdir", "mkbdev", "mkcdev", "mkfifo", "bind",
        ],
    ),
    ("rpath", &["read", "readdir"]),
    ("wpath", &["write", "truncate"]),
    ("cpath", &["create", "delete", "rename"]),
    ("dpath", &["mkbdev", "mkcdev"]),
    ("spath", &["mkfifo", "symlink"]),
    ("tpath", &["mkdir", "rmdir"]),
];

// Guidance printed when a profiled process is killed by SIGSYS (seccomp).
fn sigsys_help() -> String {
    let opt = |s: &str| format!("{}", style(s).bold().red());
    format!(
        "{}
- W^X violation: mmap(2) with PROT_WRITE|PROT_EXEC
  Fix: {}
- mprotect(2) with PROT_EXEC on writable mapping
  Fix: {}
- Block device creation: mknod(2) with S_IFBLK
  Fix: {}
- Char device creation: mknod(2) with S_IFCHR
  Fix: {}
- execve(2)/execveat(2) with NULL argv or envp
  Fix: {}
- Unsafe personality(2): ADDR_NO_RANDOMIZE, READ_IMPLIES_EXEC, etc.
  Fix: {}
- SysV shared memory: shmget(2)/shmat(2)/semget(2)
  Fix: {}
- SysV IPC permissions: shmctl(2)/msgctl(2)/semctl(2) IPC_SET
  Fix: {}
- POSIX message queue: mq_open(2)/msgget(2)
  Fix: {}
- POSIX mq permissions: mq_open(2) mode
  Fix: {}
Check kernel audit log: dmesg | grep -i seccomp
See syd(2) for details on each option.
To pass an unsafe option during profiling, use e.g:
; pandora profile -smtrace/allow_unsafe_exec_memory:1 -- ./program",
        style("Process killed by SIGSYS (seccomp)! Possible causes:")
            .bold()
            .red(),
        opt("trace/allow_unsafe_exec_memory:1"),
        opt("trace/allow_unsafe_exec_memory:1"),
        opt("trace/allow_unsafe_mkbdev:1"),
        opt("trace/allow_unsafe_mkcdev:1"),
        opt("trace/allow_unsafe_exec_null:1"),
        opt("trace/allow_unsafe_personality:1"),
        opt("trace/allow_unsafe_shm:1"),
        opt("trace/allow_unsafe_perm_shm:1"),
        opt("trace/allow_unsafe_msgqueue:1"),
        opt("trace/allow_unsafe_perm_msgqueue:1"),
    )
}

// Map a Syd capability name to the corresponding landlock(7) categories.
//
// Returns `(file_categories, dir_categories)`.
// File categories (read, write, exec, truncate) are applied to the file itself.
// Directory categories (readdir, create, delete, etc.) are applied to the parent directory.
// Capabilities with no landlock(7) equivalent return empty slices.
fn cap2lock(cap: &str) -> (&'static [&'static str], &'static [&'static str]) {
    match cap {
        "read" => (&["read"], &[]),
        "readdir" => (&[], &["readdir"]),
        "write" => (&["write", "truncate"], &[]),
        "truncate" => (&["truncate"], &[]),
        "exec" => (&["read", "exec"], &[]),
        "create" => (&[], &["create"]),
        "delete" => (&[], &["delete"]),
        "rename" => (&[], &["rename"]),
        "symlink" => (&[], &["symlink"]),
        "mkdir" => (&[], &["mkdir"]),
        "rmdir" => (&[], &["rmdir"]),
        "mkbdev" => (&[], &["mkbdev"]),
        "mkcdev" => (&[], &["mkcdev"]),
        "mkfifo" => (&[], &["mkfifo"]),
        "stat" | "walk" | "chdir" | "notify" => (&[], &[]),
        "utime" | "chown" | "chgrp" | "chmod" | "chattr" => (&[], &[]),
        "net/bind" => (&[], &["bind"]),
        _ => (&[], &[]),
    }
}

// Priority for landlock(7) category sorting.
fn cap2prio_lock(cap: &str) -> usize {
    match LOCK_CAP_ORDER.iter().position(|&s| s == cap) {
        Some(idx) => idx + 1,
        None => 0,
    }
}

#[expect(clippy::disallowed_types)]
type PandoraMap<K, V> = std::collections::HashMap<K, V, ahash::RandomState>;
#[expect(clippy::disallowed_types)]
type PandoraSet<K> = std::collections::HashSet<K, ahash::RandomState>;
type PandoraPathList = Vec<(String, PandoraSet<String>)>;

// write! which returns Errno.
macro_rules! w {
    ($out:expr) => {
        retry_on_intr(|| write!($out).or(Err(Errno::EIO)))
    };
    ($out:expr, $($arg:tt)*) => {
        retry_on_intr(|| write!($out, $($arg)*).or(Err(Errno::EIO)))
    };
}

static QUIET: AtomicBool = AtomicBool::new(false);

// writeln! which returns Errno and honours PANDORA_QUIET.
macro_rules! wln {
    (stderr() $(,)?) => {
        if QUIET.load(AtomicOrdering::Relaxed) { Ok(()) }
        else { retry_on_intr(|| writeln!(stderr()).map_err(err2no)) }
    };
    (stderr(), $($arg:tt)*) => {
        if QUIET.load(AtomicOrdering::Relaxed) { Ok(()) }
        else { retry_on_intr(|| writeln!(stderr(), $($arg)*).map_err(err2no)) }
    };
    ($out:expr) => {
        retry_on_intr(|| writeln!($out).map_err(err2no))
    };
    ($out:expr, $($arg:tt)*) => {
        retry_on_intr(|| writeln!($out, $($arg)*).map_err(err2no))
    };
}

#[derive(Clone, Debug)]
enum Capability {
    One(String),
    Some(PandoraSet<String>),
}

impl PartialEq for Capability {
    fn eq(&self, other: &Self) -> bool {
        match (self, other) {
            (Capability::One(s1), Capability::One(s2)) => s1 == s2,
            (Capability::One(s1), Capability::Some(set2)) => set2.len() == 1 && set2.contains(s1),
            (Capability::Some(set1), Capability::One(s2)) => set1.len() == 1 && set1.contains(s2),
            (Capability::Some(set1), Capability::Some(set2)) => {
                set1.len() == set2.len() && set1.is_subset(set2)
            }
        }
    }
}

impl Eq for Capability {}

impl Hash for Capability {
    fn hash<H: Hasher>(&self, state: &mut H) {
        match self {
            Capability::One(s) => {
                s.hash(state);
            }
            Capability::Some(set) => {
                for item in set {
                    item.hash(state);
                }
            }
        }
    }
}

impl Serialize for Capability {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        match self {
            Self::One(s) => s.serialize(serializer),
            Self::Some(set) => set.serialize(serializer),
        }
    }
}

// A custom visitor to handle "either a String or an array of strings."
struct CapabilityVisitor;

impl<'de> Visitor<'de> for CapabilityVisitor {
    type Value = Capability;

    /// A human-friendly description of what this visitor expects.
    fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
        formatter.write_str("either a string or an array for Capability")
    }

    /// If Serde sees a string, we interpret that as `Capability::One(...)`.
    fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
    where
        E: serde::de::Error,
    {
        Ok(Capability::One(value.to_owned()))
    }

    /// If Serde sees a sequence, we interpret that as `Capability::Some(HashSet<...>)`.
    fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
    where
        A: SeqAccess<'de>,
    {
        let mut set = PandoraSet::default();
        while let Some(elem) = seq.next_element::<String>()? {
            set.insert(elem);
        }
        Ok(Capability::Some(set))
    }
}

impl<'de> Deserialize<'de> for Capability {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: Deserializer<'de>,
    {
        deserializer.deserialize_any(CapabilityVisitor)
    }
}

#[derive(Clone, Debug)]
enum IoctlEntry {
    Name(String),
    Val(u64),
}

impl<'de> Deserialize<'de> for IoctlEntry {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: Deserializer<'de>,
    {
        struct IoctlEntryVisitor;

        impl<'de> Visitor<'de> for IoctlEntryVisitor {
            type Value = IoctlEntry;

            fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
                formatter.write_str("integer or string")
            }

            fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
            where
                E: serde::de::Error,
            {
                Ok(IoctlEntry::Val(value))
            }

            fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
            where
                E: serde::de::Error,
            {
                if value < 0 {
                    Err(E::custom("negative integer for ioctl"))
                } else {
                    Ok(IoctlEntry::Val(value as u64))
                }
            }

            fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
            where
                E: serde::de::Error,
            {
                Ok(IoctlEntry::Name(value.to_owned()))
            }

            fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
            where
                E: serde::de::Error,
            {
                Ok(IoctlEntry::Name(value))
            }
        }

        deserializer.deserialize_any(IoctlEntryVisitor)
    }
}

#[derive(Clone, Debug)]
enum Access {
    Path {
        ctx: String,
        cap: Option<Capability>,
        fs: Option<String>,
        path: String,
        tty: Option<String>,
    },
    Ioctl {
        ctx: String,
        ctl: Vec<IoctlEntry>,
    },
    InetAddr {
        ctx: String,
        cap: Option<Capability>,
        addr: String,
    },
    UnixAddr {
        ctx: String,
        cap: Option<Capability>,
        unix: String,
    },
    Run {
        cmd: String,
        argv: Vec<String>,
        time: String,
    },
    Exit {
        code: u8,
    },
    Any {
        _ctx: String,
    },
}

impl<'de> Deserialize<'de> for Access {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: Deserializer<'de>,
    {
        struct AccessVisitor;

        impl<'de> Visitor<'de> for AccessVisitor {
            type Value = Access;

            fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
                write!(f, "a map matching one of the Access enum variants")
            }

            fn visit_map<M>(self, mut map: M) -> Result<Access, M::Error>
            where
                M: MapAccess<'de>,
            {
                // Temporary storage for all possible fields:
                let mut ctx: Option<String> = None;
                let mut cap: Option<Option<Capability>> = None;

                let mut path: Option<String> = None;
                let mut args: Option<Vec<u64>> = None;
                let mut addr: Option<String> = None;
                let mut unix_: Option<String> = None;
                let mut tty: Option<String> = None;

                let mut ctl: Option<Vec<IoctlEntry>> = None;
                let mut fs: Option<String> = None;
                let mut cmd: Option<String> = None;
                let mut argv: Option<Vec<String>> = None;
                let mut time_: Option<String> = None;

                let mut op: Option<String> = None;
                let mut code: Option<u8> = None;

                // Read the incoming map field by field.
                while let Some(key) = map.next_key::<String>()? {
                    match key.as_str() {
                        "ctx" => {
                            if ctx.is_some() {
                                return Err(serde::de::Error::duplicate_field("ctx"));
                            }
                            ctx = map.next_value()?;
                        }
                        "cap" => {
                            if cap.is_some() {
                                return Err(serde::de::Error::duplicate_field("cap"));
                            }
                            cap = map.next_value()?;
                        }
                        "args" => {
                            if args.is_some() {
                                return Err(serde::de::Error::duplicate_field("args"));
                            }
                            args = map.next_value()?;
                        }
                        "path" => {
                            if path.is_some() {
                                return Err(serde::de::Error::duplicate_field("path"));
                            }
                            path = map.next_value()?;
                        }
                        "addr" => {
                            if addr.is_some() {
                                return Err(serde::de::Error::duplicate_field("addr"));
                            }
                            addr = map.next_value()?;
                        }
                        "unix" => {
                            if unix_.is_some() {
                                return Err(serde::de::Error::duplicate_field("unix"));
                            }
                            unix_ = map.next_value()?;
                        }
                        "ctl" => {
                            if ctl.is_some() {
                                return Err(serde::de::Error::duplicate_field("ctl"));
                            }
                            ctl = map.next_value()?;
                        }
                        "fs" => {
                            if fs.is_some() {
                                return Err(serde::de::Error::duplicate_field("fs"));
                            }
                            fs = map.next_value()?;
                        }
                        "cmd" => {
                            if cmd.is_some() {
                                return Err(serde::de::Error::duplicate_field("cmd"));
                            }
                            cmd = map.next_value()?;
                        }
                        "argv" => {
                            if argv.is_some() {
                                return Err(serde::de::Error::duplicate_field("argv"));
                            }
                            argv = map.next_value()?;
                        }
                        "time" => {
                            if time_.is_some() {
                                return Err(serde::de::Error::duplicate_field("time"));
                            }
                            time_ = map.next_value()?;
                        }
                        "op" => {
                            if op.is_some() {
                                return Err(serde::de::Error::duplicate_field("op"));
                            }
                            op = map.next_value()?;
                        }
                        "code" => {
                            if code.is_some() {
                                return Err(serde::de::Error::duplicate_field("code"));
                            }
                            code = map.next_value()?;
                        }
                        "tty" => {
                            if tty.is_some() {
                                return Err(serde::de::Error::duplicate_field("tty"));
                            }
                            tty = map.next_value()?;
                        }
                        _ => {
                            // If there are unknown fields, we ignore.
                            let _ignored: serde::de::IgnoredAny = map.next_value()?;
                        }
                    }
                }

                // We need `ctx` in *every* variant, so ensure we have it
                let ctx = ctx.ok_or_else(|| serde::de::Error::missing_field("ctx"))?;

                // `cap` was stored as Some(...) or None => unwrap it
                let cap = cap.unwrap_or(None);

                // Now decide which variant to build based on which fields we have:
                if let Some(path) = path {
                    Ok(Access::Path {
                        ctx,
                        cap,
                        fs,
                        path,
                        tty,
                    })
                } else if let Some(ctl) = ctl {
                    Ok(Access::Ioctl { ctx, ctl })
                } else if let Some(addr) = addr {
                    Ok(Access::InetAddr { ctx, cap, addr })
                } else if let Some(unix) = unix_ {
                    Ok(Access::UnixAddr { ctx, cap, unix })
                } else if let (Some(cmd), Some(argv), Some(time)) = (cmd, argv, time_) {
                    Ok(Access::Run { cmd, argv, time })
                } else if let (Some(_op), Some(code)) = (op, code) {
                    Ok(Access::Exit { code })
                } else {
                    // If none of those fields were found,
                    // we assume it's the `Any` variant.
                    Ok(Access::Any { _ctx: ctx })
                }
            }
        }

        // Kick off the deserialization by asking for a map.
        deserializer.deserialize_map(AccessVisitor)
    }
}

fn io_to_errno(e: std::io::Error) -> Errno {
    e.raw_os_error().map(Errno::from_raw).unwrap_or(Errno::EIO)
}

// Performs a reverse DNS lookup for the given IP address,
// returning a hostname or an error.
fn lookup_addr(addr: IpAddr) -> Result<String, Errno> {
    #[cfg(feature = "system-dns")]
    {
        dns_lookup::lookup_addr(&addr).map_err(io_to_errno)
    }

    #[cfg(feature = "rust-dns")]
    {
        // Read system DNS configuration (max 4KB).
        let f = File::open("/etc/resolv.conf").map_err(io_to_errno)?;
        let mut buf = Vec::with_capacity(4096);
        f.take(4096).read_to_end(&mut buf).map_err(io_to_errno)?;
        let conf = resolv_conf::Config::parse(&buf).map_err(|_| Errno::EINVAL)?;

        // Pick the first nameserver (IPv4/IPv6).
        let ns = conf
            .nameservers
            .iter()
            .map(|ns| -> SocketAddr {
                match ns {
                    resolv_conf::ScopedIp::V4(ipv4) => SocketAddr::V4(SocketAddrV4::new(*ipv4, 53)),
                    resolv_conf::ScopedIp::V6(ipv6, _scope) => {
                        SocketAddr::V6(SocketAddrV6::new(*ipv6, 53, 0, 0))
                    }
                }
            })
            .next()
            .ok_or(Errno::ENOENT)?;

        // Construct the reverse pointer name.
        let ptr_name = match addr {
            IpAddr::V4(ip) => {
                let octets = ip.octets();
                format!(
                    "{}.{}.{}.{}.in-addr.arpa",
                    octets[3], octets[2], octets[1], octets[0]
                )
            }
            IpAddr::V6(ip) => {
                let octets = ip.octets();
                let mut s = String::with_capacity(72);
                use std::fmt::Write;
                for octet in octets.iter().rev() {
                    let _ = write!(s, "{:x}.{:x}.", octet & 0x0f, (octet >> 4) & 0x0f);
                }
                s + "ip6.arpa"
            }
        };

        // Send Query using simple-dns.
        let qname = simple_dns::Name::new(&ptr_name).map_err(|_| Errno::EINVAL)?;
        let question = simple_dns::Question::new(
            qname.clone(),
            simple_dns::QTYPE::try_from(12).expect("QTYPE::PTR"),
            simple_dns::QCLASS::try_from(1).expect("QCLASS::IN"),
            false,
        );
        let mut packet = simple_dns::Packet::new_query(0);
        packet.questions.push(question);

        // Set Recursion Desired (RD) bit (Byte 2, Bit 0).
        let mut packet_bytes = packet.build_bytes_vec().map_err(|_| Errno::EINVAL)?;
        if packet_bytes.len() > 2 {
            packet_bytes[2] |= 1;
        }

        let socket = UdpSocket::bind("0.0.0.0:0").map_err(io_to_errno)?;
        socket
            .set_read_timeout(Some(Duration::from_secs(2)))
            .map_err(io_to_errno)?;
        socket.connect(ns).map_err(io_to_errno)?;
        socket.send(&packet_bytes).map_err(io_to_errno)?;

        let mut recv_buf = [0u8; 1024];
        let amt = socket.recv(&mut recv_buf).map_err(io_to_errno)?;

        let response = simple_dns::Packet::parse(&recv_buf[..amt]).map_err(|_| Errno::EIO)?;

        if response.answers.is_empty() {
            return Err(Errno::ENOENT);
        }

        // Extract PTR record.
        for answer in response.answers {
            match answer.rdata {
                simple_dns::rdata::RData::PTR(ptr) => return Ok(ptr.0.to_string()),
                _ => continue,
            }
        }

        Err(Errno::ENOENT)
    }
}

/// Maximum digest size for kernel hash algorithms.
///
/// Matches `HASH_MAX_DIGESTSIZE` in linux/include/crypto/hash.h.
const HASH_MAX_DIGESTSIZE: usize = 64;

/// Pipe buffer size for AF_ALG zero-copy operations.
const PIPE_BUF_ALG: usize = 16 * libc::PIPE_BUF;

/// Maximum bytes sendfile(2) can transfer at a time.
const SENDFILE_MAX: usize = 0x7ffff000;

/// Preferred strong hash algorithms in order of preference.
/// The first available algorithm will be used as the default.
const HASH_ALGORITHMS: &[&str] = &[
    "sha3-512",
    "sha3-384",
    "sha3-256",
    "blake2b-512",
    "blake2b-384",
    "blake2b-256",
    "streebog512",
    "streebog256",
    "sha512",
    "sha384",
    "sha256",
    "sha224",
    "sm3",
];

// Hash data using pipes and splice(2) via the Kernel Crypto API (AF_ALG).
//
// Any algorithm listed in proc_crypto(5) with type `ahash` or `shash`
// may be used as `func`.
// If input is `None`, this function hashes empty string which is useful
// to check for algorithm support.
fn hash_pipe<Fd: AsFd>(func: &str, input: Option<Fd>) -> Result<Vec<u8>, Errno> {
    // Create AF_ALG socket and bind it to the requested hash algorithm.
    let addr = AlgAddr::new("hash", func);
    let sock = socket(
        AddressFamily::Alg,
        SockType::SeqPacket,
        SockFlag::SOCK_CLOEXEC,
        None,
    )?;
    bind(sock.as_raw_fd(), &addr)?;

    // Accept a connection to obtain the operation fd.
    let conn_fd =
        retry_on_eintr(|| accept4(sock.as_raw_fd(), SockFlag::SOCK_CLOEXEC)).map(|fd| {
            // SAFETY: accept4 returns a valid fd on success.
            unsafe { OwnedFd::from_raw_fd(fd) }
        })?;

    if let Some(input) = input {
        // Zero-copy: splice(2) data from the input fd through a pipe(2)
        // into the AF_ALG socket. The kernel hashes the data in-kernel
        // without it ever touching our address space.
        let (pipe_rd, pipe_wr) = pipe2(OFlag::O_CLOEXEC)?;

        loop {
            // splice(2) from input fd into the write end of the pipe.
            let n = retry_on_eintr(|| {
                splice(
                    &input,
                    None,
                    &pipe_wr,
                    None,
                    PIPE_BUF_ALG,
                    SpliceFFlags::SPLICE_F_MORE,
                )
            })?;
            if n == 0 {
                // EOF on input.
                break;
            }

            // splice(2) from the read end of the pipe into the hash socket.
            let mut remain = n;
            while remain > 0 {
                let m = retry_on_eintr(|| {
                    splice(
                        &pipe_rd,
                        None,
                        &conn_fd,
                        None,
                        remain,
                        SpliceFFlags::SPLICE_F_MORE,
                    )
                })?;
                if m == 0 {
                    return Err(Errno::EBADMSG);
                }
                remain = remain.checked_sub(m).ok_or(Errno::EOVERFLOW)?;
            }
        }
    } // pipes closed here.

    // Finalise operation by sending an empty message without MSG_MORE flag.
    retry_on_eintr(|| send(conn_fd.as_raw_fd(), &[], MsgFlags::empty()))?;

    // Read the digest via recvmsg(2) and detect truncation.
    let mut buf = Vec::new();
    buf.try_reserve(HASH_MAX_DIGESTSIZE)
        .or(Err(Errno::ENOMEM))?;
    buf.resize(HASH_MAX_DIGESTSIZE, 0);

    let (bytes, trunc) = {
        let mut iov = [IoSliceMut::new(&mut buf)];
        let msg = loop {
            match recvmsg::<()>(conn_fd.as_raw_fd(), &mut iov, None, MsgFlags::empty()) {
                Ok(msg) => break msg,
                Err(Errno::EINTR) => continue,
                Err(e) => return Err(e),
            }
        };
        (msg.bytes, msg.flags.contains(MsgFlags::MSG_TRUNC))
    };

    // SAFETY: Detect truncation and panic!
    assert!(
        bytes > 0,
        "BUG: AF_ALG socket returned zero bytes for algorithm {}, report a bug!",
        func,
    );
    assert!(
        !trunc,
        "BUG: AF_ALG digest size exceeded {} bytes for algorithm {}, report a bug!",
        HASH_MAX_DIGESTSIZE, func,
    );

    // Truncate vector to digest size, and return.
    buf.truncate(bytes);
    buf.shrink_to_fit();

    Ok(buf)
}

// Hash file data using the Kernel Crypto API (AF_ALG) with sendfile64(2).
//
// Any algorithm listed in proc_crypto(5) with type `ahash` or `shash`
// may be used as `func`.
//
// When `size_hint` is `Some(n)`, the sendfile loop stops after
// transferring `n` bytes instead of needing an extra cycle to
// detect EOF.
fn hash_file<Fd: AsFd>(func: &str, input: Fd, size_hint: Option<u64>) -> Result<Vec<u8>, Errno> {
    // Create AF_ALG socket and bind it to the requested hash algorithm.
    let addr = AlgAddr::new("hash", func);
    let sock = socket(
        AddressFamily::Alg,
        SockType::SeqPacket,
        SockFlag::SOCK_CLOEXEC,
        None,
    )?;
    bind(sock.as_raw_fd(), &addr)?;

    // Accept a connection to obtain the operation fd.
    let conn = retry_on_eintr(|| accept4(sock.as_raw_fd(), SockFlag::SOCK_CLOEXEC)).map(|fd| {
        // SAFETY: accept4 returns a valid fd on success.
        unsafe { OwnedFd::from_raw_fd(fd) }
    })?;

    // Transfer data from input fd to AF_ALG socket via sendfile64(2).
    // Linux kernel doesn't finalize hash operation on partial writes.
    // When a size hint is given, stop after transferring that many
    // bytes to avoid an extra sendfile call to detect EOF.
    if let Some(mut remain) = size_hint {
        while remain > 0 {
            let chunk = remain
                .min(SENDFILE_MAX as u64)
                .try_into()
                .or(Err(Errno::EOVERFLOW))?;
            let nsent = retry_on_eintr(|| sendfile64(&conn, &input, None, chunk))?
                .try_into()
                .or(Err(Errno::EOVERFLOW))?;
            if nsent == 0 {
                // File reduced size mid-flight.
                return Err(Errno::EBADMSG);
            }
            remain = remain.checked_sub(nsent).ok_or(Errno::EOVERFLOW)?;
        }
    } else {
        while retry_on_eintr(|| sendfile64(&conn, &input, None, SENDFILE_MAX))? > 0 {}
    }

    // Read the digest via recvmsg(2) and detect truncation.
    let mut buf = Vec::new();
    buf.try_reserve(HASH_MAX_DIGESTSIZE)
        .or(Err(Errno::ENOMEM))?;
    buf.resize(HASH_MAX_DIGESTSIZE, 0);

    let (bytes, trunc) = {
        let mut iov = [IoSliceMut::new(&mut buf)];
        let msg = loop {
            match recvmsg::<()>(conn.as_raw_fd(), &mut iov, None, MsgFlags::empty()) {
                Ok(msg) => break msg,
                Err(Errno::EINTR) => continue,
                Err(e) => return Err(e),
            }
        };
        (msg.bytes, msg.flags.contains(MsgFlags::MSG_TRUNC))
    };

    // SAFETY: Detect truncation and panic!
    assert!(
        bytes > 0,
        "BUG: AF_ALG socket returned zero bytes for algorithm {}, report a bug!",
        func,
    );
    assert!(
        !trunc,
        "BUG: AF_ALG digest size exceeded {} bytes for algorithm {}, report a bug!",
        HASH_MAX_DIGESTSIZE, func,
    );

    // Truncate vector to digest size, and return.
    buf.truncate(bytes);
    buf.shrink_to_fit();

    Ok(buf)
}

// Hash data with zero-copy using the Kernel Crypto API (AF_ALG).
fn hash<Fd: AsFd>(func: &str, input: &Fd) -> Result<Vec<u8>, Errno> {
    if let Ok(stx) = fstatx(input, STATX_TYPE | STATX_SIZE) {
        // Reject unsupported file types early.
        let ftype = stx.stx_mode as libc::mode_t & libc::S_IFMT;
        if !matches!(ftype, libc::S_IFREG | libc::S_IFIFO) {
            return Err(Errno::EBADFD);
        }

        // Use sendfile64(2) for regular files that fit in one pass.
        if ftype == libc::S_IFREG && stx.stx_size <= SENDFILE_MAX as u64 {
            return hash_file(func, input, Some(stx.stx_size));
        }
    }

    // Fallback to pipe(2) and splice(2) for FIFOs and large files.
    hash_pipe(func, Some(input))
}

// Detect the best available hash algorithm from the running kernel.
// Returns `None` if AF_ALG is not supported at all.
fn hash_auto() -> Option<String> {
    for &alg in HASH_ALGORITHMS {
        match hash_pipe::<OwnedFd>(alg, None) {
            Ok(_) => return Some(alg.to_string()),
            // ENOENT: Algorithm not available, try next.
            Err(Errno::ENOENT) => continue,
            Err(_) => return None,
        }
    }
    None
}

// Define SYSLOG_ACTION_* constants.
// libc does not have to define these.
const SYSLOG_ACTION_READ_ALL: libc::c_int = 3;
const SYSLOG_ACTION_SIZE_BUFFER: libc::c_int = 10;

struct Syslog;

impl Syslog {
    fn open() -> io::Result<io::Cursor<Vec<u8>>> {
        let mut buf = vec![0u8; Self::capacity()?];
        loop {
            return match Syslog.read(&mut buf) {
                Ok(n) => {
                    buf.truncate(n);
                    Ok(io::Cursor::new(buf))
                }
                Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
                Err(e) => return Err(e),
            };
        }
    }

    fn capacity() -> io::Result<usize> {
        // Retrieve the total size of the kernel log buffer.
        // SAFETY: There's no nix interface for this.
        loop {
            return match Errno::result(unsafe {
                libc::syscall(libc::SYS_syslog, SYSLOG_ACTION_SIZE_BUFFER)
            }) {
                Ok(n) => Ok(n as usize),
                Err(Errno::EINTR) => continue,
                Err(errno) => Err(io::Error::from_raw_os_error(errno as i32)),
            };
        }
    }
}

impl Read for Syslog {
    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
        // Perform the syslog syscall with SYSLOG_ACTION_READ_ALL.
        Errno::result(unsafe {
            libc::syscall(
                libc::SYS_syslog,
                SYSLOG_ACTION_READ_ALL,
                buf.as_mut_ptr() as *mut libc::c_char,
                buf.len(),
            )
        })
        .map(|size| size as usize)
        .map_err(|errno| io::Error::from_raw_os_error(errno as i32))
    }
}

// Top-level subcommands.
enum MainOpts {
    // "profile" subcommand
    Profile(ProfileOpts),
    // "inspect" subcommand
    Inspect(InspectOpts),
    // Top-level help
    Help,
    // Top-level version
    Version,
}

// Options for `profile` subcommand.
struct ProfileOpts {
    // Syd binary
    bin: String,
    // Repeated -s flags
    syd: Vec<String>,
    // Output path
    output: String,
    // Hash algorithm (None = AF_ALG unavailable)
    hash: Option<String>,
    // Path limit
    limit: u8,
    // Optional timeout
    timeout: Option<Duration>,
    // Thread count
    threads: usize,
    // Positional subcommand
    cmd: Vec<OsString>,
}

// Options for `inspect` subcommand.
struct InspectOpts {
    // Input path
    input: String,
    // Output path
    output: String,
    // Hash algorithm (None = AF_ALG unavailable)
    hash: Option<String>,
    // Path limit
    limit: u8,
    // Threads
    threads: usize,
}

// Internal map to track access control rules.
struct AccessMap {
    magic: PandoraMap<String, PandoraSet<String>>,
    force: PandoraSet<String>,
    fs_types: PandoraSet<String>,
    ioctl: PandoraSet<u64>,
    ioctl_names: StringPatriciaSet,
    lock_file_paths: PandoraMap<String, PandoraSet<String>>,
    lock_dir_paths: PandoraMap<String, PandoraSet<String>>,
    lock_bind_ports: FixedBitSet,
    lock_connect_ports: FixedBitSet,
    tty: Option<String>,
    unsafe_opts: PandoraMap<String, UnsafeOptCtx>,
}

#[derive(Debug, Default)]
struct UnsafeOptCtx {
    ctx: Option<String>,
    msg: Option<String>,
    cmd: Option<String>,
    fns: BTreeSet<String>,
}

impl Default for AccessMap {
    fn default() -> Self {
        Self {
            magic: PandoraMap::default(),
            force: PandoraSet::default(),
            fs_types: PandoraSet::default(),
            ioctl: PandoraSet::default(),
            ioctl_names: StringPatriciaSet::default(),
            lock_file_paths: PandoraMap::default(),
            lock_dir_paths: PandoraMap::default(),
            lock_bind_ports: FixedBitSet::with_capacity(65536),
            lock_connect_ports: FixedBitSet::with_capacity(65536),
            tty: None,
            unsafe_opts: PandoraMap::default(),
        }
    }
}

impl AccessMap {
    /// Parse each JSON line for relevant info.
    fn parse_json_line(&mut self, line: &str, path_limit: u8) -> Option<Access> {
        // SAFETY: Be permissive and skip all characters up until
        // the first '{'. This makes it easy to pipe dmesg(1) output
        // to pandora(1).
        let line = line.trim();
        let line = if let Some(start) = line.find('{') {
            &line[start.saturating_sub(1)..]
        } else {
            return None;
        };

        // SAFETY: Skip lines that cannot be parsed.
        // Warn about errors if PANDORA_DEBUG is set.
        let value: serde_json::Value = match serde_json::from_str(line) {
            Ok(v) => v,
            Err(err) => {
                if env::var_os("PANDORA_DEBUG").is_some() {
                    let _ = wln!(
                        stderr(),
                        "{} {}",
                        style("pandora:").bold().magenta(),
                        style("skip invalid JSON!").bold().yellow()
                    );
                    let _ = wln!(
                        stderr(),
                        "\t{} {}",
                        style("LINE:").bold().cyan(),
                        style(line).bold().red()
                    );
                    let _ = wln!(
                        stderr(),
                        "\t{} {}",
                        style("ERROR:").bold().cyan(),
                        style(err.to_string()).bold().red()
                    );
                }
                return None;
            }
        };

        // Extract trace/allow_unsafe_* options from tip messages.
        extract_unsafe_tips(&value, &mut self.unsafe_opts);

        let json: Access = match serde_json::from_value(value) {
            Ok(a) => a,
            Err(_) => return None,
        };

        match json {
            Access::Path {
                ctx,
                cap,
                fs,
                path,
                tty,
                ..
            } if ctx == "access" => {
                // Capture session TTY from the first log entry that has one.
                // TODO: Support multiple processes with differing PTYs.
                if self.tty.is_none() {
                    if let Some(ref tty) = tty {
                        if tty != "/dev/null" {
                            self.tty = Some(tty.clone());
                        }
                    }
                }
                let mut capabilities = match cap {
                    None => return None,
                    Some(Capability::One(cap)) => {
                        let mut caps = PandoraSet::default();
                        caps.insert(cap);
                        caps
                    }
                    Some(Capability::Some(caps)) => caps,
                };

                if capabilities.contains("exec") {
                    self.force.insert(path.clone());
                }

                if let Some(ref fs_type) = fs {
                    self.fs_types.insert(fs_type.clone());
                    capabilities.remove("fs");
                    if capabilities.is_empty() {
                        return None;
                    }
                }

                // Collect landlock(7) paths from raw absolute path.
                if path.starts_with('/') {
                    // Promote nsfs entries to parent directory for landlock(7).
                    let is_nsfs = fs.as_deref() == Some("nsfs");
                    for cap in &capabilities {
                        let (file_cats, dir_cats) = cap2lock(cap);
                        if !file_cats.is_empty() {
                            if is_nsfs {
                                // nsfs: promote file caps to parent as dir.
                                let parent = parent_path(&path);
                                let entry = self.lock_dir_paths.entry(parent).or_default();
                                for &lcat in file_cats {
                                    entry.insert(lcat.to_string());
                                }
                            } else {
                                let entry = self.lock_file_paths.entry(path.clone()).or_default();
                                for &lcat in file_cats {
                                    entry.insert(lcat.to_string());
                                }
                            }
                        }
                        if !dir_cats.is_empty() {
                            let parent = parent_path(&path);
                            let entry = self.lock_dir_paths.entry(parent).or_default();
                            for &lcat in dir_cats {
                                entry.insert(lcat.to_string());
                            }
                        }
                    }
                }

                // Substitute known SYD_* values:
                // UID/GID must be replaced before process_path to prevent
                // is_tmp_comp from globbing numeric UID components.
                // HOME/PWD substitution happens later in the profile writer.
                let path = if self.tty.as_deref() == Some(path.as_str()) {
                    "${SYD_TTY}".to_string()
                } else {
                    let path = subst_id(&path);
                    process_path(&path, path_limit).to_string()
                };
                let pty = path == "/dev/pts/[0-9]*" || path == "${SYD_TTY}";
                self.magic
                    .entry(path.clone())
                    .or_default()
                    .extend(capabilities.clone());

                // When a devpts access to /dev/tty is seen and we know the
                // session TTY, also add ${SYD_TTY} with the same caps.
                if path == "/dev/tty" && fs.as_deref() == Some("devpts") && self.tty.is_some() {
                    self.magic
                        .entry("${SYD_TTY}".to_string())
                        .or_default()
                        .extend(capabilities.clone());
                }

                // Workaround for PTY listing.
                if pty {
                    let mut caps = PandoraSet::default();
                    caps.insert("readdir".to_string());
                    self.magic
                        .entry("/dev/pts".to_string())
                        .or_default()
                        .extend(caps);
                }
            }
            Access::UnixAddr { ctx, cap, unix, .. } if ctx == "access" => {
                let capabilities = match cap {
                    None => return None,
                    Some(Capability::One(cap)) => {
                        let mut caps = PandoraSet::default();
                        caps.insert(cap);
                        caps
                    }
                    Some(Capability::Some(caps)) => caps,
                };

                // Collect landlock(7) directories for UNIX domain socket binds.
                // MAKE_SOCK is dir-only so use the parent directory.
                if unix.starts_with('/') && capabilities.contains("net/bind") {
                    let parent = parent_path(&unix);
                    let lock_entry = self.lock_dir_paths.entry(parent).or_default();
                    lock_entry.insert("bind".to_string());
                }

                // We override the path limit for UNIX sockets for clarity.
                let unix = process_path(&unix, u8::MAX).to_string();
                self.magic.entry(unix).or_default().extend(capabilities);
            }
            Access::Ioctl { ctx, ctl, .. } if ctx == "access" => {
                for req in ctl {
                    match req {
                        IoctlEntry::Val(val) => {
                            self.ioctl.insert(val);
                        }
                        IoctlEntry::Name(name) => {
                            self.ioctl_names.insert(name);
                        }
                    }
                }
            }
            Access::InetAddr { ctx, cap, addr, .. } if ctx == "access" => {
                let capabilities = match cap {
                    None => return None,
                    Some(Capability::One(cap)) => {
                        let mut caps = PandoraSet::default();
                        caps.insert(cap);
                        caps
                    }
                    Some(Capability::Some(caps)) => caps,
                };

                // Collect landlock(7) bind/connect ports from IP addresses.
                // Addresses are in the form "ip!port" or "ip!port-port".
                if let Some(port_part) = addr.split('!').nth(1) {
                    if let Ok(port) = port_part.split('-').next().unwrap_or("").parse::<u16>() {
                        if capabilities.contains("net/bind") {
                            self.lock_bind_ports.insert(port as usize);
                        }
                        if capabilities.contains("net/connect") {
                            self.lock_connect_ports.insert(port as usize);
                        }
                    }
                }

                self.magic.entry(addr).or_default().extend(capabilities);
            }
            Access::Run { .. } | Access::Exit { .. } => return Some(json),
            _ => {}
        };

        None
    }
}

// Extract `trace/allow_unsafe_*` options from a parsed JSON value.
fn extract_unsafe_tips(value: &serde_json::Value, opts: &mut PandoraMap<String, UnsafeOptCtx>) {
    let map = match value.as_object() {
        Some(m) => m,
        None => return,
    };

    let needle = "trace/allow_unsafe_";
    let tip = match map.get("tip").and_then(|v| v.as_str()) {
        Some(t) if t.contains(needle) => t,
        _ => return,
    };

    // Extract the option string from the tip value.
    let mut pos = 0;
    while let Some(start) = tip[pos..].find(needle) {
        let abs_start = pos + start;
        let rest = &tip[abs_start..];
        let end = rest
            .find(|c: char| !c.is_alphanumeric() && c != '_' && c != ':' && c != '/')
            .unwrap_or(rest.len());
        let option = &rest[..end];
        if !option.is_empty() {
            let entry = opts.entry(option.to_string()).or_default();
            if entry.ctx.is_none() {
                entry.ctx = map.get("ctx").and_then(|v| v.as_str()).map(String::from);
            }
            if entry.msg.is_none() {
                entry.msg = map.get("msg").and_then(|v| v.as_str()).map(String::from);
            }
            if entry.cmd.is_none() {
                entry.cmd = map.get("cmd").and_then(|v| v.as_str()).map(String::from);
            }
            if let Some(path) = map.get("path").and_then(|v| v.as_str()) {
                entry.fns.insert(path.to_string());
            }
        }
        pos = abs_start + end;
    }
}

fn command_profile(opts: ProfileOpts) -> Result<ExitCode, Errno> {
    if Path::new(&opts.output).exists() {
        wln!(
            stderr(),
            "{} error creating output file: `{}' already exists!",
            style("pandora:").bold().magenta(),
            style(opts.output.clone()).bold().yellow(),
        )
        .unwrap();
        return Ok(ExitCode::from(1));
    }

    let (fd_rd, fd_rw) = match pipe2(OFlag::O_CLOEXEC) {
        Ok((fd_rd, fd_rw)) => (fd_rd, fd_rw),
        Err(error) => {
            wln!(
                stderr(),
                "{} error creating pipe: {}!",
                style("pandora:").bold().magenta(),
                style(error.to_string()).bold().red()
            )
            .unwrap();
            return Ok(ExitCode::from(1));
        }
    };

    let mut syd = Command::new(opts.bin);

    // Pass write end of pipe fd with SYD_LOG_FD.
    set_cloexec(&fd_rw, false)?;
    let log_fd = fd_rw.as_raw_fd().to_string();
    syd.env("SYD_LOG_FD", &log_fd);
    if env::var_os("PANDORA_DEBUG").is_some() {
        let ino = fstatx(&fd_rw, STATX_INO).map(|stx| stx.stx_ino)?;
        let _ = wln!(
            stderr(),
            "{} Syd log fd set to pipe {} with inode {}.",
            style("pandora:").bold().magenta(),
            style(&log_fd).bold().green(),
            style(&ino.to_string()).bold().cyan(),
        );
    }

    // Force line-oriented JSON with SYD_QUIET_TTY.
    syd.env("SYD_QUIET_TTY", "1");

    // Pass extra options to Syd.
    for opt in &opts.syd {
        syd.arg(format!("-{opt}"));
    }

    // Enable trace mode.
    // This is currently equivalent to -ptrace.
    syd.arg("-x");

    // Pass Command to execute.
    syd.arg("--").args(opts.cmd);

    // Spawn Syd.
    let mut child = syd.spawn().map_err(err2no)?;

    // Block SIGINT in the parent process.
    let mut mask = SigSet::empty();
    mask.add(Signal::SIGINT);
    sigprocmask(SigmaskHow::SIG_BLOCK, Some(&mask), None)?;

    if let Some(cmd_timeout) = opts.timeout {
        let pid = Pid::from_raw(child.id() as pid_t);
        thread::Builder::new()
            .name("pandora_mon".to_string())
            .spawn(move || {
                thread::sleep(cmd_timeout);
                let _ = wln!(
                    stderr(),
                    "{} {}",
                    style("pandora:").bold().magenta(),
                    style("Timeout expired, terminating process...")
                        .bold()
                        .yellow()
                );
                let _ = kill(pid, Signal::SIGKILL);
            })
            .map_err(err2no)?;
    }

    drop(fd_rw); // close the write end of the pipe.
    let input = Box::new(BufReader::new(File::from(fd_rd)));
    let result = do_inspect(
        input,
        &opts.output,
        opts.hash.as_deref(),
        opts.limit,
        opts.threads,
        Some(opts.syd),
    );

    // Wait for Syd to exit.
    let _ = child.wait();

    // Return exit status.
    result
}

fn command_inspect(opts: InspectOpts) -> Result<ExitCode, Errno> {
    let input = open_input(&opts.input);
    do_inspect(
        input,
        &opts.output,
        opts.hash.as_deref(),
        opts.limit,
        opts.threads,
        None,
    )
}

// Main entry point.
fn main() -> Result<ExitCode, lexopt::Error> {
    if env::var_os("PANDORA_QUIET").is_some() {
        QUIET.store(true, AtomicOrdering::Relaxed);
    }

    // If PANDORA_NPROC isn't set, default to num_cpus.
    if env::var_os("PANDORA_NPROC").is_none() {
        env::set_var("PANDORA_NPROC", num_cpus::get().to_string());
    }

    let opts = parse_main_opts()?;

    let result = match opts {
        MainOpts::Help => {
            print_help_main();
            Ok(ExitCode::SUCCESS)
        }
        MainOpts::Version => {
            print_version();
            Ok(ExitCode::SUCCESS)
        }
        MainOpts::Profile(p) => command_profile(p),
        MainOpts::Inspect(p) => command_inspect(p),
    };

    match result {
        Ok(code) => Ok(code),
        Err(errno) => Ok(ExitCode::from(errno as i32 as u8)),
    }
}

// Parse the top-level argument.
fn parse_main_opts() -> Result<MainOpts, lexopt::Error> {
    use lexopt::prelude::*;

    // Parse CLI options.
    //
    // Note, option parsing is POSIXly correct:
    // POSIX recommends that no more options are parsed after the first
    // positional argument. The other arguments are then all treated as
    // positional arguments.
    // See: https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap12.html#tag_12_02
    let mut parser = lexopt::Parser::from_env();
    let first_arg = parser.next()?;

    match first_arg {
        None => Ok(MainOpts::Help),
        Some(Short('h') | Long("help")) => Ok(MainOpts::Help),
        Some(Short('V') | Long("version")) => Ok(MainOpts::Version),
        Some(ref arg @ Value(ref cmd)) => match cmd.as_bytes() {
            b"profile" => {
                let prof = parse_profile_opts(parser)?;
                Ok(MainOpts::Profile(prof))
            }
            b"inspect" => {
                let insp = parse_inspect_opts(parser)?;
                Ok(MainOpts::Inspect(insp))
            }
            _ => Err(arg.clone().unexpected()),
        },
        Some(arg) => Err(arg.unexpected()),
    }
}

// Parse "profile" subcommand options.
fn parse_profile_opts(mut parser: lexopt::Parser) -> Result<ProfileOpts, lexopt::Error> {
    use lexopt::prelude::*;

    let bin = parse_env_str("SYD_BIN", b"syd").map_err(|errno| lexopt::Error::ParsingFailed {
        value: "SYD_BIN".to_string(),
        error: Box::new(errno),
    })?;
    let output = parse_env_str("PANDORA_OUT", b"./pandora_out.syd-3").map_err(|errno| {
        lexopt::Error::ParsingFailed {
            value: "PANDORA_OUT".to_string(),
            error: Box::new(errno),
        }
    })?;
    let hash = match env::var("PANDORA_HASH") {
        Ok(val) if !val.is_empty() => Some(val),
        _ => Some("auto".to_string()),
    };
    let limit = parse_env_u8("PANDORA_LIMIT", 3).map_err(|errno| lexopt::Error::ParsingFailed {
        value: "PANDORA_LIMIT".to_string(),
        error: Box::new(errno),
    })?;
    let timeout = {
        let val = env::var_os("PANDORA_TIMEOUT").unwrap_or_default();
        if val.is_empty() {
            None
        } else {
            Some(
                parse_os_u64(&val)
                    .map(Duration::from_secs)
                    .map_err(|errno| lexopt::Error::ParsingFailed {
                        value: "PANDORA_TIMEOUT".to_string(),
                        error: Box::new(errno),
                    })?,
            )
        }
    };
    let threads = parse_env_usize("PANDORA_NPROC", num_cpus::get()).map_err(|errno| {
        lexopt::Error::ParsingFailed {
            value: "PANDORA_NPROC".to_string(),
            error: Box::new(errno),
        }
    })?;

    let mut prof = ProfileOpts {
        bin,
        syd: Vec::new(),
        output,
        hash,
        limit,
        timeout,
        threads,
        cmd: vec![env::var_os("SYD_SHELL").unwrap_or(OsString::from("/bin/sh"))],
    };

    while let Some(arg) = parser.next()? {
        match arg {
            // -h => subcommand help
            Short('h') | Long("help") => {
                print_help_profile();
                std::process::exit(0);
            }
            // -V => version
            Short('V') | Long("version") => {
                print_version();
                std::process::exit(0);
            }
            // -H => hash algorithm.
            Short('H') => {
                prof.hash = Some(parse_utf8_str(parser.value()?.as_bytes()).map_err(|errno| {
                    lexopt::Error::ParsingFailed {
                        value: "-H".to_string(),
                        error: Box::new(errno),
                    }
                })?);
            }
            // -x => bin
            Short('x') => {
                prof.bin = parse_utf8_str(parser.value()?.as_bytes()).map_err(|errno| {
                    lexopt::Error::ParsingFailed {
                        value: "-x".to_string(),
                        error: Box::new(errno),
                    }
                })?;
            }
            // -s => repeated Syd options
            Short('s') => {
                prof.syd
                    .push(parse_utf8_str(parser.value()?.as_bytes()).map_err(|errno| {
                        lexopt::Error::ParsingFailed {
                            value: "-s".to_string(),
                            error: Box::new(errno),
                        }
                    })?);
            }
            // -o => output
            Short('o') => {
                prof.output = parse_utf8_str(parser.value()?.as_bytes()).map_err(|errno| {
                    lexopt::Error::ParsingFailed {
                        value: "-s".to_string(),
                        error: Box::new(errno),
                    }
                })?;
            }
            // -l => limit
            Short('l') => {
                prof.limit = parse_u8(parser.value()?.as_bytes()).map_err(|errno| {
                    lexopt::Error::ParsingFailed {
                        value: "-l".to_string(),
                        error: Box::new(errno),
                    }
                })?;
            }
            // -t => timeout
            Short('t') => {
                prof.timeout = Some(
                    parse_u64(parser.value()?.as_bytes())
                        .map(Duration::from_secs)
                        .map_err(|errno| lexopt::Error::ParsingFailed {
                            value: "-t".to_string(),
                            error: Box::new(errno),
                        })?,
                );
            }
            // -T => threads
            Short('T') => {
                prof.threads = parse_usize(parser.value()?.as_bytes()).map_err(|errno| {
                    lexopt::Error::ParsingFailed {
                        value: "-T".to_string(),
                        error: Box::new(errno),
                    }
                })?;
            }
            // positional => belongs to cmd
            Value(prog) => {
                prof.cmd.clear();
                prof.cmd.push(prog);
                prof.cmd.extend(parser.raw_args()?);
            }
            _ => return Err(arg.unexpected()),
        }
    }

    // Resolve "auto" hash to actual kernel algorithm.
    if prof.hash.as_deref() == Some("auto") {
        prof.hash = hash_auto();
    }

    Ok(prof)
}

// Parse "inspect" subcommand options.
fn parse_inspect_opts(mut parser: lexopt::Parser) -> Result<InspectOpts, lexopt::Error> {
    use lexopt::prelude::*;

    let input =
        parse_env_str("PANDORA_IN", b"-").map_err(|errno| lexopt::Error::ParsingFailed {
            value: "PANDORA_IN".to_string(),
            error: Box::new(errno),
        })?;
    let output = parse_env_str("PANDORA_OUT", b"./pandora_out.syd-3").map_err(|errno| {
        lexopt::Error::ParsingFailed {
            value: "PANDORA_OUT".to_string(),
            error: Box::new(errno),
        }
    })?;
    let hash = match env::var("PANDORA_HASH") {
        Ok(val) if !val.is_empty() => Some(val),
        _ => Some("auto".to_string()),
    };
    let limit = parse_env_u8("PANDORA_LIMIT", 3).map_err(|errno| lexopt::Error::ParsingFailed {
        value: "PANDORA_LIMIT".to_string(),
        error: Box::new(errno),
    })?;
    let threads = parse_env_usize("PANDORA_NPROC", num_cpus::get()).map_err(|errno| {
        lexopt::Error::ParsingFailed {
            value: "PANDORA_NPROC".to_string(),
            error: Box::new(errno),
        }
    })?;

    let mut io = InspectOpts {
        input,
        output,
        hash,
        limit,
        threads,
    };

    while let Some(arg) = parser.next()? {
        match arg {
            Short('h') | Long("help") => {
                print_help_inspect();
                std::process::exit(0);
            }
            Short('V') | Long("version") => {
                print_version();
                std::process::exit(0);
            }
            // -H => hash
            Short('H') => {
                io.hash = Some(parse_utf8_str(parser.value()?.as_bytes()).map_err(|errno| {
                    lexopt::Error::ParsingFailed {
                        value: "-H".to_string(),
                        error: Box::new(errno),
                    }
                })?);
            }
            // -i => input
            Short('i') => {
                io.input = parse_utf8_str(parser.value()?.as_bytes()).map_err(|errno| {
                    lexopt::Error::ParsingFailed {
                        value: "-i".to_string(),
                        error: Box::new(errno),
                    }
                })?;
            }
            // -o => output
            Short('o') => {
                io.output = parse_utf8_str(parser.value()?.as_bytes()).map_err(|errno| {
                    lexopt::Error::ParsingFailed {
                        value: "-o".to_string(),
                        error: Box::new(errno),
                    }
                })?;
            }
            // -l => limit
            Short('l') => {
                io.limit = parse_u8(parser.value()?.as_bytes()).map_err(|errno| {
                    lexopt::Error::ParsingFailed {
                        value: "-l".to_string(),
                        error: Box::new(errno),
                    }
                })?;
            }
            // -T => threads
            Short('T') => {
                io.threads = parse_usize(parser.value()?.as_bytes()).map_err(|errno| {
                    lexopt::Error::ParsingFailed {
                        value: "-T".to_string(),
                        error: Box::new(errno),
                    }
                })?;
            }
            _ => return Err(arg.unexpected()),
        }
    }

    // Resolve "auto" hash to actual kernel algorithm.
    if io.hash.as_deref() == Some("auto") {
        io.hash = hash_auto();
    }

    Ok(io)
}

// Main function that reads logs, collects data, and writes the Syd profile.
fn do_inspect(
    input: Box<dyn std::io::BufRead>,
    output_path: &str,
    hash_function: Option<&str>,
    path_limit: u8,
    concurrency: usize,
    extra_options: Option<Vec<String>>,
) -> Result<ExitCode, Errno> {
    let mut access = AccessMap::default();
    let mut output = open_output(output_path);
    let mut program_command_line = vec![];
    let mut program_startup_time = "?".to_string();
    let mut program_invocation_name = "?".to_string();
    let mut program_exit_code: u8 = 0;

    for line in input.lines() {
        // Read line, continue on errors.
        let line = match line {
            Ok(line) => line,
            Err(_) => continue,
        };

        // Parse JSON.
        if let Some(json) = access.parse_json_line(&line, path_limit) {
            match json {
                Access::Run {
                    cmd, argv, time, ..
                } => {
                    program_invocation_name = cmd;
                    program_command_line = argv;
                    program_startup_time = time;
                }
                Access::Exit { code, .. } => {
                    program_exit_code = code;
                }
                _ => {}
            }
        }
    }

    let cmd = format!(
        "{program_invocation_name} {}",
        program_command_line.join(" ")
    );
    let cmd = cmd.trim_end();

    let m = if QUIET.load(AtomicOrdering::Relaxed) {
        MultiProgress::with_draw_target(ProgressDrawTarget::hidden())
    } else {
        MultiProgress::new()
    };
    let _ = m.println(format!(
        "{} command `{}' exited with {}{}",
        style("pandora:").bold().magenta(),
        style(cmd).bold().yellow(),
        if program_exit_code == 0 {
            style("success".to_string()).bold().green()
        } else {
            style(format!("error {program_exit_code}")).bold().red()
        },
        if program_exit_code == 0 { "." } else { "!" },
    ));
    let _ = m.println(format!(
        "{} profile generation started.",
        style("pandora:").bold().magenta(),
    ));

    let mut config = Vec::new();
    if let Some(options) = extra_options {
        for option in options {
            match option.chars().next() {
                Some('m') => config.push(option[1..].to_string()),
                Some('P') => config.push(format!("include {}", &option[1..])),
                Some('p') => config.push(format!("include_profile {}", &option[1..])),
                _ => continue,
            }
        }
    }
    let config = config.join("\n");

    // Print out the magic header.
    wln!(
        &mut output,
        "#
# Syd profile generated by Pandora-{PKG_VERSION}
# PROG: {program_invocation_name}
# ARGS: {program_command_line:?}
# DATE: {program_startup_time}\n"
    )?;
    let _ = m.println(format!(
        "{} profile header written.",
        style("pandora:").bold().magenta(),
    ));

    // If user passed custom config lines, include them.
    if !config.is_empty() {
        wln!(
            &mut output,
            "###\n# User submitted options\n###\n{config}\n"
        )?;
        let _ = m.println(format!(
            "{} user submitted options written.",
            style("pandora").bold().magenta(),
        ));
    }

    // Print unsafe options section if any tips were captured.
    if !access.unsafe_opts.is_empty() {
        wln!(
            &mut output,
            "###
# Unsafe Options
# Uncomment options below at your own risk.
# Refer to the syd(2) manual page for details.
###"
        )?;
        let mut unsafe_list: Vec<_> = access.unsafe_opts.iter().collect();
        unsafe_list.sort_by_key(|(a, _)| *a);
        for (option, uctx) in &unsafe_list {
            let ctx = uctx.ctx.as_deref().unwrap_or("?");
            let msg = uctx.msg.as_deref().unwrap_or("?");
            wln!(&mut output, "# {ctx}: {msg}")?;
            if let Some(cmd) = &uctx.cmd {
                wln!(&mut output, "# cmd: {cmd}")?;
            }
            if !uctx.fns.is_empty() {
                wln!(&mut output, "# fns:")?;
                for path in &uctx.fns {
                    wln!(&mut output, "# - {path}")?;
                }
            }
            wln!(&mut output, "#{option}")?;
        }
        let _ = wln!(
            stderr(),
            "{} found {} unsafe option(s) from Syd tips.",
            style("pandora:").bold().magenta(),
            style(unsafe_list.len().to_string()).bold().yellow(),
        );
        wln!(&mut output, "")?;
    }

    wln!(&mut output, "###\n# Sandbox Rules\n###")?;

    // Extract session TTY for path generalization.
    let tty = access.tty.clone();

    // Print filesystem sandboxing rules.
    if !access.fs_types.is_empty() {
        let mut fs_types = access.fs_types.into_iter().collect::<Vec<_>>();
        fs_types.sort();
        wln!(&mut output, "allow/fs+{}\n", fs_types.join(","))?;
    }

    // Consolidate sibling magic paths sharing ephemeral suffixes.
    let magic = dedup_paths(consolidate_sibling_paths(access.magic));

    // Print out all the sandbox rules from `magic`.
    let mut list = Vec::from_iter(magic);
    // Alphabetical sort.
    list.sort_by_key(|(path, _)| path.to_string());
    // Sort reverse by Capability priority.
    list.sort_by_key(|(_, caps)| std::cmp::Reverse(caps.iter().map(cap2prio).sum::<usize>()));
    // Sort reverse by Capability count.
    list.sort_by_key(|(_, caps)| std::cmp::Reverse(caps.iter().count()));

    let len = list.len();
    let mut lastcap: Option<PandoraSet<String>> = None;
    for entry in &list {
        let raw_elem = &entry.0;
        let elem = subst_env(raw_elem, tty.as_deref());
        let mut caps = entry.1.clone();
        assert!(!caps.is_empty(), "Invalid rule!");

        if let Some(ref cap) = lastcap {
            if !cap.is_subset(&caps) {
                wln!(&mut output)?;
                lastcap = Some(caps.clone());
            }
        } else {
            lastcap = Some(caps.clone());
        }

        let mut done = false;
        if caps.contains("net/bind") {
            if ['/', '@', '!'].iter().any(|&c| raw_elem.starts_with(c)) {
                // UNIX socket (domain, abstract or unnamed).
                wln!(&mut output, "allow/bnet+{}", elem)?;
            } else {
                // IPv{4,6} address
                let ip = raw_elem.split('!').next().ok_or(Errno::EINVAL)?;
                let ip = ip.parse::<IpAddr>().or(Err(Errno::EINVAL))?;
                if let Ok(host) = lookup_addr(ip) {
                    wln!(&mut output, "# {host}")?;
                }
                wln!(&mut output, "allow/bnet+{}", elem)?;
            }
            done = true;
        }
        if caps.contains("net/connect") {
            if ['/', '@', '!'].iter().any(|&c| raw_elem.starts_with(c)) {
                // UNIX socket (domain, abstract or unnamed).
                wln!(&mut output, "allow/cnet+{}", elem)?;
            } else {
                let ip = raw_elem.split('!').next().ok_or(Errno::EINVAL)?;
                let ip = ip.parse::<IpAddr>().or(Err(Errno::EINVAL))?;
                if let Ok(host) = lookup_addr(ip) {
                    wln!(&mut output, "# {host}")?;
                }
                wln!(&mut output, "allow/cnet+{}", elem)?;
            }
            done = true;
        }
        if caps.contains("net/sendfd") {
            if ['/', '@', '!'].iter().any(|&c| raw_elem.starts_with(c)) {
                // UNIX socket (domain, abstract or unnamed).
                wln!(&mut output, "allow/snet+{elem}")?;
            } else {
                unreachable!("BUG: invalid snet entry {:?}", entry);
            }
            caps.remove("net/sendfd");
            done = true;
        }

        if done {
            continue;
        }

        // Perform alias expansion.
        loop {
            let mut changed = false;

            for (alias, members) in CAPSETS {
                if caps.contains(*alias) {
                    continue;
                }
                if members.iter().all(|m| caps.contains(*m)) {
                    for m in *members {
                        changed |= caps.remove(*m);
                    }
                    changed |= caps.insert((*alias).to_string());
                }
            }

            if !changed {
                break;
            }
        }

        // Convert to vector and sort.
        let mut caps = caps.into_iter().collect::<Vec<_>>();
        caps.sort_by_key(cap2prio);

        wln!(&mut output, "allow/{}+{}", caps.join(","), elem)?;
    }

    let _ = wln!(
        stderr(),
        "{} generated {} Syd rules.",
        style("pandora:").bold().magenta(),
        style(len.to_string()).bold().yellow(),
    );

    // Print out all ioctl requests.
    if !access.ioctl.is_empty() || !access.ioctl_names.is_empty() {
        wln!(&mut output, "\n###\n# Sandbox ioctl(2) Rules\n###")?;
        wln!(&mut output, "sandbox/ioctl:on\n")?;

        // Print unknown numeric ioctls.
        let mut nums = Vec::with_capacity(access.ioctl.len());
        for &n in access.ioctl.iter() {
            nums.push(n);
        }
        nums.sort_unstable();

        let mut line = String::new();
        for chunk in nums.chunks(5) {
            line.clear();
            line.push_str("allow/ioctl+");
            for (i, n) in chunk.iter().enumerate() {
                if i > 0 {
                    line.push(',');
                }
                w!(&mut line, "{:#x}", n)?;
            }
            wln!(&mut output, "{line}")?;
        }

        // Print named ioctls.
        if !access.ioctl_names.is_empty() {
            // Bucket names by inferred prefix without double-storing keys.
            let mut groups: PandoraMap<String, Vec<String>> = PandoraMap::default();
            for key in access.ioctl_names.iter() {
                let name = key.to_string();
                let gkey = find_shared_prefix(&access.ioctl_names, &name);
                groups.entry(gkey).or_default().push(name);
            }

            // Stable, human-oriented ordering:
            // 1. If one key is a prefix of the other, put the longer (more specific) first.
            // 2. Otherwise, plain lexicographic.
            // 3. If keys equal, bigger group first for stability.
            // 4. Names within each group are sorted as well.
            let mut grouped: Vec<(String, Vec<String>)> = groups.into_iter().collect();
            grouped.sort_by(|(ka, va), (kb, vb)| ka.cmp(kb).then_with(|| vb.len().cmp(&va.len())));
            grouped.sort_by(|(ka, va), (kb, vb)| {
                if ka == kb {
                    return vb.len().cmp(&va.len());
                }
                if kb.starts_with(ka) {
                    // ka is a prefix of kb -> kb (longer) should come first -> ka after kb
                    Ordering::Greater
                } else if ka.starts_with(kb) {
                    // kb is a prefix of ka -> ka (longer) should come first -> ka before kb
                    Ordering::Less
                } else {
                    ka.cmp(kb)
                }
            });

            let mut buf = String::new();
            for (_k, mut v) in grouped {
                v.sort_unstable();
                buf.clear();
                buf.push_str("allow/ioctl+");
                for (i, name) in v.iter().enumerate() {
                    if i > 0 {
                        buf.push(',');
                    }
                    buf.push_str(name);
                }
                wln!(&mut output, "{buf}")?;
            }
        }
    }

    // Print landlock(7) sandboxing rules.
    if !access.lock_file_paths.is_empty()
        || !access.lock_dir_paths.is_empty()
        || access.lock_bind_ports.count_ones(..) > 0
        || access.lock_connect_ports.count_ones(..) > 0
    {
        wln!(&mut output, "\n###\n# landlock(7) Sandboxing\n###")?;
        wln!(&mut output, "sandbox/lock:on\n")?;

        // Merge file and directory landlock(7) paths,
        // consolidating ephemeral entries.
        let lock_paths = merge_lock_paths(
            access.lock_file_paths,
            access.lock_dir_paths,
            if path_limit > 0 { path_limit + 1 } else { 0 },
        );

        let mut lock_list: Vec<_> = lock_paths.into_iter().collect();
        lock_list.sort_by_key(|(path, _)| path.to_string());

        let mut lastcap: Option<PandoraSet<String>> = None;
        for (path, caps) in &lock_list {
            assert!(!caps.is_empty(), "Invalid lock rule!");

            // Group separator when capabilities change.
            if let Some(ref cap) = lastcap {
                if !cap.is_subset(caps) {
                    wln!(&mut output)?;
                    lastcap = Some(caps.clone());
                }
            } else {
                lastcap = Some(caps.clone());
            }

            // Perform landlock(7) alias expansion.
            let mut caps = caps.clone();
            loop {
                let mut changed = false;
                for (alias, members) in LOCK_CAPSETS {
                    if caps.contains(*alias) {
                        continue;
                    }
                    if members.iter().all(|m| caps.contains(*m)) {
                        for m in *members {
                            changed |= caps.remove(*m);
                        }
                        changed |= caps.insert((*alias).to_string());
                    }
                }
                if !changed {
                    break;
                }
            }

            // Convert to vector and sort by landlock(7) cap priority.
            let mut caps = caps.into_iter().collect::<Vec<_>>();
            caps.sort_by_key(|c| cap2prio_lock(c));

            let path = subst_env(path, tty.as_deref());
            wln!(&mut output, "allow/lock/{}+{}", caps.join(","), path)?;
        }

        // Print landlock(7) bind(2) ports with range collapsing.
        let bind_ranges = format_port_ranges(&access.lock_bind_ports);
        if !bind_ranges.is_empty() {
            wln!(&mut output)?;
            for range in &bind_ranges {
                wln!(&mut output, "allow/lock/bind+{}", range)?;
            }
        }

        // Print landlock(7) connect(2) ports with range collapsing.
        let connect_ranges = format_port_ranges(&access.lock_connect_ports);
        if !connect_ranges.is_empty() {
            wln!(&mut output)?;
            for range in &connect_ranges {
                wln!(&mut output, "allow/lock/connect+{}", range)?;
            }
        }

        let total_rules = lock_list.len() + bind_ranges.len() + connect_ranges.len();
        let _ = wln!(
            stderr(),
            "{} generated {} landlock(7) rules.",
            style("pandora:").bold().magenta(),
            style(total_rules.to_string()).bold().yellow(),
        );
    }

    // Print Force entries if available,
    // concurrency-limited parallel checksums + multiple progress bars.
    if !access.force.is_empty() {
        wln!(&mut output, "\n###\n# Executable Verification\n###")?;

        if let Some(func) = hash_function {
            wln!(&mut output, "sandbox/force:on")?;

            let force: Vec<_> = access.force.into_iter().collect();
            let mut force: Vec<PathBuf> = force.iter().map(|s| path2dehex(s.as_str())).collect();
            force.sort_by_cached_key(|arg| (arg.as_os_str().as_bytes().len(), arg.clone()));
            let force_len = force.len();
            let force_max = force
                .iter()
                .map(|arg| arg.as_os_str().as_bytes().len())
                .max()
                .ok_or(Errno::EFAULT)?;

            let pool = ThreadPoolBuilder::new()
                .num_threads(concurrency)
                .build()
                .map_err(|_| Errno::EAGAIN)?;

            let _ = m.println(format!(
                "{} calculating {} checksums for {} executables...",
                style("pandora:").bold().magenta(),
                style(func).bold().cyan(),
                style(force_len.to_string()).bold().yellow(),
            ));

            // Prepare progress bar style.
            let prefix_width = force_max + func.len() + "()".len();
            let fmt = format!(
                "{{prefix:<{prefix_width}}} {{bar:40.bold.cyan/bold.blue}} {{bytes:>7}}/{{total_bytes:7}} {{bytes_per_sec:7}} eta: {{eta}}",
            );
            let sty = ProgressStyle::with_template(&fmt)
                .map_err(|_| Errno::EINVAL)?
                .progress_chars("+~-");

            // Initialize multiple progressbar.
            let mut pbs = Vec::<(PathBuf, ProgressBar)>::with_capacity(force_len);
            for path in &force {
                let len = metadata(path).map(|md| md.len()).map_err(err2no)?;
                let pb = m.add(ProgressBar::new(len));
                pb.set_style(sty.clone());
                pb.set_prefix(format!(
                    "{}({})",
                    style(func).bold().blue(),
                    style(path.display()).bold().yellow()
                ));
                pbs.push((path.clone(), pb));
            }

            // We'll collect final "force+path:algorithm:hash" rules here.
            let rules = Arc::new(Mutex::new(PandoraMap::<PathBuf, String>::default()));

            // Spawn concurrency worker threads to do the hashing
            #[expect(clippy::disallowed_methods)]
            pool.install(|| {
                pbs.par_iter()
                    .for_each(|(path, pb)| match path2force(path, func, pb) {
                        Ok(rule) => {
                            // Extract "algorithm:hex" after the second ':'.
                            let mut split = rule.splitn(2, ':');
                            split.next().unwrap();
                            let data = split.next().unwrap();

                            pb.println(format!(
                                "{}({}) = {}",
                                style(&func).bold().cyan(),
                                style(path.display()).bold().yellow(),
                                style(data).bold().green(),
                            ));
                            pb.finish_and_clear();

                            {
                                let mut rules = rules.lock().unwrap_or_else(|err| err.into_inner());
                                rules.insert(path.clone(), rule);
                            }
                        }
                        Err(error) => {
                            pb.println(format!(
                                "{}({}) = {}",
                                style(&func).bold().red(),
                                style(path.display()).bold().yellow(),
                                style(error).bold().red(),
                            ));
                            pb.finish_and_clear();
                        }
                    });
            });

            drop(pool);
            let rules = rules.lock().unwrap_or_else(|err| err.into_inner());

            #[expect(clippy::disallowed_methods)]
            for path in &force {
                let rule = rules
                    .get(path)
                    .expect("BUG: path not found in force map, report a bug!");
                w!(&mut output, "\n{rule}")?;
            }
            wln!(&mut output)?;

            let _ = wln!(
                stderr(),
                "{} calculated {} checksums for {} executables.",
                style("pandora:").bold().magenta(),
                style(func).bold().cyan(),
                style(force_len.to_string()).bold().yellow(),
            );
        } else {
            wln!(
                &mut output,
                "# AF_ALG not supported, Force Sandboxing disabled."
            )?;
            wln!(&mut output, "sandbox/force:off")?;
            let _ = wln!(
                stderr(),
                "{} {}",
                style("pandora:").bold().magenta(),
                style("AF_ALG not supported by kernel, skipping Force Sandboxing.")
                    .bold()
                    .yellow(),
            );
        }
    }

    let _ = wln!(
        stderr(),
        "{} profile generation completed! \\o/",
        style("pandora:").bold().magenta(),
    );

    let _ = wln!(
        stderr(),
        "{} profile has been written to `{}'.",
        style("pandora:").bold().magenta(),
        style(output_path).bold().yellow(),
    );

    let _ = wln!(
        stderr(),
        "{} To use it, do: {} -P \"{}\" -- {cmd}",
        style("pandora:").bold().magenta(),
        style("syd").bold().green(),
        style(output_path).bold().yellow(),
    );

    // SIGSYS guidance.
    if program_exit_code == 128 + Signal::SIGSYS as i32 as u8 {
        let _ = wln!(
            stderr(),
            "{} {}",
            style("pandora:").bold().magenta(),
            &sigsys_help()
        );
    }

    Ok(ExitCode::from(program_exit_code))
}

// Used to perform path-based hashing in parallel with a progress bar.
// Uses the Kernel Crypto API (AF_ALG) with zero-copy splice.
fn path2force(path: &PathBuf, func: &str, pb: &ProgressBar) -> Result<String, Errno> {
    let ohow = safe_open_how(OFlag::O_RDONLY | OFlag::O_NOCTTY);
    let file = openat2(AT_FDCWD, path, ohow)?;

    let filesz = metadata(path).map(|md| md.len()).unwrap_or(0);
    let digest = hash(func, &file)?;
    pb.inc(filesz);

    let hex = HEXLOWER.encode(&digest);
    Ok(format!("force+{}:{func}:{hex}", mask_path(path)))
}

// Open either stdin(3) (for "-"), syslog(2) (for "dmesg"/"syslog") or a file for reading.
fn open_input(input: &str) -> Box<dyn BufRead> {
    match input {
        "-" => Box::new(BufReader::new(stdin())),
        "dmesg" | "syslog" => Box::new(BufReader::new(match Syslog::open() {
            Ok(syslog) => syslog,
            Err(err) => {
                wln!(
                    stderr(),
                    "{} failed to access syslog: {}!",
                    style("pandora:").bold().magenta(),
                    style(err.to_string()).bold().red(),
                )
                .unwrap();
                exit(1);
            }
        })),
        path => Box::new(BufReader::new(
            #[expect(clippy::disallowed_methods)]
            match OpenOptions::new().read(true).open(path) {
                Ok(file) => file,
                Err(err) => {
                    wln!(
                        stderr(),
                        "{} failed to open file {}: {}!",
                        style("pandora:").bold().magenta(),
                        style(path).bold().yellow(),
                        style(err.to_string()).bold().red(),
                    )
                    .unwrap();
                    exit(1);
                }
            },
        )),
    }
}

// Open either stdout(3) (if "-") or a file for writing (in create_new mode).
fn open_output(output: &str) -> Box<dyn Write> {
    match output {
        "-" => Box::new(std::io::BufWriter::new(std::io::stdout())),
        path => Box::new(std::io::BufWriter::new(
            #[expect(clippy::disallowed_methods)]
            match OpenOptions::new().write(true).create_new(true).open(path) {
                Ok(file) => file,
                Err(err) => {
                    wln!(
                        stderr(),
                        "{} failed to open file {}: {}!",
                        style("pandora:").bold().magenta(),
                        style(path).bold().cyan(),
                        style(err.to_string()).bold().red(),
                    )
                    .unwrap();
                    exit(1);
                }
            },
        )),
    }
}

// Replace UID/GID path components with ${SYD_UID}/${SYD_GID}.
// Must run before process_path to prevent is_tmp_comp from globbing digits.
fn subst_id(path: &str) -> String {
    let uid = Uid::current().to_string();
    let gid = Gid::current().to_string();
    let uid_mid = format!("/{uid}/");
    let gid_mid = format!("/{gid}/");
    let uid_tail = format!("/{uid}");
    let gid_tail = format!("/{gid}");

    let needs_uid = uid != "0" && (path.contains(&uid_mid) || path.ends_with(&uid_tail));
    let needs_gid =
        gid != "0" && gid != uid && (path.contains(&gid_mid) || path.ends_with(&gid_tail));

    if !needs_uid && !needs_gid {
        return path.to_string();
    }

    let mut result = path.to_string();
    if needs_uid {
        result = result.replace(&uid_mid, "/${SYD_UID}/");
        if result.ends_with(&uid_tail) {
            result.truncate(result.len() - uid_tail.len());
            result.push_str("/${SYD_UID}");
        }
    }
    if needs_gid {
        result = result.replace(&gid_mid, "/${SYD_GID}/");
        if result.ends_with(&gid_tail) {
            result.truncate(result.len() - gid_tail.len());
            result.push_str("/${SYD_GID}");
        }
    }
    result
}

// Replace path prefixes and components with Syd environment variables.
// Handles: ${SYD_TTY}, ${SYD_UID}, ${SYD_GID}, ${SYD_HOME}, ${SYD_PWD}.
fn subst_env(path: &str, tty: Option<&str>) -> String {
    // Replace TTY path with ${SYD_TTY}.
    if let Some(tty) = tty {
        if path == tty {
            return "${SYD_TTY}".to_string();
        }
    }

    // Replace UID/GID path components.
    let path = subst_id(path);

    // Determine the user's home directory:
    // 1. Honour HOME environment variable.
    // 2. Fall back to passwd(5) lookup.
    let home = env::var("HOME").unwrap_or_else(|_| {
        let uid = Uid::current();
        match User::from_uid(uid) {
            Ok(Some(user)) => user.dir.to_string_lossy().into_owned(),
            _ => String::new(),
        }
    });

    // Replace home directory prefix with ${SYD_HOME}.
    if !home.is_empty() && home != "/" {
        if path == home {
            return "${SYD_HOME}".to_string();
        }
        if let Some(rest) = path.strip_prefix(&*home) {
            return format!("${{SYD_HOME}}{rest}");
        }
    }

    // Replace CWD prefix with ${SYD_PWD}.
    if let Some(cwd) = getcwd()
        .ok()
        .and_then(|cwd| std::fs::canonicalize(cwd).ok())
        .map(|cwd| cwd.to_string_lossy().to_string())
        .filter(|cwd| !cwd.is_empty() && *cwd != *"/")
    {
        if path == *cwd {
            return "${SYD_PWD}".to_string();
        }
        if let Some(rest) = path.strip_prefix(&*cwd) {
            return format!("${{SYD_PWD}}{rest}");
        }
    }

    path
}

// Truncate an absolute path to at most `limit` concrete components.
fn limit_path(path: &str, limit: u8) -> Cow<'_, str> {
    if limit == 0 || !path.starts_with('/') {
        return Cow::Borrowed(path);
    }
    let parts: Vec<&str> = path
        .split('/')
        .filter(|&x| !x.is_empty() && x != "***")
        .collect();
    let concrete = parts.iter().filter(|x| !x.contains('*')).count();
    if (limit as usize) < concrete {
        let mut kept = Vec::new();
        let mut count = 0usize;
        for &p in &parts {
            if p.contains('*') {
                kept.push(p);
            } else {
                count += 1;
                if count <= limit as usize {
                    kept.push(p);
                }
            }
        }
        format!("/{}/***", kept.join("/")).into()
    } else {
        // Abstract and unnamed UNIX sockets
        Cow::Borrowed(path)
    }
}

// Apply glob transformations then enforce the path limit.
fn process_path<'a>(path: &'a str, limit: u8) -> Cow<'a, str> {
    if path == "/" {
        return Cow::Borrowed(path);
    }
    let base = path2glob(path).map(|g| g.into_owned());
    match base {
        Some(g) => limit_path(&g, limit).into_owned().into(),
        None if path.starts_with('/') => limit_path(path, limit),
        None => Cow::Borrowed(path),
    }
}

// Process a path for landlock(7) rules.
//
// Return the parent directory of an absolute path.
// For `/usr/lib/libc.so.6`, returns `/usr/lib`.
// For `/tmp`, returns `/`.
fn parent_path(path: &str) -> String {
    match path.rfind('/') {
        Some(i) if i > 0 => path[..i].to_string(),
        _ => "/".to_string(),
    }
}

// Thresholds for ephemeral path detection.
const TMP_SUFFIX_MIN_LEN: usize = 3;
const TMP_SUFFIX_MIN_CLASSES: u8 = 2;
const TMP_SUFFIX_MIN_ENTROPY: f64 = 1.5;
const TMP_DIGIT_MIN_LEN: usize = 5;
const TMP_FULL_MIN_LEN: usize = 6;
const TMP_FULL_MIN_CLASSES: u8 = 3;

// Shannon entropy H(X) = -sum(p_i * log2(p_i)) in bits per character.
fn entropy(s: &str) -> f64 {
    let len = s.len() as f64;
    if len == 0.0 {
        return 0.0;
    }
    let mut freq = [0u32; 256];
    for &b in s.as_bytes() {
        freq[b as usize] += 1;
    }
    freq.iter()
        .filter(|&&c| c > 0)
        .map(|&c| {
            let p = c as f64 / len;
            -p * p.log2()
        })
        .sum()
}

// Count distinct character classes: {lowercase, uppercase, digit}.
fn char_classes(s: &str) -> u8 {
    let lo = s.bytes().any(|b| b.is_ascii_lowercase()) as u8;
    let up = s.bytes().any(|b| b.is_ascii_uppercase()) as u8;
    let di = s.bytes().any(|b| b.is_ascii_digit()) as u8;
    lo + up + di
}

// Classify a path component as ephemeral via entropy and character class analysis.
fn is_tmp_comp(name: &str) -> bool {
    if !name.is_empty() && name.bytes().all(|b| b.is_ascii_digit()) {
        return true;
    }
    if let Some(pos) = name.rfind(['.', '-', '_']) {
        let suffix = &name[pos + 1..];
        if suffix.len() >= TMP_SUFFIX_MIN_LEN {
            if char_classes(suffix) >= TMP_SUFFIX_MIN_CLASSES
                && entropy(suffix) >= TMP_SUFFIX_MIN_ENTROPY
            {
                return true;
            }
            if suffix.len() >= TMP_FULL_MIN_LEN
                && entropy(suffix) >= TMP_SUFFIX_MIN_ENTROPY
                && char_classes(name) >= TMP_SUFFIX_MIN_CLASSES
            {
                return true;
            }
            if suffix.len() >= TMP_DIGIT_MIN_LEN && suffix.bytes().all(|b| b.is_ascii_digit()) {
                return true;
            }
        }
    }
    if name.len() >= TMP_FULL_MIN_LEN && char_classes(name) >= TMP_FULL_MIN_CLASSES {
        return true;
    }
    false
}

// Normalize /proc/<pid>/... paths to /proc for lock rules.
fn normpath_proc(path: &str) -> Option<String> {
    if !path.starts_with("/proc/") {
        return None;
    }
    let components: Vec<&str> = path.split('/').collect();
    if components.len() >= 3 && components[2].bytes().all(|b| b.is_ascii_digit()) {
        return Some("/proc".to_string());
    }
    None
}

// Promote path to nearest stable ancestor upon encountering an ephemeral component.
fn resolve_tmp_comp(path: &str) -> Option<String> {
    if let Some(s) = normpath_proc(path) {
        return Some(s);
    }
    // Walk components from right to left via byte scanning.
    let mut end = path.len();
    while let Some(i) = path[..end].rfind('/') {
        let name = &path[i + 1..end];
        if !name.is_empty() && is_tmp_comp(name) {
            return Some(if i > 0 {
                path[..i].to_string()
            } else {
                "/".to_string()
            });
        }
        if i == 0 {
            break;
        }
        end = i;
    }
    None
}

// Strip the last path component, returning the parent directory.
// Returns None for root or single-component paths.
fn parent_dir(path: &str) -> Option<&str> {
    match path.rfind('/') {
        Some(i) if i > 0 => Some(&path[..i]),
        _ => None,
    }
}

// Whether a dir_paths entry has caps that imply child creation/deletion.
fn has_mutation_caps(caps: &PandoraSet<String>) -> bool {
    caps.contains("mkdir") || caps.contains("create") || caps.contains("delete")
}

// Promote path to highest ancestor whose parent holds mutation caps.
// Walks up the parent chain, skipping ancestors not in dir_paths.
fn promote_to_parent(
    path: &str,
    dir_paths: &PandoraMap<String, PandoraSet<String>>,
) -> Option<String> {
    let mut best: Option<String> = None;
    let mut current = path;
    while let Some(parent) = parent_dir(current) {
        if let Some(parent_caps) = dir_paths.get(parent) {
            if has_mutation_caps(parent_caps) {
                best = Some(parent.to_string());
            }
        }
        current = parent;
    }
    best
}

// Find the nearest ancestor wildcard (path/***) entry in a map.
// Used by dedup_paths to locate covering wildcards.
fn find_ancestor_wildcard(
    path: &str,
    map: &PandoraMap<String, PandoraSet<String>>,
) -> Option<String> {
    let base = path.strip_suffix("/***").unwrap_or(path);
    let mut current = base;
    while let Some(ancestor) = parent_dir(current) {
        let wildcard = format!("{ancestor}/***");
        if map.contains_key(&wildcard) {
            return Some(wildcard);
        }
        current = ancestor;
    }
    None
}

// Ephemeral basename: glob markers or entropy+class analysis.
fn is_ephemeral_basename(name: &str) -> bool {
    name.contains('*') || name.contains("[0-9]") || is_tmp_comp(name)
}

// Minimum number of mutated children to trigger parent/*** consolidation.
const CONSOLIDATE_MUT_THRESHOLD: usize = 10;

// Collapse ephemeral siblings and mutation-heavy groups under parent/***.
fn consolidate_sibling_paths(
    paths: PandoraMap<String, PandoraSet<String>>,
) -> PandoraMap<String, PandoraSet<String>> {
    let mut buckets: PandoraMap<String, PandoraPathList> = PandoraMap::default();
    let mut non_path: PandoraPathList = Vec::new();

    for (key, caps) in paths {
        if !key.starts_with('/') {
            non_path.push((key, caps));
            continue;
        }
        let stripped = key.strip_suffix("/***").unwrap_or(&key);
        if let Some(i) = stripped.rfind('/') {
            if i > 0 {
                buckets
                    .entry(stripped[..i].to_string())
                    .or_default()
                    .push((key, caps));
            } else {
                non_path.push((key, caps));
            }
        } else {
            non_path.push((key, caps));
        }
    }

    let mut out: PandoraMap<String, PandoraSet<String>> = PandoraMap::default();
    for (key, caps) in non_path {
        out.entry(key).or_default().extend(caps);
    }

    for (parent, children) in buckets {
        if children.len() < 3 {
            for (key, caps) in children {
                out.entry(key).or_default().extend(caps);
            }
            continue;
        }

        // Many mutated files under one parent -- generated content.
        let mut_count = children
            .iter()
            .filter(|(_, caps)| caps.contains("create") || caps.contains("delete"))
            .count();
        if mut_count >= CONSOLIDATE_MUT_THRESHOLD {
            let collapsed = format!("{parent}/***");
            let entry = out.entry(collapsed).or_default();
            for (_key, caps) in children {
                entry.extend(caps);
            }
            continue;
        }

        // Per-child ephemeral classification.
        let mut ephemeral: PandoraPathList = Vec::new();
        let mut stable: PandoraPathList = Vec::new();
        for (key, caps) in children {
            let stripped = key.strip_suffix("/***").unwrap_or(&key);
            let basename = Path::new(stripped)
                .file_name()
                .and_then(|n| n.to_str())
                .unwrap_or("");
            if is_ephemeral_basename(basename) {
                ephemeral.push((key, caps));
            } else {
                stable.push((key, caps));
            }
        }

        for (key, caps) in stable {
            out.entry(key).or_default().extend(caps);
        }
        if ephemeral.len() >= 3 {
            let collapsed = format!("{parent}/***");
            let entry = out.entry(collapsed).or_default();
            for (_key, caps) in ephemeral {
                entry.extend(caps);
            }
        } else {
            for (key, caps) in ephemeral {
                out.entry(key).or_default().extend(caps);
            }
        }
    }

    out
}

// Merge entries into their nearest ancestor wildcard rule and drop
// fully covered duplicates.
fn dedup_paths(
    paths: PandoraMap<String, PandoraSet<String>>,
) -> PandoraMap<String, PandoraSet<String>> {
    let mut map = paths;

    // Pass 1: fold filesystem entries into their ancestor wildcard.
    // Network entries (sockets) keep individual rules.
    let mergeable: Vec<(String, String)> = map
        .iter()
        .filter(|(_, caps)| !caps.iter().any(|c| c.starts_with("net/")))
        .filter_map(|(path, _)| {
            let anc = find_ancestor_wildcard(path, &map)?;
            if *path != anc {
                Some((path.clone(), anc))
            } else {
                None
            }
        })
        .collect();
    for (child, ancestor) in mergeable {
        if let Some(caps) = map.remove(&child) {
            map.entry(ancestor).or_default().extend(caps);
        }
    }

    // Pass 2: drop entries fully covered by an ancestor wildcard.
    let covered: Vec<String> = map
        .iter()
        .filter(|(path, caps)| {
            find_ancestor_wildcard(path, &map)
                .and_then(|anc| map.get(&anc).map(|ac| caps.is_subset(ac)))
                .unwrap_or(false)
        })
        .map(|(path, _)| path.clone())
        .collect();
    for key in covered {
        map.remove(&key);
    }
    map
}

// Truncate a directory path to at most `limit` concrete components.
fn limit_dir_path(path: &str, limit: u8) -> Cow<'_, str> {
    if limit == 0 || !path.starts_with('/') {
        return Cow::Borrowed(path);
    }
    let mut count = 0u8;
    for (i, &b) in path.as_bytes().iter().enumerate() {
        if b == b'/' && i > 0 {
            count += 1;
            if count >= limit {
                return Cow::Owned(path[..i].to_string());
            }
        }
    }
    Cow::Borrowed(path)
}

// Merge and consolidate lock paths: normalize, limit, consolidate, deduplicate.
fn merge_lock_paths(
    file_paths: PandoraMap<String, PandoraSet<String>>,
    dir_paths: PandoraMap<String, PandoraSet<String>>,
    limit: u8,
) -> PandoraMap<String, PandoraSet<String>> {
    let mut out: PandoraMap<String, PandoraSet<String>> = PandoraMap::default();

    // Process file paths through a four-stage pipeline:
    //   1. Strip unresolvable files to their parent dir
    //   2. Resolve ephemeral components to stable ancestor
    //   3. Promote to highest ancestor with mutation caps (skip exec)
    //   4. Apply depth limit, re-promote if truncated
    for (path, caps) in file_paths {
        // Stage 1: files pending creation and /proc pseudo-paths
        // (pipe:[N], socket:[N]) don't exist -- use parent dir.
        let is_virtual =
            caps.contains("create") || (path.starts_with("/proc/") && path.contains(":["));
        let path = if is_virtual {
            parent_dir(&path).map(str::to_string).unwrap_or(path)
        } else {
            path
        };

        // Stage 2: collapse ephemeral path components.
        let key = resolve_tmp_comp(&path).unwrap_or(path);

        // Stage 3: promote to ancestor with mutation caps.
        // Exec files keep their specific path for integrity checking.
        let key = if caps.contains("exec") {
            key
        } else {
            promote_to_parent(&key, &dir_paths).unwrap_or(key)
        };

        // Stage 4: enforce depth limit, re-promote if truncation
        // landed on a non-existent intermediate directory.
        let limited = limit_dir_path(&key, limit);
        let key = if limited.as_ref() != key {
            promote_to_parent(&limited, &dir_paths).unwrap_or_else(|| limited.into_owned())
        } else {
            limited.into_owned()
        };

        out.entry(key).or_default().extend(caps);
    }

    // Process directory paths: same pipeline without exec guard.
    for (path, caps) in &dir_paths {
        let key = resolve_tmp_comp(path).unwrap_or_else(|| path.clone());
        let key = promote_to_parent(&key, &dir_paths).unwrap_or(key);
        let key = limit_dir_path(&key, limit).into_owned();
        out.entry(key).or_default().extend(caps.iter().cloned());
    }

    dedup_paths(consolidate_sibling_paths(out))
}

// Format a FixedBitSet of ports as a list of ranges.
//
// Consecutive ports are collapsed, e.g: [80, 81, 82, 443] becomes ["80-82", "443"].
fn format_port_ranges(ports: &fixedbitset::FixedBitSet) -> Vec<String> {
    let mut ranges = Vec::new();
    let mut start: Option<usize> = None;
    let mut end: usize = 0;

    for port in ports.ones() {
        match start {
            None => {
                start = Some(port);
                end = port;
            }
            Some(_) if port == end + 1 => {
                end = port;
            }
            Some(s) => {
                if s == end {
                    ranges.push(format!("{s}"));
                } else {
                    ranges.push(format!("{s}-{end}"));
                }
                start = Some(port);
                end = port;
            }
        }
    }

    if let Some(s) = start {
        if s == end {
            ranges.push(format!("{s}"));
        } else {
            ranges.push(format!("{s}-{end}"));
        }
    }

    ranges
}

// Possibly decode a hex path. If hex decode fails, return it as-is.
fn path2dehex(path: &str) -> PathBuf {
    if let Ok(path_decoded) = HEXLOWER_PERMISSIVE.decode(path.as_bytes()) {
        OsString::from_vec(path_decoded).into()
    } else {
        path.into()
    }
}

// Join path components from index `from` with '/' prefix, or empty string.
fn path_join(components: &[&str], from: usize) -> String {
    if components.len() > from {
        format!("/{}", components[from..].join("/"))
    } else {
        String::new()
    }
}

// Replace trailing numeric fd/ns components with [0-9]* globs
// and handle foo:[N] pseudo-path patterns.
fn mkglob_proc_fdns(path: &str) -> String {
    let components: Vec<&str> = path.split('/').collect();
    let mut out = String::new();
    for (idx, component) in components.iter().enumerate() {
        if idx > 0 {
            out.push('/');
        }
        if idx == components.len() - 1 && component.chars().all(char::is_numeric) {
            out.push_str("[0-9]*");
        } else if component.contains(':') {
            let parts: Vec<&str> = component.split(':').collect();
            if parts.len() == 2 && parts[1].starts_with('[') && parts[1].ends_with(']') {
                let inner = &parts[1][1..parts[1].len() - 1];
                if inner.chars().all(char::is_numeric) {
                    // pipe:[5303077] -> pipe:\[[0-9]*\]
                    out.push_str(&format!("{}:\\[[0-9]*\\]", parts[0]));
                } else {
                    // anon_inode:[eventfd] -> anon_inode:*
                    out.push_str(&format!("{}:*", parts[0]));
                }
                continue;
            }
            out.push_str(component);
        } else {
            out.push_str(component);
        }
    }
    out
}

// Glob /proc/$pid/... paths, replacing PIDs and TIDs with [0-9]*.
fn mkglob_proc_pid(components: &[&str]) -> Option<String> {
    if components.len() < 3 || !components[2].chars().all(char::is_numeric) {
        return None;
    }

    // /proc/$pid/task/$tid/... case
    if components.len() > 4
        && components[3] == "task"
        && components[4].chars().all(char::is_numeric)
    {
        let rest = path_join(components, 5);
        let mut path = format!("/proc/[0-9]*/task/[0-9]*{rest}");

        // Refine fd/ns subdirectory handling.
        if components.len() > 5 && (components[5] == "fd" || components[5] == "ns") {
            let subrest = path_join(components, 6);
            path = format!("/proc/[0-9]*/task/[0-9]*/{}{subrest}", components[5]);
        }
        return Some(path);
    }

    // General /proc/$pid/... case
    let rest = path_join(components, 3);
    let mut path = format!("/proc/[0-9]*{rest}");

    // Refine fd/ns subdirectory handling.
    if components.len() > 3 && (components[3] == "fd" || components[3] == "ns") {
        let subrest = path_join(components, 4);
        path = format!("/proc/[0-9]*/{}{subrest}", components[3]);
    }
    Some(path)
}

// Glob /proc/self/... and /proc/thread-self/... paths.
fn mkglob_proc_self(components: &[&str]) -> Option<String> {
    if components.len() < 3 || (components[2] != "self" && components[2] != "thread-self") {
        return None;
    }

    let prefix = format!("/proc/{}", components[2]);
    let rest: Vec<&str> = components[3..]
        .iter()
        .copied()
        .filter(|segment| !segment.is_empty())
        .collect();

    if rest.is_empty() {
        return Some(prefix);
    }

    // /proc/self/task/<tid>/...
    if rest[0] == "task" && rest.len() >= 2 && rest[1].bytes().all(|b| b.is_ascii_digit()) {
        let tid_rest = if rest.len() > 2 {
            let sub: Vec<&str> = rest[2..]
                .iter()
                .scan(None::<&str>, |prev, &segment| {
                    let result = if matches!(*prev, Some("fd") | Some("fdinfo"))
                        && segment.bytes().all(|b| b.is_ascii_digit())
                    {
                        "[0-9]*"
                    } else {
                        segment
                    };
                    *prev = Some(segment);
                    Some(result)
                })
                .collect();
            format!("/{}", sub.join("/"))
        } else {
            String::new()
        };
        return Some(format!("{prefix}/task/[0-9]*{tid_rest}"));
    }

    // /proc/self/fd/<num> or /proc/self/fdinfo/<num>
    if (rest[0] == "fd" || rest[0] == "fdinfo")
        && rest.len() >= 2
        && rest[1].bytes().all(|b| b.is_ascii_digit())
    {
        return Some(format!("{prefix}/{}/[0-9]*", rest[0]));
    }

    // Stable procfs entries: ns/, net/, attr/, maps, status, etc.
    Some(format!("{prefix}/{}", rest.join("/")))
}

// Glob /dev/pts/N and /dev/tty* paths.
fn mkglob_dev_tty(path: &str) -> Option<String> {
    if let Some(rest) = path.strip_prefix("/dev/pts/") {
        return if rest.starts_with(|c: char| c.is_numeric()) {
            Some("/dev/pts/[0-9]*".into())
        } else {
            None
        };
    }
    if path == "/dev/tty" {
        return Some("/dev/tty".into());
    }
    if path.starts_with("/dev/tty") {
        return Some("/dev/tty*".into());
    }
    None
}

// Glob abstract sockets: @cuda-uvmfd--1-63797 -> @cuda-uvmfd--*
// and @gecko-crash-helper-pipe.462275 -> @gecko-crash-helper-pipe.*
fn mkglob_sock_abs(path: &str) -> Option<String> {
    // CUDA pattern: @..--<digits-and-punctuation>
    if path.starts_with('@') {
        if let Some(dashdash) = path.rfind("--") {
            let after = &path[dashdash + 2..];
            if !after.is_empty()
                && after
                    .chars()
                    .all(|c| c.is_ascii_digit() || c.is_ascii_punctuation())
            {
                return Some(format!("{}--*", &path[..dashdash]));
            }
        }
    }
    // Gecko pattern: @gecko-...<.pid>
    if let Some(at_pos) = path.find("@gecko-") {
        if let Some(dot_pos) = path[at_pos..].rfind('.') {
            let dot_abs = at_pos + dot_pos;
            let suffix = &path[dot_abs + 1..];
            if !suffix.is_empty() && suffix.chars().all(|c| c.is_ascii_alphanumeric()) {
                return Some(format!("{}*", &path[..dot_abs + 1]));
            }
        }
    }
    None
}

// Glob generic ephemeral filesystem paths by replacing ephemeral
// components with smart prefix-based globs.
fn mkglob_tmp(path: &str) -> Option<String> {
    if !path.starts_with('/') {
        return None;
    }
    let parsed = Path::new(path);
    let components: Vec<&str> = parsed
        .components()
        .filter_map(|component| {
            let segment = component.as_os_str().to_str().unwrap_or("");
            if segment == "/" || segment.is_empty() {
                None
            } else {
                Some(segment)
            }
        })
        .collect();

    let mut changed = false;
    let mut out = String::new();
    for segment in &components {
        out.push('/');
        if is_tmp_comp(segment) {
            changed = true;
            if segment.bytes().all(|b| b.is_ascii_digit()) {
                out.push_str("[0-9]*");
            } else if let Some(sep) = segment.rfind(['.', '-', '_']) {
                out.push_str(&segment[..sep + 1]);
                out.push('*');
            } else {
                out.push('*');
            }
        } else {
            out.push_str(segment);
        }
    }

    if changed {
        out.push_str("/***");
        Some(out)
    } else {
        None
    }
}

// If the path is known to map to a standard glob, return it. Otherwise return None.
fn path2glob<'a>(path: &'a str) -> Option<Cow<'a, str>> {
    if !matches!(path.chars().next(), Some('/') | Some('@') | Some('!')) {
        // SAFETY: hex-encoded untrusted path, return as is.
        return Some(Cow::Borrowed(path));
    }

    let decoded = path2dehex(path);
    let path_str = decoded.to_string_lossy();

    // /proc paths: replace PIDs, TIDs, fd numbers with globs.
    if path_str.starts_with("/proc/") {
        let components: Vec<&str> = path_str.split('/').collect();
        if let Some(globbed) =
            mkglob_proc_pid(&components).or_else(|| mkglob_proc_self(&components))
        {
            // Post-process fd/ns numeric components.
            let result = if globbed.contains("/fd/") || globbed.contains("/ns/") {
                mkglob_proc_fdns(&globbed)
            } else {
                globbed
            };
            return Some(Cow::Owned(result));
        }
    }

    // Memory file descriptors.
    if path_str.starts_with("!memfd:") {
        return Some(Cow::Owned("!memfd:**".into()));
    }
    if path_str.starts_with("!memfd-hugetlb:") {
        return Some(Cow::Owned("!memfd-hugetlb:**".into()));
    }

    // /dev paths.
    if path_str.starts_with("/dev/") {
        if let Some(result) = mkglob_dev_tty(&path_str) {
            return Some(Cow::Owned(result));
        }
    }

    // Abstract sockets and Gecko pipes.
    if let Some(result) = mkglob_sock_abs(&path_str) {
        return Some(Cow::Owned(result));
    }

    // Generic ephemeral path components.
    if let Some(result) = mkglob_tmp(&path_str) {
        return Some(Cow::Owned(result));
    }

    // No glob pattern matched.
    None
}

// Logs an untrusted Path, escaping it as hex if it contains control
// characters.
fn mask_path(path: &Path) -> String {
    let (mask, _) = log_untrusted_buf(path.as_os_str().as_bytes());
    mask
}

// Logs an untrusted buffer, escaping it as hex if it contains control characters.
// Returns a boolean in addition to the String which is true if String is hex-encoded.
fn log_untrusted_buf(buf: &[u8]) -> (String, bool) {
    if contains_ascii_unprintable(buf) {
        (HEXLOWER.encode(buf), true)
    } else if let Ok(s) = std::str::from_utf8(buf) {
        (s.to_string(), false)
    } else {
        (HEXLOWER.encode(buf), true)
    }
}

// Checks if the buffer contains ASCII unprintable characters.
fn contains_ascii_unprintable(buf: &[u8]) -> bool {
    buf.iter().any(|byte| !is_ascii_printable(*byte))
}

// Checks if the given character is ASCII printable.
fn is_ascii_printable(byte: u8) -> bool {
    (0x20..=0x7e).contains(&byte)
}

// Converts capability to a priority number for sorting.
#[expect(clippy::ptr_arg)]
fn cap2prio(cap: &String) -> usize {
    let cap = cap.as_str().as_bytes();

    match CAP_ORDER.iter().position(|&s| is_equal(cap, s.as_bytes())) {
        Some(idx) => idx + 1,
        None => 0, // unknown/new capability => sort first
    }
}

#[expect(clippy::disallowed_methods)]
fn print_help_main() {
    let nproc = env::var("PANDORA_NPROC").unwrap();
    w!(
        stderr(),
        r#"{PKG_NAME} {PKG_VERSION}
{PKG_DESCRIPTION}
Copyright (c) 2023, 2024, 2025 {PKG_AUTHORS}
SPDX-License-Identifier: {PKG_LICENSE}

Usage: {PKG_NAME} [COMMAND] [OPTIONS...]

Commands:
  profile  Execute a program under inspection and write a Syd profile
  inspect  Read Syd logs from input and write a Syd profile

Options:
  -h  Print help
  -V  Print version

Environment Variables:
  SYD_BIN         Path to Syd binary [default: syd]
  PANDORA_IN      Path to Syd access violation logs, use "-" for standard input, "syslog" for syslog(2) [default: -]
  PANDORA_OUT     Path to Syd profile output, use "-" for standard output [default: ./pandora_out.syd-3]
  PANDORA_LIMIT   Maximum number of path members before trim, 0 to disable [default: 3]
  PANDORA_TIMEOUT Timeout in seconds
  PANDORA_HASH    Hash algorithm:
                  Any kernel hash algorithm from proc_crypto(5), auto-detected if unset
  PANDORA_NPROC   Number of concurrency threads used for parallel hashing [default: {nproc}]

Hey you, out there beyond the wall,
Breaking bottles in the hall,
Can you help me?

Send bug reports to {PKG_AUTHORS}.
Attaching poems encourages consideration tremendously.

Homepage: https://sydbox.exherbo.org
Repository: https://gitlab.exherbo.org/sydbox/
"#,
    ).unwrap();
}

#[expect(clippy::disallowed_methods)]
fn print_help_profile() {
    let nproc = env::var("PANDORA_NPROC").unwrap();
    w!(
        stderr(),
        r#"{PKG_NAME} {PKG_VERSION}
Profile subcommand

Usage: {PKG_NAME} profile [OPTIONS] <cmd>...

Options:
  -h           Print help
  -V           Print version
  -x <bin>     Path to Syd binary [default: syd, env:SYD_BIN]
  -s <option>  Pass an option to Syd during init, may be repeated
  -o <output>  Path to Syd profile output, use "-" for standard output [default: ./pandora_out.syd-3, env:PANDORA_OUT]
  -l <limit>   Maximum number of path members before trim, 0 to disable [default: 3, env:PANDORA_LIMIT]
  -t <secs>    Timeout in seconds [env:PANDORA_TIMEOUT]
  -H <hash>    Hash algorithm: [env:PANDORA_HASH]
               Any kernel hash algorithm from proc_crypto(5), auto-detected if unset
  -T <threads> Number of concurrency threads used for parallel hashing [default: {nproc}, env:PANDORA_NPROC]
"#,
    ).unwrap();
}

#[expect(clippy::disallowed_methods)]
fn print_help_inspect() {
    let nproc = env::var("PANDORA_NPROC").unwrap();
    w!(
        stderr(),
        r#"{PKG_NAME} {PKG_VERSION}
Inspect subcommand

Usage: {PKG_NAME} inspect [OPTIONS]

Options:
  -h           Print help
  -V           Print version
  -i <input>   Path to Syd access violation logs, use "-" for standard input, "syslog" for syslog(2) [default: -]
  -o <output>  Path to Syd profile output, use "-" for standard output [default: ./pandora_out.syd-3, env:PANDORA_OUT]
  -l <limit>   Maximum number of path members before trim, 0 to disable [default: 3, env:PANDORA_LIMIT]
  -H <hash>    Hash algorithm: [env:PANDORA_HASH]
               Any kernel hash algorithm from proc_crypto(5), auto-detected if unset
  -T <threads> Number of concurrency threads used for parallel hashing [default: {nproc}, env:PANDORA_NPROC]
"#,
    ).unwrap();
}

fn print_version() {
    wln!(stderr(), "{PKG_NAME}-{PKG_VERSION}").unwrap();
}

// Returns the longest prefix of `s` shared by >=2 keys in `set`, else `s`.
#[inline]
fn find_shared_prefix(set: &StringPatriciaSet, s: &str) -> String {
    if s.is_empty() {
        return String::new();
    }

    let mut last = 0usize;

    // Walk all UTF-8 char boundaries plus the end-of-string (s.len()).
    // This lets a shorter key (e.g., "...CREATE") become the chosen prefix
    // when a longer one ("...CREATE_EXT") also exists.
    let mut boundaries = s
        .char_indices()
        .map(|(i, _)| i)
        .chain(std::iter::once(s.len()));

    // Skip the empty prefix at 0.
    boundaries.next();

    for i in boundaries {
        let mut it = set.iter_prefix(&s[..i]);
        let two_or_more = it.next().is_some() && it.next().is_some();
        if two_or_more {
            last = i;
        } else {
            break;
        }
    }

    if last > 0 {
        s[..last].to_owned()
    } else {
        s.to_owned()
    }
}

fn parse_env_str(var: &str, default: &[u8]) -> Result<String, Errno> {
    // If var is set, parse as valid UTF-8. If not set, fallback to default.
    if let Some(osv) = env::var_os(var) {
        if osv.is_empty() {
            return Err(Errno::EINVAL);
        }
        let bytes = osv.as_bytes();
        parse_utf8_str(bytes)
    } else {
        parse_utf8_str(default)
    }
}

fn parse_env_u8(var: &str, default_val: u8) -> Result<u8, Errno> {
    // If var is set, parse it as an integer, else default_val
    if let Some(osv) = env::var_os(var) {
        if osv.is_empty() {
            return Err(Errno::EINVAL);
        }
        parse_u8(osv.as_bytes())
    } else {
        Ok(default_val)
    }
}

fn parse_env_usize(var: &str, default_val: usize) -> Result<usize, Errno> {
    if let Some(osv) = env::var_os(var) {
        if osv.is_empty() {
            return Err(Errno::EINVAL);
        }
        parse_usize(osv.as_bytes())
    } else {
        Ok(default_val)
    }
}

fn parse_os_u64(osv: &std::ffi::OsString) -> Result<u64, Errno> {
    if osv.is_empty() {
        return Err(Errno::EINVAL);
    }
    parse_u64(osv.as_bytes())
}

fn parse_utf8_str(bytes: &[u8]) -> Result<String, Errno> {
    match std::str::from_utf8(bytes) {
        Ok(s) => Ok(s.to_owned()),
        Err(_) => {
            wln!(stderr(), "ERROR: invalid UTF-8 data")?;
            Err(Errno::EINVAL)
        }
    }
}

fn parse_u8(bytes: &[u8]) -> Result<u8, Errno> {
    let n = btoi::<i64>(bytes).map_err(|_| Errno::EINVAL)?;
    if n < 0 || n > u8::MAX as i64 {
        return Err(Errno::EINVAL);
    }
    Ok(n as u8)
}

fn parse_usize(bytes: &[u8]) -> Result<usize, Errno> {
    let n = btoi::<i64>(bytes).map_err(|_| Errno::EINVAL)?;
    if n < 0 {
        return Err(Errno::EINVAL);
    }
    Ok(n as usize)
}

fn parse_u64(bytes: &[u8]) -> Result<u64, Errno> {
    let n = btoi::<i64>(bytes).map_err(|_| Errno::EINVAL)?;
    if n < 0 {
        return Err(Errno::EINVAL);
    }
    Ok(n as u64)
}

// Convert a std::io::Error into a nix::Errno.
fn err2no(err: std::io::Error) -> Errno {
    err.raw_os_error()
        .map(Errno::from_raw)
        .unwrap_or(Errno::ENOSYS)
}

// Return a safe OpenHow structure.
fn safe_open_how(flags: OFlag) -> OpenHow {
    // Note we leave the caller to handle O_NOCTTY,
    // because its use is invalid with O_PATH.
    let mode = if flags.contains(OFlag::O_CREAT) || flags.contains(OFlag::O_TMPFILE) {
        Mode::from_bits_truncate(0o600)
    } else {
        Mode::empty()
    };
    OpenHow::new()
        .flags(flags | OFlag::O_CLOEXEC | OFlag::O_NOFOLLOW)
        .mode(mode)
        .resolve(ResolveFlag::RESOLVE_NO_MAGICLINKS | ResolveFlag::RESOLVE_NO_SYMLINKS)
}

// Sets or clears the close-on-exec (FD_CLOEXEC) flag on a file descriptor.
fn set_cloexec<Fd: AsFd>(fd: Fd, state: bool) -> Result<(), Errno> {
    let flags = fcntl(&fd, FcntlArg::F_GETFD)?;

    let mut new_flags = flags;
    if state {
        new_flags |= FdFlag::FD_CLOEXEC.bits();
    } else {
        new_flags &= !FdFlag::FD_CLOEXEC.bits();
    }

    fcntl(
        &fd,
        FcntlArg::F_SETFD(FdFlag::from_bits_truncate(new_flags)),
    )
    .map(drop)
}

// Keep in sync with syd/src/compat.rs
//
// This structure represents the Linux data structure `struct statx_timestamp`
#[repr(C)]
#[derive(Copy, Clone, Debug, Default)]
struct FileStatxTimestamp {
    tv_sec: i64,
    tv_nsec: u32,
    __statx_timestamp_pad1: [i32; 1],
}

impl PartialEq for FileStatxTimestamp {
    fn eq(&self, other: &Self) -> bool {
        self.tv_sec == other.tv_sec && self.tv_nsec == other.tv_nsec
    }
}

impl Eq for FileStatxTimestamp {}

impl PartialOrd for FileStatxTimestamp {
    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
        Some(self.cmp(other))
    }
}

impl Ord for FileStatxTimestamp {
    fn cmp(&self, other: &Self) -> Ordering {
        match self.tv_sec.cmp(&other.tv_sec) {
            Ordering::Equal => self.tv_nsec.cmp(&other.tv_nsec),
            ord => ord,
        }
    }
}

// This structure represents the Linux data structure `struct statx`
#[repr(C)]
#[derive(Copy, Clone, Debug)]
struct FileStatx {
    stx_mask: u32,       // What results were written [uncond]
    stx_blksize: u32,    // Preferred general I/O size [uncond]
    stx_attributes: u64, // Flags conveying information about the file [uncond]

    stx_nlink: u32, // Number of hard links
    stx_uid: u32,   // User ID of owner
    stx_gid: u32,   // Group ID of owner
    stx_mode: u16,  // File mode
    __statx_pad1: [u16; 1],

    stx_ino: u64,             // Inode number
    stx_size: u64,            // File size
    stx_blocks: u64,          // Number of 512-byte blocks allocated
    stx_attributes_mask: u64, // Mask to show what's supported in stx_attributes

    stx_atime: FileStatxTimestamp, // Last access time
    stx_btime: FileStatxTimestamp, // File creation time
    stx_ctime: FileStatxTimestamp, // Last attribute change time
    stx_mtime: FileStatxTimestamp, // Last data modification time

    stx_rdev_major: u32, // Device ID of special file [if bdev/cdev]
    stx_rdev_minor: u32,

    // Note, these are not not public on purpose
    // as they return inconsistent values on filesytems
    // such as btrfs and overlayfs. `stx_mnt_id` should
    // be used instead.
    stx_dev_major: u32, // ID of device containing file [uncond]
    stx_dev_minor: u32,

    stx_mnt_id: u64,
    stx_dio_mem_align: u32,    // Memory buffer alignment for direct I/O
    stx_dio_offset_align: u32, // File offset alignment for direct I/O

    __statx_pad2: [u64; 12], // Spare space for future expansion
}

// Safe statx() wrapper to use with a FD only.
fn fstatx<Fd: AsFd>(fd: Fd, mask: libc::c_uint) -> Result<FileStatx, Errno> {
    let fd = fd.as_fd().as_raw_fd();
    let mut dst = std::mem::MaybeUninit::uninit();

    // SAFETY: Neither nix nor libc has a wrapper for statx.
    Errno::result(unsafe {
        libc::syscall(
            libc::SYS_statx,
            fd,
            b"\0".as_ptr(),
            libc::AT_EMPTY_PATH,
            mask,
            dst.as_mut_ptr(),
        )
    })?;

    // SAFETY: statx returned success.
    Ok(unsafe { dst.assume_init() })
}

// Want/got stx_ino.
const STATX_INO: libc::c_uint = 0x00000100;
// Want/got stx_type.
const STATX_TYPE: libc::c_uint = 0x00000001;
// Want/got stx_size.
const STATX_SIZE: libc::c_uint = 0x00000200;

// Retries a closure on `EINTR` error.
//
// This function will call the provided closure, and if the closure
// returns `EINTR` error, it will retry the operation until it succeeds
// or fails with a different error.
fn retry_on_eintr<F, T>(mut f: F) -> Result<T, Errno>
where
    F: FnMut() -> Result<T, Errno>,
{
    loop {
        match f() {
            Err(Errno::EINTR) => continue,
            result => return result,
        }
    }
}

// Retries a closure on `EAGAIN` and `EINTR` errors.
//
// This function will call the provided closure, and if the closure
// returns `EAGAIN` or `EINTR` error, it will retry the operation until it
// succeeds or fails with a different error.
fn retry_on_intr<F, T>(mut f: F) -> Result<T, Errno>
where
    F: FnMut() -> Result<T, Errno>,
{
    loop {
        match f() {
            Err(Errno::EAGAIN | Errno::EINTR) => continue,
            result => return result,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    const TMP_COMP_CASES: &[(&str, bool)] = &[
        ("1", true),
        ("42", true),
        ("999", true),
        ("12345", true),
        ("131037", true),
        ("4194304", true),
        ("t004_hXEy5U", true),
        ("tmp.aB3xZ9", true),
        ("data-Kf8mQ2pR", true),
        ("sess_a1b2c3", true),
        ("t004_bvkdem", true),
        ("aB3xZ9", true),
        ("Kf8mQ2pR", true),
        ("file.12345", true),
        ("log-20260314", true),
        ("cache.001", false),
        ("hostname", false),
        ("hosts", false),
        ("status", false),
        ("passwd", false),
        ("resolv.conf", false),
        ("nsswitch.conf", false),
        ("pid", false),
        ("ns", false),
        ("self", false),
        ("task", false),
        ("fd", false),
        ("attr", false),
        ("smack", false),
        ("apparmor", false),
        ("net", false),
        ("sys", false),
        ("libc.so.6", false),
        ("ld-linux-x86-64.so.2", false),
        ("libacl.so.1.1.2302", false),
        ("libattr.so.1.1.2502", false),
        ("libncursesw.so.6.5", false),
        ("libreadline.so.8.3", false),
        ("t031_src", false),
        ("t031_dst", false),
        ("t032_a", false),
        ("t032_b", false),
        ("t033_out", false),
        ("src", false),
        ("dst", false),
        ("", false),
        ("a", false),
        ("ab", false),
        ("abc", false),
        ("ld.so.cache", false),
        ("ab_cd", false),
        ("ab_cde", false),
        ("X_123", false),
        ("X_12345", true),
        ("foo.so.123", false),
        ("foo.so.12345", true),
    ];

    #[test]
    fn test_is_tmp_comp() {
        for &(input, expected) in TMP_COMP_CASES {
            assert_eq!(
                is_tmp_comp(input),
                expected,
                "is_tmp_comp({input:?}) = {expected}"
            );
        }
    }

    #[test]
    fn test_entropy_degenerate() {
        assert_eq!(entropy(""), 0.0);
        assert_eq!(entropy("aaaa"), 0.0);
        assert_eq!(entropy("zzzzzzzzz"), 0.0);
    }

    #[test]
    fn test_entropy_uniform() {
        // H("ab") = 1.0 bit (2 equiprobable symbols).
        assert!((entropy("ab") - 1.0).abs() < 0.01);
        // H("abcd") = 2.0 bits (4 equiprobable symbols).
        assert!((entropy("abcd") - 2.0).abs() < 0.01);
    }

    #[test]
    fn test_entropy_skewed() {
        // "aab": p(a)=2/3, p(b)=1/3 -> H ~= 0.918 bits.
        let bits = entropy("aab");
        assert!(bits > 0.9 && bits < 0.95, "H(aab) = {}", bits);
    }

    #[test]
    fn test_char_classes() {
        assert_eq!(char_classes(""), 0);
        assert_eq!(char_classes("abc"), 1);
        assert_eq!(char_classes("ABC"), 1);
        assert_eq!(char_classes("123"), 1);
        assert_eq!(char_classes("aB"), 2);
        assert_eq!(char_classes("a1"), 2);
        assert_eq!(char_classes("A1"), 2);
        assert_eq!(char_classes("aB3"), 3);
        assert_eq!(char_classes("_-."), 0); // punctuation = none
    }

    #[test]
    fn test_normpath_proc_pid() {
        assert_eq!(normpath_proc("/proc/1/status"), Some("/proc".into()));
        assert_eq!(normpath_proc("/proc/42/status"), Some("/proc".into()));
        assert_eq!(normpath_proc("/proc/131037/fd/3"), Some("/proc".into()));
    }

    #[test]
    fn test_normpath_proc_self() {
        // /proc/self/* must NOT be normalized (self is not a PID).
        assert_eq!(normpath_proc("/proc/self/status"), None);
        assert_eq!(normpath_proc("/proc/self/task/131037/status"), None);
    }

    #[test]
    fn test_normpath_proc_non_proc() {
        assert_eq!(normpath_proc("/etc/hostname"), None);
        assert_eq!(normpath_proc("/tmp/foo"), None);
    }

    #[test]
    fn test_resolve_tmp_comp_pid() {
        assert_eq!(resolve_tmp_comp("/proc/42/status"), Some("/proc".into()));
        assert_eq!(resolve_tmp_comp("/proc/131037/fd/3"), Some("/proc".into()));
    }

    #[test]
    fn test_resolve_tmp_comp_tid() {
        assert_eq!(
            resolve_tmp_comp("/proc/self/task/131037/status"),
            Some("/proc/self/task".into())
        );
    }

    #[test]
    fn test_resolve_tmp_comp_stable() {
        assert_eq!(resolve_tmp_comp("/etc/hostname"), None);
        assert_eq!(resolve_tmp_comp("/usr/lib/libc.so.6"), None);
        assert_eq!(resolve_tmp_comp("/proc/self/status"), None);
    }

    #[test]
    fn test_resolve_tmp_comp_mkstemp() {
        assert_eq!(resolve_tmp_comp("/tmp/sess_a1b2c3"), Some("/tmp".into()));
    }

    #[test]
    fn test_promote_to_parent_with_mutation() {
        let mut dp: PandoraMap<String, PandoraSet<String>> = PandoraMap::default();
        dp.entry("/tmp".into()).or_default().insert("delete".into());

        assert_eq!(promote_to_parent("/tmp/t033_out", &dp), Some("/tmp".into()));
    }

    #[test]
    fn test_promote_to_parent_no_mutation() {
        let mut dp: PandoraMap<String, PandoraSet<String>> = PandoraMap::default();
        dp.entry("/tmp".into()).or_default().insert("read".into());

        // Parent has only read, not mutation caps -- no promotion.
        assert_eq!(promote_to_parent("/tmp/file", &dp), None);
    }

    #[test]
    fn test_merge_lock_paths_exec() {
        // Exec files must not be promoted into their parent directory.
        let mut fp: PandoraMap<String, PandoraSet<String>> = PandoraMap::default();
        let mut caps = PandoraSet::default();
        caps.insert("read".into());
        caps.insert("exec".into());
        fp.insert("/tmp/mybinary".into(), caps);
        let mut dp: PandoraMap<String, PandoraSet<String>> = PandoraMap::default();
        dp.entry("/tmp".into()).or_default().insert("delete".into());
        let merged = merge_lock_paths(fp, dp, 0);
        assert!(merged.contains_key("/tmp/mybinary"));
    }

    #[test]
    fn test_merge_lock_paths_siblings() {
        // Without resolve_siblings, non-ephemeral siblings stay separate.
        let mut fp: PandoraMap<String, PandoraSet<String>> = PandoraMap::default();
        fp.entry("/tmp/t031_src".into())
            .or_default()
            .insert("wpath".into());
        fp.entry("/tmp/t031_dst".into())
            .or_default()
            .insert("wpath".into());
        let dp: PandoraMap<String, PandoraSet<String>> = PandoraMap::default();
        let merged = merge_lock_paths(fp, dp, 0);
        assert!(merged.contains_key("/tmp/t031_src"));
        assert!(merged.contains_key("/tmp/t031_dst"));
    }

    #[test]
    fn test_merge_lock_paths_stable() {
        let mut fp: PandoraMap<String, PandoraSet<String>> = PandoraMap::default();
        fp.entry("/etc/hostname".into())
            .or_default()
            .insert("read".into());
        fp.entry("/etc/hosts".into())
            .or_default()
            .insert("read".into());
        let dp: PandoraMap<String, PandoraSet<String>> = PandoraMap::default();
        let merged = merge_lock_paths(fp, dp, 0);
        assert!(merged.contains_key("/etc/hostname"));
        assert!(merged.contains_key("/etc/hosts"));
    }

    #[test]
    fn test_merge_lock_paths_pid() {
        let mut fp: PandoraMap<String, PandoraSet<String>> = PandoraMap::default();
        fp.entry("/proc/42/status".into())
            .or_default()
            .insert("read".into());
        let dp: PandoraMap<String, PandoraSet<String>> = PandoraMap::default();
        let merged = merge_lock_paths(fp, dp, 0);
        assert!(merged.contains_key("/proc"));
        assert!(!merged.contains_key("/proc/42/status"));
    }

    #[test]
    fn test_merge_lock_paths_mut_parent() {
        let mut fp: PandoraMap<String, PandoraSet<String>> = PandoraMap::default();
        fp.entry("/tmp/t033_out".into())
            .or_default()
            .insert("wpath".into());
        let mut dp: PandoraMap<String, PandoraSet<String>> = PandoraMap::default();
        dp.entry("/tmp".into()).or_default().insert("delete".into());
        let merged = merge_lock_paths(fp, dp, 0);
        assert!(merged.contains_key("/tmp"));
        assert!(!merged.contains_key("/tmp/t033_out"));
    }

    #[test]
    fn test_merge_lock_paths_cap_union() {
        // Non-ephemeral siblings with only 2 entries stay separate.
        let mut fp: PandoraMap<String, PandoraSet<String>> = PandoraMap::default();
        fp.entry("/tmp/t031_src".into())
            .or_default()
            .insert("wpath".into());
        fp.entry("/tmp/t031_dst".into())
            .or_default()
            .insert("read".into());
        let dp: PandoraMap<String, PandoraSet<String>> = PandoraMap::default();
        let merged = merge_lock_paths(fp, dp, 0);
        assert!(merged.contains_key("/tmp/t031_src"));
        assert!(merged.contains_key("/tmp/t031_dst"));
    }

    #[test]
    fn test_merge_lock_paths_no_excess() {
        // /proc/self/status must NOT be promoted (self is not ephemeral).
        let mut fp: PandoraMap<String, PandoraSet<String>> = PandoraMap::default();
        fp.entry("/proc/self/status".into())
            .or_default()
            .insert("read".into());
        let dp: PandoraMap<String, PandoraSet<String>> = PandoraMap::default();
        let merged = merge_lock_paths(fp, dp, 0);
        assert!(merged.contains_key("/proc/self/status"));
        assert!(!merged.contains_key("/proc"));
    }

    const PATH2GLOB_CASES: &[(&str, Option<&str>)] = &[
        ("/proc/1/status", Some("/proc/[0-9]*/status")),
        ("/proc/42/status", Some("/proc/[0-9]*/status")),
        ("/proc/131037/status", Some("/proc/[0-9]*/status")),
        ("/proc/1/maps", Some("/proc/[0-9]*/maps")),
        ("/proc/1/cmdline", Some("/proc/[0-9]*/cmdline")),
        ("/proc/42/comm", Some("/proc/[0-9]*/comm")),
        ("/proc/999/oom_score", Some("/proc/[0-9]*/oom_score")),
        ("/proc/1/cgroup", Some("/proc/[0-9]*/cgroup")),
        ("/proc/1/mountinfo", Some("/proc/[0-9]*/mountinfo")),
        ("/proc/1/stat", Some("/proc/[0-9]*/stat")),
        ("/proc/1/fd/0", Some("/proc/[0-9]*/fd/[0-9]*")),
        ("/proc/42/fd/3", Some("/proc/[0-9]*/fd/[0-9]*")),
        ("/proc/131037/fd/255", Some("/proc/[0-9]*/fd/[0-9]*")),
        ("/proc/1/fd/1024", Some("/proc/[0-9]*/fd/[0-9]*")),
        ("/proc/1/ns/pid", Some("/proc/[0-9]*/ns/pid")),
        ("/proc/42/ns/net", Some("/proc/[0-9]*/ns/net")),
        ("/proc/1/ns/mnt", Some("/proc/[0-9]*/ns/mnt")),
        ("/proc/1/ns/user", Some("/proc/[0-9]*/ns/user")),
        ("/proc/1/ns/ipc", Some("/proc/[0-9]*/ns/ipc")),
        ("/proc/1/ns/uts", Some("/proc/[0-9]*/ns/uts")),
        (
            "/proc/1/task/1/status",
            Some("/proc/[0-9]*/task/[0-9]*/status"),
        ),
        (
            "/proc/42/task/131037/status",
            Some("/proc/[0-9]*/task/[0-9]*/status"),
        ),
        (
            "/proc/42/task/131037/comm",
            Some("/proc/[0-9]*/task/[0-9]*/comm"),
        ),
        (
            "/proc/1/task/1/fd/0",
            Some("/proc/[0-9]*/task/[0-9]*/fd/[0-9]*"),
        ),
        (
            "/proc/42/task/131037/fd/3",
            Some("/proc/[0-9]*/task/[0-9]*/fd/[0-9]*"),
        ),
        (
            "/proc/1/task/1/ns/pid",
            Some("/proc/[0-9]*/task/[0-9]*/ns/pid"),
        ),
        (
            "/proc/42/task/131037/ns/net",
            Some("/proc/[0-9]*/task/[0-9]*/ns/net"),
        ),
        ("/proc/self/status", Some("/proc/self/status")),
        ("/proc/self/maps", Some("/proc/self/maps")),
        ("/proc/self/cmdline", Some("/proc/self/cmdline")),
        ("/proc/self/comm", Some("/proc/self/comm")),
        ("/proc/self/exe", Some("/proc/self/exe")),
        ("/proc/self/cgroup", Some("/proc/self/cgroup")),
        ("/proc/self/mountinfo", Some("/proc/self/mountinfo")),
        ("/proc/self/attr/current", Some("/proc/self/attr/current")),
        ("/proc/self/ns/pid", Some("/proc/self/ns/pid")),
        ("/proc/self/ns/net", Some("/proc/self/ns/net")),
        ("/proc/self/ns/mnt", Some("/proc/self/ns/mnt")),
        ("/proc/self/net/tcp", Some("/proc/self/net/tcp")),
        ("/proc/self/net/unix", Some("/proc/self/net/unix")),
        ("/proc/self/fd/0", Some("/proc/self/fd/[0-9]*")),
        ("/proc/self/fd/3", Some("/proc/self/fd/[0-9]*")),
        ("/proc/self/fd/255", Some("/proc/self/fd/[0-9]*")),
        ("/proc/self/fdinfo/0", Some("/proc/self/fdinfo/[0-9]*")),
        ("/proc/self/fdinfo/42", Some("/proc/self/fdinfo/[0-9]*")),
        (
            "/proc/self/task/131037/status",
            Some("/proc/self/task/[0-9]*/status"),
        ),
        (
            "/proc/self/task/1/comm",
            Some("/proc/self/task/[0-9]*/comm"),
        ),
        (
            "/proc/self/task/999/maps",
            Some("/proc/self/task/[0-9]*/maps"),
        ),
        (
            "/proc/self/task/131037/fd/3",
            Some("/proc/self/task/[0-9]*/fd/[0-9]*"),
        ),
        (
            "/proc/self/task/1/fd/0",
            Some("/proc/self/task/[0-9]*/fd/[0-9]*"),
        ),
        (
            "/proc/self/task/131037/fdinfo/3",
            Some("/proc/self/task/[0-9]*/fdinfo/[0-9]*"),
        ),
        ("/proc/thread-self/status", Some("/proc/thread-self/status")),
        ("/proc/thread-self/comm", Some("/proc/thread-self/comm")),
        (
            "/proc/thread-self/fd/0",
            Some("/proc/thread-self/fd/[0-9]*"),
        ),
        (
            "/proc/thread-self/fd/42",
            Some("/proc/thread-self/fd/[0-9]*"),
        ),
        (
            "/proc/thread-self/fdinfo/3",
            Some("/proc/thread-self/fdinfo/[0-9]*"),
        ),
        ("/proc/thread-self/ns/pid", Some("/proc/thread-self/ns/pid")),
        (
            "/proc/thread-self/attr/current",
            Some("/proc/thread-self/attr/current"),
        ),
        ("/dev/pts/0", Some("/dev/pts/[0-9]*")),
        ("/dev/pts/1", Some("/dev/pts/[0-9]*")),
        ("/dev/pts/34", Some("/dev/pts/[0-9]*")),
        ("/dev/pts/255", Some("/dev/pts/[0-9]*")),
        ("/dev/pts/ptmx", None),
        ("/dev/tty", Some("/dev/tty")),
        ("/dev/tty0", Some("/dev/tty*")),
        ("/dev/tty1", Some("/dev/tty*")),
        ("/dev/ttyS0", Some("/dev/tty*")),
        ("/dev/ttyACM0", Some("/dev/tty*")),
        ("/dev/ttyUSB0", Some("/dev/tty*")),
        ("!memfd:foo", Some("!memfd:**")),
        ("!memfd:", Some("!memfd:**")),
        ("!memfd:jit-code", Some("!memfd:**")),
        ("!memfd:pulseaudio", Some("!memfd:**")),
        ("!memfd-hugetlb:huge", Some("!memfd-hugetlb:**")),
        ("!memfd-hugetlb:", Some("!memfd-hugetlb:**")),
        ("@cuda-uvmfd--1-63797", Some("@cuda-uvmfd--*")),
        ("@cuda-uvmfd--0-12345", Some("@cuda-uvmfd--*")),
        ("@cuda-uvmfd--42-99999", Some("@cuda-uvmfd--*")),
        (
            "@gecko-crash-helper-pipe.462275",
            Some("@gecko-crash-helper-pipe.*"),
        ),
        (
            "@gecko-crash-helper-pipe.1",
            Some("@gecko-crash-helper-pipe.*"),
        ),
        ("@gecko-ipc-pipe.12345", Some("@gecko-ipc-pipe.*")),
        (
            "2f6574632f686f73746e616d65",
            Some("2f6574632f686f73746e616d65"),
        ),
        ("deadbeef", Some("deadbeef")),
        ("0123456789abcdef", Some("0123456789abcdef")),
        ("/etc/hostname", None),
        ("/etc/hosts", None),
        ("/etc/resolv.conf", None),
        ("/etc/nsswitch.conf", None),
        ("/etc/ld.so.cache", None),
        ("/usr/lib/libc.so.6", None),
        ("/usr/lib/ld-linux-x86-64.so.2", None),
        ("/usr/bin/cat", None),
        ("/bin/sh", None),
        ("/dev/null", None),
        ("/dev/zero", None),
        ("/dev/urandom", None),
        ("/tmp/stable_name", None),
        ("/var/log/syslog", None),
        ("/home/user/.bashrc", None),
        ("/proc/stat", None),
        ("/proc/meminfo", None),
        ("/proc/cpuinfo", None),
        ("/proc/version", None),
        ("/proc/filesystems", None),
        ("/proc/mounts", None),
        ("@dbus-session", None),
        ("@/tmp/dbus-xyz", None),
        ("/proc/1", Some("/proc/[0-9]*")),
        ("/proc/131037", Some("/proc/[0-9]*")),
        ("/proc/1/fd", Some("/proc/[0-9]*/fd")),
        ("/proc/42/ns", Some("/proc/[0-9]*/ns")),
        ("/proc/1/task/1", Some("/proc/[0-9]*/task/[0-9]*")),
        ("/proc/42/task/131037", Some("/proc/[0-9]*/task/[0-9]*")),
        ("/proc/self", Some("/proc/self")),
        ("/proc/self/", Some("/proc/self")),
        ("/proc/thread-self", Some("/proc/thread-self")),
        ("/proc/thread-self/", Some("/proc/thread-self")),
        ("/proc/self/task/131037", Some("/proc/self/task/[0-9]*")),
        ("/proc/self/task/self", Some("/proc/self/task/self")),
        (
            "/proc/1/ns/pid:[4026531836]",
            Some("/proc/[0-9]*/ns/pid:\\[[0-9]*\\]"),
        ),
        (
            "/proc/42/ns/net:[4026531992]",
            Some("/proc/[0-9]*/ns/net:\\[[0-9]*\\]"),
        ),
        (
            "/proc/1/task/1/ns/mnt:[4026531840]",
            Some("/proc/[0-9]*/task/[0-9]*/ns/mnt:\\[[0-9]*\\]"),
        ),
        (
            "/tmp/tmpXXXXXX",
            None, // Only 6 X's, might not trigger is_tmp_comp
        ),
        (
            "/run/user/1000/sess_a1b2c3d4e5f6",
            Some("/run/user/[0-9]*/sess_*/***"),
        ),
        ("/dev/pts", None),
        ("/dev/pts/", None),
        ("/dev/tty", Some("/dev/tty")),
        ("!memfd:wayland-shm", Some("!memfd:**")),
        ("!memfd:mesa-shader", Some("!memfd:**")),
        ("!memfd:v8-wasm", Some("!memfd:**")),
        ("@gecko-ipc-pipe.1", Some("@gecko-ipc-pipe.*")),
        (
            "@gecko-crash-server-pipe.999999",
            Some("@gecko-crash-server-pipe.*"),
        ),
        ("@cuda-uvmfd--0-0", Some("@cuda-uvmfd--*")),
        ("/sys/class/net", None),
        ("/sys/kernel/mm/transparent_hugepage/enabled", None),
        ("/run/dbus/system_bus_socket", None),
        ("/usr/share/locale/en_US/LC_MESSAGES/libc.mo", None),
        ("/usr/share/zoneinfo/UTC", None),
        ("/etc/localtime", None),
        ("/etc/passwd", None),
        ("/etc/group", None),
        ("/etc/shadow", None),
        ("/proc/sys/kernel/hostname", None),
        ("/proc/sys/net/core/somaxconn", None),
        ("/proc/pressure/cpu", None),
        ("/proc/net/tcp", None),
        ("/proc/net/unix", None),
        ("/proc/bus/pci/devices", None),
        ("/proc/self/oom_adj", Some("/proc/self/oom_adj")),
        ("/proc/self/oom_score_adj", Some("/proc/self/oom_score_adj")),
        ("/proc/self/smaps", Some("/proc/self/smaps")),
        ("/proc/self/smaps_rollup", Some("/proc/self/smaps_rollup")),
        ("/proc/self/pagemap", Some("/proc/self/pagemap")),
        ("/proc/self/wchan", Some("/proc/self/wchan")),
        ("/proc/self/net/tcp6", Some("/proc/self/net/tcp6")),
        ("/proc/self/net/udp", Some("/proc/self/net/udp")),
        (
            "/proc/self/attr/apparmor/current",
            Some("/proc/self/attr/apparmor/current"),
        ),
        (
            "/proc/thread-self/attr/apparmor/current",
            Some("/proc/thread-self/attr/apparmor/current"),
        ),
        (
            "/proc/thread-self/net/tcp",
            Some("/proc/thread-self/net/tcp"),
        ),
        (
            "/proc/thread-self/task/42/ns/pid",
            Some("/proc/thread-self/task/[0-9]*/ns/pid"),
        ),
        (
            "/proc/thread-self/task/42/fd/5",
            Some("/proc/thread-self/task/[0-9]*/fd/[0-9]*"),
        ),
        (
            "/proc/thread-self/task/42/fdinfo/5",
            Some("/proc/thread-self/task/[0-9]*/fdinfo/[0-9]*"),
        ),
        (
            "/proc/self/fd/pipe:[5303077]",
            Some("/proc/self/fd/pipe:\\[[0-9]*\\]"),
        ),
        (
            "/proc/self/fd/socket:[12345]",
            Some("/proc/self/fd/socket:\\[[0-9]*\\]"),
        ),
        (
            "/proc/1/fd/pipe:[999]",
            Some("/proc/[0-9]*/fd/pipe:\\[[0-9]*\\]"),
        ),
        (
            "/proc/self/fd/anon_inode:[eventfd]",
            Some("/proc/self/fd/anon_inode:*"),
        ),
        (
            "/proc/self/fd/anon_inode:[eventpoll]",
            Some("/proc/self/fd/anon_inode:*"),
        ),
    ];

    #[test]
    fn test_path2glob() {
        for &(input, expected) in PATH2GLOB_CASES {
            let result = path2glob(input).map(|c| c.into_owned());
            let expected = expected.map(|s| s.to_string());
            assert_eq!(result, expected, "path2glob({input:?})");
        }
    }
}
