//
// 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

#![expect(clippy::disallowed_methods)]
#![expect(clippy::disallowed_types)]

#[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::net::{SocketAddr, SocketAddrV4, SocketAddrV6, UdpSocket};
use std::{
    borrow::Cow,
    cmp::Ordering,
    collections::BTreeSet,
    convert::TryFrom,
    env,
    ffi::OsString,
    fmt,
    fmt::Write as FmtWrite,
    fs::{metadata, File, OpenOptions},
    hash::{Hash, Hasher as StdHasher},
    io::{self, stderr, stdin, BufRead, BufReader, Read, Write as IoWrite, Write},
    iter::FromIterator,
    mem::MaybeUninit,
    net::IpAddr,
    os::{
        fd::{AsFd, AsRawFd},
        unix::ffi::{OsStrExt, OsStringExt},
    },
    path::{Path, PathBuf},
    process::{exit, Command, ExitCode},
    str,
    sync::{
        atomic::{AtomicBool, Ordering as AtomicOrdering},
        Arc, Mutex,
    },
    thread,
    time::Duration,
};

use base16ct::{lower, mixed};
use blake2::{Blake2b, Blake2s};
use btoi::btoi;
use console::style;
use crc::{Crc, CRC_32_ISCSI, CRC_32_ISO_HDLC, CRC_64_ECMA_182};
use digest::{
    consts::{U16, U20, U32, U48, U64},
    Digest,
};
use fixedbitset::FixedBitSet;
use gost94::Gost94CryptoPro;
use groestl::{Groestl256, Groestl512};
use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle};
use libc::pid_t;
use md4::Md4;
use md5::Md5;
use memchr::arch::all::is_equal;
use nix::{
    errno::Errno,
    fcntl::{fcntl, openat2, FcntlArg, FdFlag, OFlag, OpenHow, ResolveFlag, AT_FDCWD},
    sys::{
        signal::{kill, sigprocmask, SigmaskHow, Signal},
        signalfd::SigSet,
        stat::Mode,
    },
    unistd::{getcwd, pipe2, Gid, Pid, Uid, User},
};
use patricia_tree::StringPatriciaSet;
use rayon::{
    iter::{IntoParallelRefIterator, ParallelIterator},
    ThreadPoolBuilder,
};
use ripemd::{Ripemd128, Ripemd160, Ripemd256, Ripemd320};
use serde::{
    de::{MapAccess, SeqAccess, Visitor},
    Deserialize, Deserializer, Serialize, Serializer,
};
use sha1::Sha1;
use sha2::{Sha224, Sha256, Sha384, Sha512};
use sha3::{Keccak256, Keccak512, Sha3_224, Sha3_256, Sha3_384, Sha3_512};
use sm3::Sm3;
use streebog::{Streebog256, Streebog512};
use tiger::{Tiger, Tiger2};
use whirlpool::Whirlpool;

// Get CPU count using sched_getaffinity(2)
mod cpu;

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", "list", "stat", "chdir", "notify", "readlink"];
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",
    "list",
    "stat",
    "read",
    "write",
    "exec",
    "create",
    "delete",
    "rename",
    "readlink",
    "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", "connect",
];

// 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" | "list" | "chdir" | "notify" | "readlink" => (&[], &[]),
        "utime" | "chown" | "chgrp" | "chmod" | "chattr" => (&[], &[]),
        "net/bind" => (&[], &["bind"]),
        "net/connect" => (&["connect"], &[]),
        _ => (&[], &[]),
    }
}

// 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,
    }
}

fn hex_encode(data: &[u8]) -> Result<String, Errno> {
    let len = data.len().checked_mul(2).ok_or(Errno::EOVERFLOW)?;

    let mut buf = Vec::new();
    buf.try_reserve(len).or(Err(Errno::ENOMEM))?;
    buf.resize(len, 0);

    lower::encode(data, &mut buf).map_err(|e| match e {
        base16ct::Error::InvalidLength => Errno::EINVAL,
        base16ct::Error::InvalidEncoding => Errno::EILSEQ,
    })?;

    // SAFETY: hex output is always valid ASCII/UTF-8.
    Ok(unsafe { String::from_utf8_unchecked(buf) })
}

fn hex_decode(hex: &[u8]) -> Result<Vec<u8>, Errno> {
    let len = (hex.len() / 2)
        .checked_add(hex.len() % 2)
        .ok_or(Errno::EOVERFLOW)?;

    let mut buf = Vec::new();
    buf.try_reserve(len).or(Err(Errno::ENOMEM))?;
    buf.resize(len, 0);

    mixed::decode(hex, &mut buf).map_err(|e| match e {
        base16ct::Error::InvalidLength => Errno::EINVAL,
        base16ct::Error::InvalidEncoding => Errno::EILSEQ,
    })?;

    Ok(buf)
}

#[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: StdHasher>(&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)
}

fn dial_ip(key: &str) -> Option<&str> {
    let mut it = key.split('!');
    let first = it.next()?;
    match (it.next(), it.next()) {
        (None, _) => Some(first),
        (Some(_), None) => Some(first),
        (Some(ip), Some(_)) => Some(ip),
    }
}

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

// Streaming I/O buffer size used to chunk hash input.
const IO_BUF_SIZE: usize = 64 * 1024;

// CRC32 algorithm used with ISO-HDLC.
static CRC32: Crc<u32> = Crc::<u32>::new(&CRC_32_ISO_HDLC);
// CRC32c algorithm used with Castagnoli/iSCSI.
static CRC32C: Crc<u32> = Crc::<u32>::new(&CRC_32_ISCSI);
// CRC64 algorithm used with ECMA-182.
static CRC64: Crc<u64> = Crc::<u64>::new(&CRC_64_ECMA_182);

// Hash algorithms backing "hash", "hash_pipe", and "hash_file".
enum Hasher {
    Md4(Md4),
    Md5(Md5),
    Sha1(Sha1),
    Sha224(Sha224),
    Sha256(Sha256),
    Sha384(Sha384),
    Sha512(Sha512),
    Sha3_224(Sha3_224),
    Sha3_256(Sha3_256),
    Sha3_384(Sha3_384),
    Sha3_512(Sha3_512),
    Keccak256(Keccak256),
    Keccak512(Keccak512),
    Ripemd128(Ripemd128),
    Ripemd160(Ripemd160),
    Ripemd256(Ripemd256),
    Ripemd320(Ripemd320),
    Blake2b160(Blake2b<U20>),
    Blake2b256(Blake2b<U32>),
    Blake2b384(Blake2b<U48>),
    Blake2b512(Blake2b<U64>),
    Blake2s128(Blake2s<U16>),
    Blake2s256(Blake2s<U32>),
    Blake3(Box<blake3::Hasher>),
    Sm3(Sm3),
    Wp256(Whirlpool),
    Wp384(Whirlpool),
    Wp512(Whirlpool),
    Streebog256(Streebog256),
    Streebog512(Streebog512),
    Gost94(Gost94CryptoPro),
    Tiger(Tiger),
    Tiger2(Tiger2),
    Groestl256(Groestl256),
    Groestl512(Groestl512),
    Crc32(crc::Digest<'static, u32>),
    Crc32c(crc::Digest<'static, u32>),
    Crc64(crc::Digest<'static, u64>),
}

impl Hasher {
    // Select a hasher by algorithm name. Unknown names yield "EOPNOTSUPP".
    fn from_name(func: &str) -> Result<Self, Errno> {
        Ok(match func {
            "md4" => Self::Md4(Md4::new()),
            "md5" => Self::Md5(Md5::new()),
            "sha1" => Self::Sha1(Sha1::new()),
            "sha224" => Self::Sha224(Sha224::new()),
            "sha256" => Self::Sha256(Sha256::new()),
            "sha384" => Self::Sha384(Sha384::new()),
            "sha512" => Self::Sha512(Sha512::new()),
            "sha3-224" => Self::Sha3_224(Sha3_224::new()),
            "sha3-256" => Self::Sha3_256(Sha3_256::new()),
            "sha3-384" => Self::Sha3_384(Sha3_384::new()),
            "sha3-512" => Self::Sha3_512(Sha3_512::new()),
            "keccak256" => Self::Keccak256(Keccak256::new()),
            "keccak512" => Self::Keccak512(Keccak512::new()),
            "rmd128" => Self::Ripemd128(Ripemd128::new()),
            "rmd160" => Self::Ripemd160(Ripemd160::new()),
            "rmd256" => Self::Ripemd256(Ripemd256::new()),
            "rmd320" => Self::Ripemd320(Ripemd320::new()),
            "blake2b-160" => Self::Blake2b160(Blake2b::<U20>::new()),
            "blake2b-256" => Self::Blake2b256(Blake2b::<U32>::new()),
            "blake2b-384" => Self::Blake2b384(Blake2b::<U48>::new()),
            "blake2b-512" => Self::Blake2b512(Blake2b::<U64>::new()),
            "blake2s-128" => Self::Blake2s128(Blake2s::<U16>::new()),
            "blake2s-256" => Self::Blake2s256(Blake2s::<U32>::new()),
            "blake3" => Self::Blake3(Box::new(blake3::Hasher::new())),
            "sm3" => Self::Sm3(Sm3::new()),
            "wp256" => Self::Wp256(Whirlpool::new()),
            "wp384" => Self::Wp384(Whirlpool::new()),
            "wp512" => Self::Wp512(Whirlpool::new()),
            "streebog256" => Self::Streebog256(Streebog256::new()),
            "streebog512" => Self::Streebog512(Streebog512::new()),
            "gost94" => Self::Gost94(Gost94CryptoPro::new()),
            "tiger" => Self::Tiger(Tiger::new()),
            "tiger2" => Self::Tiger2(Tiger2::new()),
            "groestl256" => Self::Groestl256(Groestl256::new()),
            "groestl512" => Self::Groestl512(Groestl512::new()),
            "crc32" => Self::Crc32(CRC32.digest()),
            "crc32c" => Self::Crc32c(CRC32C.digest()),
            "crc64" => Self::Crc64(CRC64.digest()),
            _ => return Err(Errno::EOPNOTSUPP),
        })
    }

    // Feed "data" into the running hash.
    fn update(&mut self, data: &[u8]) {
        match self {
            Self::Md4(h) => h.update(data),
            Self::Md5(h) => h.update(data),
            Self::Sha1(h) => h.update(data),
            Self::Sha224(h) => h.update(data),
            Self::Sha256(h) => h.update(data),
            Self::Sha384(h) => h.update(data),
            Self::Sha512(h) => h.update(data),
            Self::Sha3_224(h) => h.update(data),
            Self::Sha3_256(h) => h.update(data),
            Self::Sha3_384(h) => h.update(data),
            Self::Sha3_512(h) => h.update(data),
            Self::Keccak256(h) => h.update(data),
            Self::Keccak512(h) => h.update(data),
            Self::Ripemd128(h) => h.update(data),
            Self::Ripemd160(h) => h.update(data),
            Self::Ripemd256(h) => h.update(data),
            Self::Ripemd320(h) => h.update(data),
            Self::Blake2b160(h) => h.update(data),
            Self::Blake2b256(h) => h.update(data),
            Self::Blake2b384(h) => h.update(data),
            Self::Blake2b512(h) => h.update(data),
            Self::Blake2s128(h) => h.update(data),
            Self::Blake2s256(h) => h.update(data),
            Self::Blake3(h) => {
                h.update(data);
            }
            Self::Sm3(h) => h.update(data),
            Self::Wp256(h) | Self::Wp384(h) | Self::Wp512(h) => h.update(data),
            Self::Streebog256(h) => h.update(data),
            Self::Streebog512(h) => h.update(data),
            Self::Gost94(h) => h.update(data),
            Self::Tiger(h) => h.update(data),
            Self::Tiger2(h) => h.update(data),
            Self::Groestl256(h) => h.update(data),
            Self::Groestl512(h) => h.update(data),
            Self::Crc32(d) | Self::Crc32c(d) => d.update(data),
            Self::Crc64(d) => d.update(data),
        }
    }

    // Finalize and return digest. CRC digests are big-endian.
    fn finalize(self) -> Result<Vec<u8>, Errno> {
        match self {
            Self::Md4(h) => try_to_vec(&h.finalize()),
            Self::Md5(h) => try_to_vec(&h.finalize()),
            Self::Sha1(h) => try_to_vec(&h.finalize()),
            Self::Sha224(h) => try_to_vec(&h.finalize()),
            Self::Sha256(h) => try_to_vec(&h.finalize()),
            Self::Sha384(h) => try_to_vec(&h.finalize()),
            Self::Sha512(h) => try_to_vec(&h.finalize()),
            Self::Sha3_224(h) => try_to_vec(&h.finalize()),
            Self::Sha3_256(h) => try_to_vec(&h.finalize()),
            Self::Sha3_384(h) => try_to_vec(&h.finalize()),
            Self::Sha3_512(h) => try_to_vec(&h.finalize()),
            Self::Keccak256(h) => try_to_vec(&h.finalize()),
            Self::Keccak512(h) => try_to_vec(&h.finalize()),
            Self::Ripemd128(h) => try_to_vec(&h.finalize()),
            Self::Ripemd160(h) => try_to_vec(&h.finalize()),
            Self::Ripemd256(h) => try_to_vec(&h.finalize()),
            Self::Ripemd320(h) => try_to_vec(&h.finalize()),
            Self::Blake2b160(h) => try_to_vec(&h.finalize()),
            Self::Blake2b256(h) => try_to_vec(&h.finalize()),
            Self::Blake2b384(h) => try_to_vec(&h.finalize()),
            Self::Blake2b512(h) => try_to_vec(&h.finalize()),
            Self::Blake2s128(h) => try_to_vec(&h.finalize()),
            Self::Blake2s256(h) => try_to_vec(&h.finalize()),
            Self::Blake3(h) => try_to_vec(h.finalize().as_bytes()),
            Self::Sm3(h) => try_to_vec(&h.finalize()),
            Self::Wp256(h) => try_to_vec(&h.finalize()[..32]),
            Self::Wp384(h) => try_to_vec(&h.finalize()[..48]),
            Self::Wp512(h) => try_to_vec(&h.finalize()),
            Self::Streebog256(h) => try_to_vec(&h.finalize()),
            Self::Streebog512(h) => try_to_vec(&h.finalize()),
            Self::Gost94(h) => try_to_vec(&h.finalize()),
            Self::Tiger(h) => try_to_vec(&h.finalize()),
            Self::Tiger2(h) => try_to_vec(&h.finalize()),
            Self::Groestl256(h) => try_to_vec(&h.finalize()),
            Self::Groestl512(h) => try_to_vec(&h.finalize()),
            Self::Crc32(d) | Self::Crc32c(d) => try_to_vec(&d.finalize().to_be_bytes()),
            Self::Crc64(d) => try_to_vec(&d.finalize().to_be_bytes()),
        }
    }
}

// Stream "input" into "hasher" in "IO_BUF_SIZE" chunks.
//
// With "size_hint" set, exactly that many bytes are hashed. A short
// read before reaching it (file shrank mid-flight) returns "EBADMSG".
//
// Without a hint, file is hashed until EOF.
fn hash_feed<Fd: AsFd>(
    hasher: &mut Hasher,
    input: Fd,
    size_hint: Option<u64>,
) -> Result<(), Errno> {
    if size_hint == Some(0) {
        return Err(Errno::EINVAL);
    }

    let mut buf = [MaybeUninit::<u8>::uninit(); IO_BUF_SIZE];
    let mut remaining = size_hint;

    loop {
        let len = match remaining {
            Some(0) => break,
            Some(rem) => usize::try_from(rem).unwrap_or(IO_BUF_SIZE).min(IO_BUF_SIZE),
            None => IO_BUF_SIZE,
        };
        // SAFETY: read(2) writes at most "len" bytes into the buffer.
        let n = retry_on_eintr(|| {
            Errno::result(unsafe {
                libc::read(input.as_fd().as_raw_fd(), buf.as_mut_ptr().cast(), len)
            })
        })?;
        let n = usize::try_from(n).or(Err(Errno::EOVERFLOW))?;
        if n == 0 {
            break;
        }
        // SAFETY: read(2) initialized the first "n" bytes.
        let chunk = unsafe { &*(&buf[..n] as *const [MaybeUninit<u8>] as *const [u8]) };
        hasher.update(chunk);
        if let Some(rem) = remaining {
            let n = u64::try_from(n).or(Err(Errno::EOVERFLOW))?;
            remaining = Some(rem.checked_sub(n).ok_or(Errno::EOVERFLOW)?);
        }
    }

    if matches!(remaining, Some(left) if left > 0) {
        // File shrank between size hint and read.
        return Err(Errno::EBADMSG);
    }

    Ok(())
}

// Hash data using a hash algorithm.
fn hash_pipe<Fd: AsFd>(func: &str, input: Option<Fd>) -> Result<Vec<u8>, Errno> {
    let mut hasher = Hasher::from_name(func)?;
    if let Some(input) = input {
        hash_feed(&mut hasher, input, None)?;
    }
    hasher.finalize()
}

// Hash file data using a hash algorithm.
//
// When "size_hint" is "Some(n)", exactly "n" bytes are hashed and a
// file that shrank below "n" mid-flight returns "EBADMSG".
fn hash_file<Fd: AsFd>(func: &str, input: Fd, size_hint: Option<u64>) -> Result<Vec<u8>, Errno> {
    if size_hint == Some(0) {
        return Err(Errno::EINVAL);
    }

    let mut hasher = Hasher::from_name(func)?;
    hash_feed(&mut hasher, input, size_hint)?;

    hasher.finalize()
}

// Hash data using a hash algorithm.
//
// Only regular files and FIFOs are accepted.
// Other file types return "EBADFD".
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);
        }

        // Bound read to stat size for regular files.
        if ftype == libc::S_IFREG && stx.stx_size > 0 {
            return hash_file(func, input, Some(stx.stx_size));
        }
    }

    // FIFOs and files without a usable size are hashed until EOF.
    hash_pipe(func, Some(input))
}

// Return preferred hash algorithm.
fn hash_auto() -> &'static str {
    "blake3"
}

// 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
    hash: 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
    hash: 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 = {
            let start = line.find('{')?;
            &line[start.saturating_sub(1)..]
        };

        // 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 = subst_id(&path);
                let path = process_path(&path, path_limit).to_string();
                let pty = path == "/dev/pts/[0-9]*";
                self.magic
                    .entry(path.clone())
                    .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());
                }

                // Collect landlock(7) paths for UNIX domain socket connects.
                // RESOLVE_UNIX is a file right so use socket path.
                if unix.starts_with('/') && capabilities.contains("net/connect") {
                    let lock_entry = self.lock_file_paths.entry(unix.clone()).or_default();
                    lock_entry.insert("connect".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.
                //
                // Address may be in following forms:
                // 1. ip!port
                // 2. ip!port-port
                // 3. dial(3) string "net!ip!port" whose service is last.
                // 4. ICMP isn't confined by landlock(7) yet.
                let icmp = addr
                    .split('!')
                    .next()
                    .is_some_and(|net| net.to_ascii_lowercase().starts_with("icmp"));
                if let Some(port_part) = addr.rsplit('!').next().filter(|_| !icmp) {
                    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,
        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,
        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 number of CPUs.
    if env::var_os("PANDORA_NPROC").is_none() {
        let nproc = cpu::get().unwrap_or(2).to_string();
        env::set_var("PANDORA_NPROC", nproc);
    }

    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() => val,
        _ => "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", cpu::get().unwrap_or(2)).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 = 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 the preferred algorithm.
    if prof.hash == "auto" {
        prof.hash = hash_auto().to_string();
    }

    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() => val,
        _ => "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", cpu::get().unwrap_or(2)).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 = 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 the preferred algorithm.
    if io.hash == "auto" {
        io.hash = hash_auto().to_string();
    }

    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: &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###")?;

    // 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);
        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 = dial_ip(raw_elem).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 = dial_ip(raw_elem).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);
            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, using concurrency-limited
    // parallel checksums and multiple progress bars.
    if !access.force.is_empty() {
        wln!(&mut output, "\n###\n# Executable Verification\n###")?;

        let 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 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(),
        );
    }

    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.
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 = hex_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_UID}, ${SYD_GID}, ${SYD_HOME}, ${SYD_PWD}.
fn subst_env(path: &str) -> 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();

    for (path, caps) in file_paths {
        let is_virtual =
            caps.contains("create") || (path.starts_with("/proc/") && path.contains(":["));
        let key = if is_virtual {
            parent_dir(&path).map(str::to_string).unwrap_or(path)
        } else {
            path
        };

        if caps.contains("exec") || caps.contains("ioctl") {
            out.entry(key).or_default().extend(caps);
            continue;
        }

        let key = resolve_tmp_comp(&key).unwrap_or(key);
        let key = promote_to_parent(&key, &dir_paths).unwrap_or(key);
        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);
    }

    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) = hex_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) {
        (
            hex_encode(buf).ok().unwrap_or_else(|| "?".to_string()),
            true,
        )
    } else if let Ok(s) = std::str::from_utf8(buf) {
        (s.to_string(), false)
    } else {
        (
            hex_encode(buf).ok().unwrap_or_else(|| "?".to_string()),
            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 [default: blake3]
                  e.g. blake3, sha256, sha512, sha3-512, blake2b-256, md5, crc32c
                  Use "syd-sum -a list" to see the complete list of supported algorithms.
  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: [default:blake3, env:PANDORA_HASH]
               e.g. blake3, sha256, sha512, sha3-512, blake2b-256, md5, crc32c
               Use "syd-sum -a list" to see the complete list of supported algorithms.
  -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: [default:blake3, env:PANDORA_HASH]
               e.g. blake3, sha256, sha512, sha3-512, blake2b-256, md5, crc32c
               Use "syd-sum -a list" to see the complete list of supported algorithms.
  -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,
        }
    }
}

// Allocate a Vec from a slice using fallible allocation.
fn try_to_vec(data: &[u8]) -> Result<Vec<u8>, Errno> {
    let mut buf = Vec::new();
    buf.try_reserve_exact(data.len()).or(Err(Errno::ENOMEM))?;
    buf.extend_from_slice(data);
    Ok(buf)
}

#[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_1() {
        assert_eq!(entropy(""), 0.0);
        assert_eq!(entropy("aaaa"), 0.0);
        assert_eq!(entropy("zzzzzzzzz"), 0.0);
    }

    #[test]
    fn test_entropy_2() {
        assert!((entropy("ab") - 1.0).abs() < 0.01);
        assert!((entropy("abcd") - 2.0).abs() < 0.01);
    }

    #[test]
    fn test_entropy_3() {
        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);
    }

    #[test]
    fn test_normpath_proc_1() {
        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_2() {
        assert_eq!(normpath_proc("/proc/self/status"), None);
        assert_eq!(normpath_proc("/proc/self/task/131037/status"), None);
    }

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

    #[test]
    fn test_resolve_tmp_comp_1() {
        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_2() {
        assert_eq!(
            resolve_tmp_comp("/proc/self/task/131037/status"),
            Some("/proc/self/task".into())
        );
    }

    #[test]
    fn test_resolve_tmp_comp_3() {
        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_4() {
        assert_eq!(resolve_tmp_comp("/tmp/sess_a1b2c3"), Some("/tmp".into()));
    }

    #[test]
    fn test_promote_to_parent_1() {
        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_2() {
        let mut dp: PandoraMap<String, PandoraSet<String>> = PandoraMap::default();
        dp.entry("/tmp".into()).or_default().insert("read".into());
        assert_eq!(promote_to_parent("/tmp/file", &dp), None);
    }

    #[test]
    fn test_merge_lock_paths_1() {
        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_2() {
        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_3() {
        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_4() {
        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_5() {
        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_6() {
        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_7() {
        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"));
    }

    #[test]
    fn test_merge_lock_paths_8() {
        let mut fp: PandoraMap<String, PandoraSet<String>> = PandoraMap::default();
        fp.entry("/tmp/my.sock".into())
            .or_default()
            .insert("connect".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.get("/tmp").is_some_and(|c| c.contains("connect")));
        assert!(!merged.contains_key("/tmp/my.sock"));
    }

    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),
        (
            "/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:?})");
        }
    }

    #[test]
    fn test_dial_ip_1() {
        assert_eq!(dial_ip("127.0.0.1!80"), Some("127.0.0.1"));
        assert_eq!(dial_ip("::1!443"), Some("::1"));
        assert_eq!(dial_ip("tcp!127.0.0.1!80"), Some("127.0.0.1"));
        assert_eq!(dial_ip("udp!9.9.9.9!53"), Some("9.9.9.9"));
        assert_eq!(dial_ip("tcp!::1!443"), Some("::1"));
    }

    #[test]
    fn test_dial_ip_2() {
        let mut access = AccessMap::default();
        let line = r#"{"ctx":"access","cap":"net/connect","addr":"tcp!127.0.0.1!80"}"#;
        assert!(access.parse_json_line(line, u8::MAX).is_none());
        let caps = access
            .magic
            .get("tcp!127.0.0.1!80")
            .expect("dial(3) address learned as a rule pattern");
        assert!(caps.contains("net/connect"));
        assert!(access.lock_connect_ports.contains(80));
    }

    #[test]
    fn test_dial_ip_3() {
        let mut access = AccessMap::default();
        let line = r#"{"ctx":"access","cap":"net/connect","addr":"127.0.0.1!80"}"#;
        assert!(access.parse_json_line(line, u8::MAX).is_none());
        assert!(access.magic.contains_key("127.0.0.1!80"));
        assert!(access.lock_connect_ports.contains(80));
    }

    #[test]
    fn test_dial_ip_4() {
        assert_eq!(dial_ip("127.0.0.1"), Some("127.0.0.1"));
        assert_eq!(dial_ip("::1"), Some("::1"));
        assert_eq!(dial_ip("10.0.0.0/8"), Some("10.0.0.0/8"));
    }

    #[test]
    fn test_dial_ip_5() {
        assert_eq!(dial_ip("tcp!10.0.0.0/8!22,80,443"), Some("10.0.0.0/8"));
        assert_eq!(dial_ip("net6!::1!1024-65535"), Some("::1"));
        assert_eq!(dial_ip("udp4!0.0.0.0/0!*"), Some("0.0.0.0/0"));
    }

    #[test]
    fn test_dial_ip_6() {
        assert_eq!(dial_ip(""), Some(""));
        assert_eq!(dial_ip("tcp!"), Some("tcp"));
        assert_eq!(dial_ip("!"), Some(""));
    }

    #[test]
    fn test_dial_ip_7() {
        let mut access = AccessMap::default();
        let line = r#"{"ctx":"access","cap":"net/connect","addr":"udp!9.9.9.9!53"}"#;
        assert!(access.parse_json_line(line, u8::MAX).is_none());
        assert!(access.magic.contains_key("udp!9.9.9.9!53"));
        assert!(access.lock_connect_ports.contains(53));
    }

    #[test]
    fn test_dial_ip_8() {
        let mut access = AccessMap::default();
        let line = r#"{"ctx":"access","cap":"net/bind","addr":"tcp!127.0.0.1!8080"}"#;
        assert!(access.parse_json_line(line, u8::MAX).is_none());
        assert!(access.magic.contains_key("tcp!127.0.0.1!8080"));
        assert!(access.lock_bind_ports.contains(8080));
        assert!(!access.lock_connect_ports.contains(8080));
    }

    #[test]
    fn test_dial_ip_9() {
        let mut access = AccessMap::default();
        let line = r#"{"ctx":"access","cap":"net/connect","addr":"tcp6!::1!443"}"#;
        assert!(access.parse_json_line(line, u8::MAX).is_none());
        assert!(access.magic.contains_key("tcp6!::1!443"));
        assert!(access.lock_connect_ports.contains(443));
    }

    #[test]
    fn test_dial_ip_10() {
        let mut access = AccessMap::default();
        let line = r#"{"ctx":"access","cap":"net/bind","addr":"0.0.0.0!0"}"#;
        assert!(access.parse_json_line(line, u8::MAX).is_none());
        assert!(access.magic.contains_key("0.0.0.0!0"));
        assert!(access.lock_bind_ports.contains(0));
    }

    #[test]
    fn test_dial_ip_11() {
        let mut access = AccessMap::default();
        let line = r#"{"ctx":"access","cap":"net/connect","addr":"::1!8080"}"#;
        assert!(access.parse_json_line(line, u8::MAX).is_none());
        assert!(access.magic.contains_key("::1!8080"));
        assert!(access.lock_connect_ports.contains(8080));
    }

    #[test]
    fn test_dial_ip_12() {
        let mut access = AccessMap::default();
        let line = r#"{"ctx":"access","cap":"net/connect","addr":"tcp!127.0.0.1!4711"}"#;
        assert!(access.parse_json_line(line, u8::MAX).is_none());
        assert!(access.lock_connect_ports.contains(4711));
        assert!(!access.lock_bind_ports.contains(4711));
    }

    #[test]
    fn test_dial_ip_13() {
        let mut access = AccessMap::default();
        let line = r#"{"ctx":"block","cap":"net/connect","addr":"tcp!1.2.3.4!80"}"#;
        let _ = access.parse_json_line(line, u8::MAX);
        assert!(!access.magic.contains_key("tcp!1.2.3.4!80"));
        assert!(!access.lock_connect_ports.contains(80));
    }

    #[test]
    fn test_dial_ip_14() {
        assert_eq!(dial_ip("icmp!127.0.0.1!0"), Some("127.0.0.1"));
        assert_eq!(dial_ip("icmp4!127.0.0.1!0"), Some("127.0.0.1"));
        assert_eq!(dial_ip("icmp6!::1!0"), Some("::1"));
    }

    #[test]
    fn test_dial_ip_15() {
        let mut access = AccessMap::default();
        let line = r#"{"ctx":"access","cap":"net/bind","addr":"icmp4!127.0.0.1!0"}"#;
        assert!(access.parse_json_line(line, u8::MAX).is_none());
        assert!(access.magic.contains_key("icmp4!127.0.0.1!0"));
        assert!(!access.lock_bind_ports.contains(0));
    }

    #[test]
    fn test_dial_ip_16() {
        let mut access = AccessMap::default();
        let line = r#"{"ctx":"access","cap":"net/connect","addr":"icmp6!::1!0"}"#;
        assert!(access.parse_json_line(line, u8::MAX).is_none());
        assert!(access.magic.contains_key("icmp6!::1!0"));
        assert!(!access.lock_connect_ports.contains(0));
        assert!(!access.lock_bind_ports.contains(0));
    }
}
