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

#![allow(clippy::disallowed_methods)]

use std::{
    borrow::Cow,
    collections::{HashMap, HashSet},
    env,
    ffi::{CStr, OsString},
    fmt,
    fs::{metadata, File, OpenOptions},
    hash::{Hash, Hasher},
    io::{self, stdin, BufRead, BufReader, Read, Write},
    iter::FromIterator,
    net::{IpAddr, SocketAddrV4, SocketAddrV6},
    os::{
        fd::AsRawFd,
        unix::ffi::{OsStrExt, OsStringExt},
    },
    path::{Path, PathBuf},
    process::{exit, Command, ExitCode},
    ptr,
    str::FromStr,
    sync::{Arc, Mutex},
    thread,
    time::Duration,
};

use ahash::RandomState;
use btoi::btoi;
use console::style;
use crc::{Crc, CRC_32_ISO_HDLC, CRC_64_ECMA_182};
use hex::{DisplayHex, FromHex};
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
use libc::{
    getnameinfo, pid_t, res_init, socklen_t, EAI_AGAIN, EAI_BADFLAGS, EAI_FAIL, EAI_FAMILY,
    EAI_MEMORY, EAI_NONAME, EAI_SERVICE, EAI_SOCKTYPE, EAI_SYSTEM, NI_MAXHOST, NI_NAMEREQD,
    NI_NUMERICSERV,
};
use memchr::arch::all::is_equal;
use nix::{
    errno::Errno,
    sys::{
        signal::{kill, sigprocmask, SigmaskHow, Signal},
        signalfd::SigSet,
        socket::{SockaddrLike, SockaddrStorage},
    },
    unistd::Pid,
};
use rayon::{
    iter::{IntoParallelRefIterator, ParallelIterator},
    ThreadPoolBuilder,
};
use serde::{
    de::{MapAccess, SeqAccess, Visitor},
    Deserialize, Deserializer, Serialize, Serializer,
};
use sha1::Sha1;
use sha3::{Digest, Sha3_256, Sha3_384, Sha3_512};

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");

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

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

impl Eq for Capability {}

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

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

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

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

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

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

    /// If Serde sees a sequence, we interpret that as `Capability::Some(HashSet<...>)`.
    fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
    where
        A: SeqAccess<'de>,
    {
        let mut set = HashSet::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 Access {
    Path {
        ctx: String,
        cap: Option<Capability>,
        path: String,
        args: Option<Vec<u64>>,
    },
    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 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 = Some(map.next_value()?);
                        }
                        "cap" => {
                            if cap.is_some() {
                                return Err(serde::de::Error::duplicate_field("cap"));
                            }
                            cap = Some(map.next_value()?);
                        }
                        "args" => {
                            if args.is_some() {
                                return Err(serde::de::Error::duplicate_field("args"));
                            }
                            args = Some(map.next_value()?);
                        }
                        "path" => {
                            if path.is_some() {
                                return Err(serde::de::Error::duplicate_field("path"));
                            }
                            path = Some(map.next_value()?);
                        }
                        "addr" => {
                            if addr.is_some() {
                                return Err(serde::de::Error::duplicate_field("addr"));
                            }
                            addr = Some(map.next_value()?);
                        }
                        "unix" => {
                            if unix_.is_some() {
                                return Err(serde::de::Error::duplicate_field("unix"));
                            }
                            unix_ = Some(map.next_value()?);
                        }
                        "cmd" => {
                            if cmd.is_some() {
                                return Err(serde::de::Error::duplicate_field("cmd"));
                            }
                            cmd = Some(map.next_value()?);
                        }
                        "argv" => {
                            if argv.is_some() {
                                return Err(serde::de::Error::duplicate_field("argv"));
                            }
                            argv = Some(map.next_value()?);
                        }
                        "time" => {
                            if time_.is_some() {
                                return Err(serde::de::Error::duplicate_field("time"));
                            }
                            time_ = Some(map.next_value()?);
                        }
                        "op" => {
                            if op.is_some() {
                                return Err(serde::de::Error::duplicate_field("op"));
                            }
                            op = Some(map.next_value()?);
                        }
                        "code" => {
                            if code.is_some() {
                                return Err(serde::de::Error::duplicate_field("code"));
                            }
                            code = Some(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,
                        path,
                        args,
                    })
                } 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)
    }
}

// lookup_addr is borrowed from Syd to avoid depending on Syd.
// syd::dns::lookup_addr:
// Performs a reverse DNS lookup for the given IP address, returning a hostname or an error.
#[allow(clippy::cast_possible_truncation)]
fn lookup_addr(addr: IpAddr) -> Result<String, Errno> {
    let addr = match addr {
        IpAddr::V4(v4) => SockaddrStorage::from(SocketAddrV4::new(v4, 0)),
        IpAddr::V6(v6) => SockaddrStorage::from(SocketAddrV6::new(v6, 0, 0, 0)),
    };
    let mut host_buf = [0i8; NI_MAXHOST as usize];

    // SAFETY: Initialize system DNS resolver.
    if unsafe { res_init() } != 0 {
        return Err(Errno::EFAULT);
    }

    // SAFETY: We call a system function (getnameinfo) with valid pointers for the address
    // and buffer, and we check the return value to ensure success before using `host_buf`.
    let ret = unsafe {
        getnameinfo(
            addr.as_ptr(),
            addr.len(),
            host_buf.as_mut_ptr(),
            host_buf.len() as socklen_t,
            ptr::null_mut(),
            0,
            NI_NAMEREQD | NI_NUMERICSERV,
        )
    };

    if ret != 0 {
        if ret == EAI_SYSTEM {
            return Err(Errno::last());
        } else {
            let e = match ret {
                EAI_AGAIN => Errno::EAGAIN,
                EAI_BADFLAGS => Errno::EINVAL,
                EAI_FAIL => Errno::EIO,
                EAI_FAMILY => Errno::EAFNOSUPPORT,
                EAI_MEMORY => Errno::ENOMEM,
                EAI_NONAME => Errno::ENOENT,
                EAI_SERVICE => Errno::EPROTONOSUPPORT,
                EAI_SOCKTYPE => Errno::ESOCKTNOSUPPORT,
                _ => Errno::EIO,
            };
            return Err(e);
        }
    }

    // SAFETY: On success, `host_buf` contains a valid null-terminated string.
    let cstr = unsafe { CStr::from_ptr(host_buf.as_ptr()) };
    let name = cstr.to_string_lossy().into_owned();
    Ok(name)
}
////

/// Defines hash functions supported by Syd.
///
/// Replicated from `syd::hash::HashAlgorithm` to avoid depending on Syd.
#[derive(Debug, Clone, Copy)]
pub enum HashAlgorithm {
    /// Crc32
    Crc32,
    /// Crc64
    Crc64,
    /// Md5
    Md5,
    /// SHA-1
    Sha1,
    /// SHA3-256
    Sha256,
    /// SHA3-384
    Sha384,
    /// SHA3-512
    Sha512,
}

impl FromStr for HashAlgorithm {
    type Err = Errno;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        Ok(match value {
            "sha3-512" => Self::Sha512,
            "sha3-384" => Self::Sha384,
            "sha3-256" => Self::Sha256,
            "sha1" => Self::Sha1,
            "md5" => Self::Md5,
            "crc64" => Self::Crc64,
            "crc32" => Self::Crc32,
            _ => return Err(Errno::EINVAL),
        })
    }
}

impl std::fmt::Display for HashAlgorithm {
    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
        let name = match self {
            Self::Sha512 => "sha3-512",
            Self::Sha384 => "sha3-384",
            Self::Sha256 => "sha2-256",
            Self::Sha1 => "sha1",
            Self::Md5 => "md5",
            Self::Crc64 => "crc64",
            Self::Crc32 => "crc32",
        };
        write!(f, "{name}")
    }
}

// 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 new() -> 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: HashAlgorithm,
    /// 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: HashAlgorithm,
    /// Path limit
    limit: u8,
    /// Threads
    threads: usize,
}

fn command_profile(opts: ProfileOpts) -> u8 {
    if Path::new(&opts.output).exists() {
        eprintln!(
            "{} error creating output file: `{}' already exists!",
            style("pandora:").bold().magenta(),
            style(format!("{}", opts.output)).bold().yellow(),
        );
        return 1;
    }

    let (fd_rd, fd_rw) = match nix::unistd::pipe() {
        Ok((fd_rd, fd_rw)) => (fd_rd, fd_rw),
        Err(error) => {
            eprintln!(
                "{} error creating pipe: {}!",
                style("pandora:").bold().magenta(),
                style(format!("{error}")).bold().red()
            );
            return 1;
        }
    };

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

    // Pass the pipe fd using SYD_LOG_FD.
    let log_fd = fd_rw.as_raw_fd().to_string();
    syd.env("SYD_LOG_FD", log_fd);

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

    // 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().expect("Syd command failed to start");

    // Block SIGINT in the parent process.
    let mut mask = SigSet::empty();
    mask.add(Signal::SIGINT);
    sigprocmask(SigmaskHow::SIG_BLOCK, Some(&mask), None).expect("Failed to block signals");

    if let Some(cmd_timeout) = opts.timeout {
        let pid = Pid::from_raw(child.id() as pid_t);
        thread::spawn(move || {
            thread::sleep(cmd_timeout);
            eprintln!(
                "{} {}",
                style("pandora:").bold().magenta(),
                style("Timeout expired, terminating process...")
                    .bold()
                    .yellow()
            );
            let _ = kill(pid, Signal::SIGKILL);
        });
    }

    drop(fd_rw); // close the write end of the pipe.
    let input = Box::new(BufReader::new(File::from(fd_rd)));
    let r = do_inspect(
        input,
        &opts.output,
        opts.hash,
        opts.limit,
        opts.threads,
        Some(opts.syd),
    );
    child.wait().expect("failed to wait for Syd");
    r
}

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

/// Main function, returns `lexopt::Error` on errors.
fn main() -> Result<ExitCode, lexopt::Error> {
    // If PANDORA_NPROC isn't set, default to num_cpus.
    if env::var_os("PANDORA_NPROC").is_none() {
        env::set_var("PANDORA_NPROC", num_cpus::get().to_string());
    }

    let opts = parse_main_opts()?;

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

/// Parse the top-level argument to see which subcommand (or help/version).
fn parse_main_opts<'a>() -> 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')) => Ok(MainOpts::Help),
        Some(Short('V')) => 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))
            }
            _ => return Err(arg.clone().unexpected()),
        },
        Some(arg) => return 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 = parse_env_str("PANDORA_HASH", b"sha3-512")
        .map_err(|errno| lexopt::Error::ParsingFailed {
            value: "PANDORA_HASH".to_string(),
            error: Box::new(errno),
        })?
        .parse::<HashAlgorithm>()
        .map_err(|errno| lexopt::Error::ParsingFailed {
            value: "PANDORA_HASH".to_string(),
            error: Box::new(errno),
        })?;
    let limit = parse_env_u8("PANDORA_LIMIT", 3).map_err(|errno| lexopt::Error::ParsingFailed {
        value: "PANDORA_LIMIT".to_string(),
        error: Box::new(errno),
    })?;
    let timeout = {
        let val = env::var_os("PANDORA_TIMEOUT").unwrap_or_default();
        if val.is_empty() {
            None
        } else {
            Some(
                parse_os_u64(&val)
                    .map(Duration::from_secs)
                    .map_err(|errno| lexopt::Error::ParsingFailed {
                        value: "PANDORA_TIMEOUT".to_string(),
                        error: Box::new(errno),
                    })?,
            )
        }
    };
    let threads = parse_env_usize("PANDORA_NPROC", num_cpus::get()).map_err(|errno| {
        lexopt::Error::ParsingFailed {
            value: "PANDORA_NPROC".to_string(),
            error: Box::new(errno),
        }
    })?;

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

    while let Some(arg) = parser.next()? {
        match arg {
            // -h => subcommand help
            Short('h') => {
                print_help_profile();
                std::process::exit(0);
            }
            // -V => version
            Short('V') => {
                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),
                    })?
                    .parse::<HashAlgorithm>()
                    .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()),
        }
    }

    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 = parse_env_str("PANDORA_HASH", b"sha3-512")
        .map_err(|errno| lexopt::Error::ParsingFailed {
            value: "PANDORA_HASH".to_string(),
            error: Box::new(errno),
        })?
        .parse::<HashAlgorithm>()
        .map_err(|errno| lexopt::Error::ParsingFailed {
            value: "PANDORA_HASH".to_string(),
            error: Box::new(errno),
        })?;
    let limit = parse_env_u8("PANDORA_LIMIT", 3).map_err(|errno| lexopt::Error::ParsingFailed {
        value: "PANDORA_LIMIT".to_string(),
        error: Box::new(errno),
    })?;
    let threads = parse_env_usize("PANDORA_NPROC", num_cpus::get()).map_err(|errno| {
        lexopt::Error::ParsingFailed {
            value: "PANDORA_NPROC".to_string(),
            error: Box::new(errno),
        }
    })?;

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

    while let Some(arg) = parser.next()? {
        match arg {
            Short('h') => {
                print_help_inspect();
                std::process::exit(0);
            }
            Short('V') => {
                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),
                    })?
                    .parse::<HashAlgorithm>()
                    .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()),
        }
    }

    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: HashAlgorithm,
    path_limit: u8,
    concurrency: usize,
    extra_options: Option<Vec<String>>,
) -> u8 {
    let mut output = open_output(output_path);
    let mut magic = HashMap::<String, HashSet<String, RandomState>, RandomState>::default();
    let mut force = HashSet::<String, RandomState>::default();
    let mut ioctl = HashSet::<u64, RandomState>::default();
    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) = parse_json_line(&line, &mut magic, &mut force, &mut ioctl, 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 = MultiProgress::new();
    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 { "!" },
    ))
    .unwrap();
    m.println(format!(
        "{} profile generation started.",
        style("pandora:").bold().magenta(),
    ))
    .unwrap();

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

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

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

    // Step 2: Print out all the sandbox rules from `magic`.
    writeln!(&mut output, "###\n# Sandbox Rules\n###").unwrap();
    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::<u64>()));
    // Sort reverse by Capability count.
    list.sort_by_key(|(_, caps)| std::cmp::Reverse(caps.iter().count()));

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

        if let Some(ref cap) = lastcap {
            if !cap.is_subset(&caps) {
                writeln!(&mut output, "").unwrap();
                lastcap = Some(caps.clone());
            }
        } else {
            lastcap = Some(caps.clone());
        }

        let mut done = false;
        if caps.contains("net/bind") {
            if ['/', '@', '!'].iter().any(|&c| elem.starts_with(c)) {
                // UNIX socket (domain, abstract or unnamed).
                writeln!(&mut output, "allow/net/bind+{}", elem).unwrap();
            } else {
                // IPv{4,6} address
                let ip = elem.splitn(2, '!').next().unwrap();
                let ip = ip.parse::<IpAddr>().unwrap_or_else(|e| {
                    panic!("Failed to parse IP address `{}': {}", ip, e);
                });
                if let Ok(host) = lookup_addr(ip) {
                    writeln!(&mut output, "# {host}").unwrap();
                }
                writeln!(&mut output, "allow/net/bind+{}", elem).unwrap();
            }
            done = true;
        }
        if caps.contains("net/connect") {
            if ['/', '@', '!'].iter().any(|&c| elem.starts_with(c)) {
                // UNIX socket (domain, abstract or unnamed).
                writeln!(&mut output, "allow/net/connect+{}", elem).unwrap();
            } else {
                let ip = elem.splitn(2, '!').next().unwrap();
                let ip = ip.parse::<IpAddr>().unwrap_or_else(|e| {
                    panic!("Failed to parse IP address `{}': {}", ip, e);
                });
                if let Ok(host) = lookup_addr(ip) {
                    writeln!(&mut output, "# {host}").unwrap();
                }
                writeln!(&mut output, "allow/net/connect+{}", elem).unwrap();
            }
            done = true;
        }
        if caps.contains("net/sendfd") {
            if ['/', '@', '!'].iter().any(|&c| elem.starts_with(c)) {
                // UNIX socket (domain, abstract or unnamed).
                writeln!(&mut output, "allow/net/sendfd+{elem}").unwrap();
            } else {
                unreachable!("BUG: invalid net/sendfd entry {:?}", entry);
            }
            caps.remove("net/sendfd");
        }

        if done {
            continue;
        }

        let mut caps = caps.into_iter().collect::<Vec<_>>();
        caps.sort_by_key(cap2prio);

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

    eprintln!(
        "{} generated {} rules.",
        style("pandora:").bold().magenta(),
        style(len.to_string()).bold().yellow(),
    );

    // Step 3: Print out all ioctl requests.
    if !ioctl.is_empty() {
        writeln!(&mut output, "\n###\n# Sandbox Ioctl Rules\n###").unwrap();
        writeln!(&mut output, "sandbox/ioctl:on").unwrap();

        // Collect all requests into a vector, then sort them.
        let mut requests: Vec<u64> = ioctl.iter().copied().collect();
        requests.sort_unstable();

        for request in requests {
            writeln!(&mut output, "ioctl/allow+{request:#x}").unwrap();
        }
    }

    // Step 4: Print Force entries if available,
    // concurrency-limited parallel checksums + multiple progress bars.
    if !force.is_empty() {
        writeln!(&mut output, "\n###\n# Executable Verification\n###").unwrap();
        writeln!(&mut output, "sandbox/force:on").unwrap();

        let force: Vec<_> = force.into_iter().collect();
        let mut force: Vec<PathBuf> = force
            .iter()
            .map(|s| path2dehex(s.as_str()))
            .map(PathBuf::from)
            .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()
            .unwrap();

        let pool = ThreadPoolBuilder::new()
            .num_threads(concurrency)
            .build()
            .expect("Failed to build thread pool");

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

        // Prepare progress bar style.
        let prefix_width = force_max + hash_function.to_string().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)
            .unwrap()
            .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()).unwrap();
            let pb = m.add(ProgressBar::new(len));
            pb.set_style(sty.clone());
            pb.set_prefix(format!(
                "{}({})",
                style(hash_function.to_string()).bold().blue(),
                style(path.display()).bold().yellow()
            ));
            pbs.push((path.clone(), pb));
        }

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

        // Spawn concurrency worker threads to do the hashing
        pool.install(|| {
            pbs.par_iter()
                .for_each(|(path, pb)| match path2force(&path, hash_function, &pb) {
                    Ok(rule) => {
                        let mut split = rule.splitn(2, ':');
                        split.next().unwrap();
                        let hash = split.next().unwrap();

                        pb.println(format!(
                            "{}({}) = {}",
                            style(hash_function.to_string()).bold().cyan(),
                            style(path.display()).bold().yellow(),
                            style(hash).bold().green(),
                        ));
                        pb.finish_and_clear();

                        {
                            let mut rules = rules.lock().unwrap();
                            rules.insert(path.clone(), rule);
                        }
                    }
                    Err(error) => {
                        pb.println(format!(
                            "{}({}) = {}",
                            style(hash_function.to_string()).bold().red(),
                            style(path.display()).bold().yellow(),
                            style(error).bold().red(),
                        ));
                        pb.finish_and_clear();
                    }
                });
        });

        drop(pool);
        let rules = rules.lock().unwrap();

        for path in &force {
            let rule = rules.get(path).unwrap();
            write!(&mut output, "\n{rule}").unwrap();
        }
        writeln!(&mut output, "").unwrap();

        eprintln!(
            "{} calculated {} checksums for {} executables.",
            style("pandora:").bold().magenta(),
            style(hash_function.to_string()).bold().cyan(),
            style(force_len.to_string()).bold().yellow(),
        );
    }

    eprintln!(
        "{} profile generation completed! \\o/",
        style("pandora:").bold().magenta(),
    );

    eprintln!(
        "{} profile has been written to `{}'.",
        style("pandora:").bold().magenta(),
        style(output_path).bold().yellow(),
    );

    eprintln!(
        "{} To use it, do: {} -P \"{}\" -- command args...",
        style("pandora:").bold().magenta(),
        style("syd").bold().green(),
        style(output_path).bold().yellow(),
    );

    program_exit_code
}

/// Used to perform path-based hashing in parallel with a progress bar.
fn path2force(path: &PathBuf, func: HashAlgorithm, pb: &ProgressBar) -> std::io::Result<String> {
    // We use CRC32 as defined in IEEE 802.3.
    let crc32 = Crc::<u32>::new(&CRC_32_ISO_HDLC);
    // We use CRC64 as defined in ECMA-182.
    let crc64 = Crc::<u64>::new(&CRC_64_ECMA_182);

    let mut hasher_state = match func {
        HashAlgorithm::Crc32 => HashState::Crc32(crc32.digest()),
        HashAlgorithm::Crc64 => HashState::Crc64(crc64.digest()),
        HashAlgorithm::Md5 => HashState::Md5(md5::Context::new()),
        HashAlgorithm::Sha1 => HashState::Sha1(Sha1::new()),
        HashAlgorithm::Sha256 => HashState::Sha3_256(Sha3_256::new()),
        HashAlgorithm::Sha384 => HashState::Sha3_384(Sha3_384::new()),
        HashAlgorithm::Sha512 => HashState::Sha3_512(Sha3_512::new()),
    };

    let mut file = File::open(path)?;
    let mut buffer = vec![0u8; 1024 * 1024];
    loop {
        let read_count = match file.read(&mut buffer) {
            Ok(0) => break,
            Ok(n) => n,
            Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
            Err(e) => return Err(e),
        };
        match &mut hasher_state {
            HashState::Crc32(d) => d.update(&buffer[..read_count]),
            HashState::Crc64(d) => d.update(&buffer[..read_count]),
            HashState::Md5(c) => c.consume(&buffer[..read_count]),
            HashState::Sha1(s) => s.update(&buffer[..read_count]),
            HashState::Sha3_256(s) => s.update(&buffer[..read_count]),
            HashState::Sha3_384(s) => s.update(&buffer[..read_count]),
            HashState::Sha3_512(s) => s.update(&buffer[..read_count]),
        }
        pb.inc(read_count as u64);
    }

    let digest = match hasher_state {
        HashState::Crc32(d) => d.finalize().to_be_bytes().to_vec(),
        HashState::Crc64(d) => d.finalize().to_be_bytes().to_vec(),
        HashState::Md5(c) => {
            let dg = c.compute();
            dg.0.to_vec()
        }
        HashState::Sha1(s) => s.finalize().to_vec(),
        HashState::Sha3_256(s) => s.finalize().to_vec(),
        HashState::Sha3_384(s) => s.finalize().to_vec(),
        HashState::Sha3_512(s) => s.finalize().to_vec(),
    };

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

/// Enum for incremental hashing.
enum HashState<'a> {
    Crc32(crc::Digest<'a, u32>),
    Crc64(crc::Digest<'a, u64>),
    Md5(md5::Context),
    Sha1(Sha1),
    Sha3_256(Sha3_256),
    Sha3_384(Sha3_384),
    Sha3_512(Sha3_512),
}

/// Parse each JSON line for relevant info.
#[allow(clippy::type_complexity)]
fn parse_json_line(
    line: &str,
    magic: &mut HashMap<String, HashSet<String, RandomState>, RandomState>,
    force: &mut HashSet<String, RandomState>,
    ioctl: &mut HashSet<u64, RandomState>,
    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.
    let line = line.trim();
    let line = if let Some(start) = line.find('{') {
        &line[start.saturating_sub(1)..]
    } else {
        return None;
    };

    // SAFETY: Skip lines that cannot be parsed.
    let json = if let Ok(json) = serde_json::from_str(line) {
        json
    } else {
        return None;
    };

    match json {
        Access::Path {
            ctx,
            cap,
            path,
            args,
            ..
        } if ctx == "access" => {
            let capabilities = match cap {
                None => return None,
                Some(Capability::One(cap)) => {
                    let mut caps = HashSet::<String, RandomState>::default();
                    caps.insert(cap);
                    caps
                }
                Some(Capability::Some(caps)) => caps,
            };

            if capabilities.contains("exec") {
                force.insert(path.clone());
            } else if capabilities.contains("ioctl") {
                if let Some(args) = args {
                    ioctl.insert(args[1]);
                }
            }

            let path = process_path(&path, path_limit).to_string();
            let pty = path == "/dev/pts/[0-9]*";
            magic
                .entry(path)
                .or_insert_with(HashSet::<String, RandomState>::default)
                .extend(capabilities);

            // Workaround for PTY listing.
            if pty {
                let mut caps = HashSet::<String, RandomState>::default();
                caps.insert("readdir".to_string());
                magic
                    .entry("/dev/pts".to_string())
                    .or_insert_with(HashSet::<String, RandomState>::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 = HashSet::<String, RandomState>::default();
                    caps.insert(cap);
                    caps
                }
                Some(Capability::Some(caps)) => caps,
            };

            // We override the path limit for UNIX sockets for clarity.
            let unix = process_path(&unix, u8::MAX).to_string();
            magic
                .entry(unix)
                .or_insert_with(HashSet::<String, RandomState>::default)
                .extend(capabilities);
        }
        Access::InetAddr { ctx, cap, addr, .. } if ctx == "access" => {
            let capabilities = match cap {
                None => return None,
                Some(Capability::One(cap)) => {
                    let mut caps = HashSet::<String, RandomState>::default();
                    caps.insert(cap);
                    caps
                }
                Some(Capability::Some(caps)) => caps,
            };
            magic
                .entry(addr)
                .or_insert_with(HashSet::<String, RandomState>::default)
                .extend(capabilities);
        }
        Access::Run { .. } | Access::Exit { .. } => return Some(json),
        _ => {}
    };

    None
}

/// Open either stdin, syslog(2) 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::new() {
            Ok(syslog) => syslog,
            Err(err) => {
                eprintln!(
                    "{} failed to access syslog: {}!",
                    style("pandora:").bold().magenta(),
                    style(format!("{err}")).bold().red(),
                );
                exit(1);
            }
        })),
        path => Box::new(BufReader::new(
            match OpenOptions::new().read(true).open(path) {
                Ok(file) => file,
                Err(err) => {
                    eprintln!(
                        "{} failed to open file {}: {}!",
                        style("pandora:").bold().magenta(),
                        style(format!("{path}")).bold().yellow(),
                        style(format!("{err}")).bold().red(),
                    );
                    exit(1);
                }
            },
        )),
    }
}

/// Open either stdout or a file for writing (in create_new mode).
fn open_output(path_or_stdout: &str) -> Box<dyn std::io::Write> {
    match path_or_stdout {
        "-" => Box::new(std::io::BufWriter::new(std::io::stdout())),
        path => Box::new(std::io::BufWriter::new(
            match OpenOptions::new().write(true).create_new(true).open(path) {
                Ok(file) => file,
                Err(err) => {
                    eprintln!(
                        "{} failed to open file {}: {}!",
                        style("pandora:").bold().magenta(),
                        style(format!("{path}")).bold().cyan(),
                        style(format!("{err}")).bold().red(),
                    );
                    exit(1);
                }
            },
        )),
    }
}

/// Apply the path limit or special-case transformations.
fn process_path<'a>(path: &'a str, limit: u8) -> Cow<'a, str> {
    if limit == 0 || path == "/" {
        Cow::Borrowed(path)
    } else if let Some(glob) = path2glob(path) {
        glob
    } else {
        let limit = limit as usize;
        let members: Vec<&str> = path.split('/').filter(|&x| !x.is_empty()).collect();
        if limit > 0 && limit < members.len() {
            format!("/{}/***", members[0..limit].join("/")).into()
        } else {
            format!("/{}", members.join("/")).into()
        }
    }
}

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

/// 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().nth(0), Some('/') | Some('@') | Some('!')) {
        // SAFETY: hex-encoded untrusted path, return as is.
        return Some(Cow::Borrowed(path));
    }
    // SAFETY: Path is valid UTF-8.
    let path = path2dehex(path);
    let path = path.to_string_lossy();
    let components: Vec<&str> = path.split('/').collect();
    let mut new_path = String::new();
    let mut handled = false;

    if path.starts_with("/proc/") {
        if components.len() >= 3 && components[2].chars().all(char::is_numeric) {
            if components.len() > 4
                && components[4].chars().all(char::is_numeric)
                && components[3] == "task"
            {
                // Handle the /proc/$pid/task/$tid/... case
                let rest_of_path = if components.len() > 5 {
                    format!("/{}", components[5..].join("/"))
                } else {
                    String::new()
                };
                new_path = format!("/proc/[0-9]*/task/[0-9]*{}", rest_of_path);
                handled = true;

                // Specifically handle the /proc/$pid/task/$tid/{fd,ns}/... cases.
                if components.len() > 5 && components[5] == "fd" {
                    let fd_rest_of_path = if components.len() > 6 {
                        format!("/{}", components[6..].join("/"))
                    } else {
                        String::new()
                    };
                    new_path = format!("/proc/[0-9]*/task/[0-9]*/fd{}", fd_rest_of_path);
                } else if components.len() > 5 && components[5] == "ns" {
                    let ns_rest_of_path = if components.len() > 6 {
                        format!("/{}", components[6..].join("/"))
                    } else {
                        String::new()
                    };
                    new_path = format!("/proc/[0-9]*/task/[0-9]*/ns{}", ns_rest_of_path);
                }
            } else {
                // Handle the general /proc/$pid/... case
                let rest_of_path = if components.len() > 3 {
                    format!("/{}", components[3..].join("/"))
                } else {
                    String::new()
                };
                new_path = format!("/proc/[0-9]*{}", rest_of_path);
                handled = true;

                // Specifically handle the /proc/$pid/{fd,ns}/... cases.
                if components.len() > 3 && components[3] == "fd" {
                    let fd_rest_of_path = if components.len() > 4 {
                        format!("/{}", components[4..].join("/"))
                    } else {
                        String::new()
                    };
                    new_path = format!("/proc/[0-9]*/fd{}", fd_rest_of_path);
                } else if components.len() > 3 && components[3] == "ns" {
                    let ns_rest_of_path = if components.len() > 4 {
                        format!("/{}", components[4..].join("/"))
                    } else {
                        String::new()
                    };
                    new_path = format!("/proc/[0-9]*/ns{}", ns_rest_of_path);
                }
            }
        }

        // Further handle /{fd,ns}/... parts.
        if new_path.contains("/fd/") || new_path.contains("/ns/") {
            let mut final_path = String::new();
            let fd_components: Vec<&str> = new_path.split('/').collect();
            for (i, component) in fd_components.iter().enumerate() {
                if i > 0 {
                    final_path.push('/');
                }
                if i == fd_components.len() - 1 && component.chars().all(char::is_numeric) {
                    // Convert numeric fd/ns component to [0-9]*.
                    final_path.push_str("[0-9]*");
                } else if component.contains(':') {
                    // Handle foo:[number] pattern
                    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) {
                            final_path.push_str(&format!("{}:[0-9]*", parts[0]));
                            continue;
                        }
                    }
                    final_path.push_str(component);
                } else {
                    final_path.push_str(component);
                }
            }
            return Some(final_path.into());
        }
    }

    if handled {
        return Some(new_path.into());
    }

    // Handle memory file descriptors.
    if path.starts_with("/memfd:") {
        return Some(Cow::Borrowed("/memfd:**"));
    }

    // Handle /dev/pts/[number] case
    if path.starts_with("/dev/pts/") {
        if path
            .chars()
            .nth("/dev/pts/".len())
            .map(|c| c.is_numeric())
            .unwrap_or(false)
        {
            return Some(Cow::Borrowed("/dev/pts/[0-9]*"));
        } else {
            return None;
        }
    }

    // Handle /dev/tty case
    if path == "/dev/tty" {
        return Some(Cow::Borrowed("/dev/tty"));
    } else if path.starts_with("/dev/tty") {
        return Some(Cow::Borrowed("/dev/tty*"));
    }

    // Handle CUDA abstract sockets:
    //
    // e.g. @cuda-uvmfd--1-63797 -> @cuda-uvmfd--*
    if let Some(dashdash_pos) = path.rfind("--") {
        let after = &path[dashdash_pos + 2..];
        if !after.is_empty()
            && after
                .chars()
                .all(|c| c.is_ascii_digit() || c.is_ascii_punctuation())
        {
            let path = format!("{}--*", &path[..dashdash_pos]);
            return Some(Cow::Owned(path));
        }
    }

    // Return None if no cases match
    None
}

/// Logs an untrusted Path, escaping it as hex if it contains control
/// characters.
#[inline]
pub 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.
pub fn log_untrusted_buf(buf: &[u8]) -> (String, bool) {
    if contains_ascii_unprintable(buf) {
        (buf.to_lower_hex_string(), true)
    } else if let Ok(s) = std::str::from_utf8(buf) {
        (s.to_string(), false)
    } else {
        (buf.to_lower_hex_string(), true)
    }
}

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

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

/// Convers capability to a priority number for sorting.
#[inline]
pub fn cap2prio(cap: &String) -> u64 {
    let cap = cap.as_str().as_bytes();
    if is_equal(cap, b"stat") {
        0
    } else if is_equal(cap, b"read") {
        1
    } else if is_equal(cap, b"write") {
        2
    } else if is_equal(cap, b"exec") {
        3
    } else if is_equal(cap, b"ioctl") {
        4
    } else if is_equal(cap, b"create") {
        5
    } else if is_equal(cap, b"delete") {
        6
    } else if is_equal(cap, b"rename") {
        7
    } else if is_equal(cap, b"symlink") {
        8
    } else if is_equal(cap, b"truncate") {
        9
    } else if is_equal(cap, b"chdir") {
        10
    } else if is_equal(cap, b"readdir") {
        11
    } else if is_equal(cap, b"mkdir") {
        12
    } else if is_equal(cap, b"chown") {
        13
    } else if is_equal(cap, b"chgrp") {
        14
    } else if is_equal(cap, b"chmod") {
        15
    } else if is_equal(cap, b"chattr") {
        16
    } else if is_equal(cap, b"chroot") {
        17
    } else if is_equal(cap, b"utime") {
        18
    } else if is_equal(cap, b"mkdev") {
        20
    } else if is_equal(cap, b"mkfifo") {
        21
    } else if is_equal(cap, b"mktemp") {
        22
    } else if is_equal(cap, b"net/bind") {
        22
    } else if is_equal(cap, b"net/connect") {
        23
    } else if is_equal(cap, b"net/sendfd") {
        24
    } else {
        u64::MAX // new/unknown capability
    }
}

fn print_help_main() {
    let nproc = env::var("PANDORA_NPROC").unwrap();
    eprint!(
        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:
                  sha3-512 (default), sha3-384, sha3-256, sha1, md5, crc64, crc32
  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.exherbolinux.org
Repository: https://gitlab.exherbo.org/sydbox/
"#,
    );
}

fn print_help_profile() {
    let nproc = env::var("PANDORA_NPROC").unwrap();
    eprint!(
        r#"{PKG_NAME} {PKG_VERSION}
Profile subcommand

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

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

fn print_help_inspect() {
    let nproc = env::var("PANDORA_NPROC").unwrap();
    eprint!(
        r#"{PKG_NAME} {PKG_VERSION}
Inspect subcommand

Usage: {PKG_NAME} inspect [OPTIONS]

Options:
  -h           Print help
  -V           Print version
  -i <input>   Path to Syd access violation logs, use "-" for standard input, "syslog" for syslog(2) [default: -]
  -o <output>  Path to Syd profile output, use "-" for standard output [default: ./pandora_out.syd-3, env:PANDORA_OUT]
  -l <limit>   Maximum number of path members before trim, 0 to disable [default: 3, env:PANDORA_LIMIT]
  -H <hash>    Hash algorithm: [env:PANDORA_HASH]
               sha3-512 (default), sha3-384, sha3-256, sha1, md5, crc64, crc32
  -T <threads> Number of concurrency threads used for parallel hashing [default: {nproc}, env:PANDORA_NPROC]
"#,
    );
}

fn print_version() {
    eprintln!("{PKG_NAME}-{PKG_VERSION}");
}

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(_) => {
            eprintln!("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)
}
