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

use std::{
    cmp::Ordering,
    env,
    fmt::Write as FmtWrite,
    fs::{File, OpenOptions},
    io::{stdin, stdout, BufRead, BufReader, BufWriter, Read, Stdout, Write as IoWrite, Write},
    net::IpAddr,
    os::fd::AsRawFd,
    path::{Path, PathBuf},
    process::{exit, Command, ExitCode},
    str,
    sync::{atomic::Ordering as AtomicOrdering, LazyLock},
    thread,
};
#[cfg(feature = "force")]
use std::{
    fs::metadata,
    os::unix::ffi::OsStrExt,
    sync::{Arc, Mutex},
};

use console::style;
use indicatif::{MultiProgress, ProgressDrawTarget};
#[cfg(feature = "force")]
use indicatif::{ProgressBar, ProgressStyle};
use libc::pid_t;
use memchr::arch::all::is_equal;
#[cfg(feature = "force")]
use nix::fcntl::{openat2, AT_FDCWD};
use nix::{
    errno::Errno,
    fcntl::OFlag,
    sys::{
        signal::{kill, sigprocmask, SigmaskHow, Signal},
        signalfd::SigSet,
        stat::fstat,
    },
    unistd::{getcwd, pipe2, Gid, Pid, Uid, User},
};
use patricia_tree::StringPatriciaSet;
#[cfg(feature = "force")]
use rayon::{
    iter::{IntoParallelRefIterator, ParallelIterator},
    ThreadPoolBuilder,
};

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

pub(crate) static QUIET: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);

// Force sandboxing support of the running syd.
#[cfg(feature = "force")]
static SYD_HAVE_FORCE: LazyLock<bool> = LazyLock::new(crate::sys::syd_have_force);

// landlock(7) ABI version of the running kernel.
static LANDLOCK_ABI: LazyLock<Option<libc::c_long>> = LazyLock::new(landlock_abi);

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

mod cli;
mod cpu;
mod dns;
mod glob;
#[cfg(feature = "force")]
mod hasher;
mod log;
mod sys;

use crate::{
    cli::{parse_main_opts, print_help_main, print_version, InspectOpts, MainOpts, ProfileOpts},
    dns::{dial_ip, lookup_addr},
    glob::{
        consolidate_sibling_paths, dedup_paths, format_port_ranges, merge_lock_paths, reduce_magic,
        subst_id_with,
    },
    log::{Access, AccessMap},
    sys::{landlock_abi, set_cloexec, Syslog},
};
#[cfg(feature = "force")]
use crate::{
    glob::path2dehex,
    hasher::{hash, hex_encode},
    sys::safe_open_how,
};

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

const CAPSET_LPATH: &[&str] = &["walk", "list", "chdir", "readlink"];
const CAPSET_NPATH: &[&str] = &["notify", "stat"];
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"];
const CAPSET_INET: &[&str] = &["net/bind", "net/connect"];
const CAPSET_BNET: &[&str] = &["net/bind"];
const CAPSET_CNET: &[&str] = &["net/connect"];
const CAPSET_PASSFD: &[&str] = &["sendfd", "recvfd"];

const CAPSETS: &[(&str, &[&str])] = &[
    ("lpath", CAPSET_LPATH),
    ("npath", CAPSET_NPATH),
    ("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),
    ("passfd", CAPSET_PASSFD),
];

const CAP_ORDER: &[&str] = &[
    // aliases (keep before base)
    "lpath",
    "npath",
    "rpath",
    "wpath",
    "cpath",
    "dpath",
    "spath",
    "tpath",
    "fown",
    "fattr",
    "net",
    "inet",
    "bnet",
    "cnet",
    // 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",
    "sendfd",
    "recvfd",
    "passfd",
];

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

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

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

// Render profile rule sections.
fn render_rules(
    access: AccessMap,
    path_limit: u8,
    uid: &str,
    gid: &str,
    home: &str,
    cwd: &str,
    resolve_hosts: bool,
) -> Result<String, Errno> {
    let mut output = Vec::new();
    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(","))?;
    }

    // Convert paths to rule patterns.
    let reduced = reduce_magic(access.magic, path_limit);

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

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

    let len = list.len();
    let mut lastcap: Option<PandoraSet<String>> = None;
    for entry in &list {
        let raw_elem = &entry.0;
        let elem = subst_env_with(raw_elem, uid, gid, home, cwd);
        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 resolve_hosts && !['/', '@', '!'].iter().any(|&c| raw_elem.starts_with(c)) {
                if let Some(Ok(ip)) = dial_ip(raw_elem).map(|ip| ip.parse::<IpAddr>()) {
                    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 resolve_hosts && !['/', '@', '!'].iter().any(|&c| raw_elem.starts_with(c)) {
                if let Some(Ok(ip)) = dial_ip(raw_elem).map(|ip| ip.parse::<IpAddr>()) {
                    if let Ok(host) = lookup_addr(ip) {
                        wln!(&mut output, "# {host}")?;
                    }
                }
            }
            wln!(&mut output, "allow/cnet+{}", elem)?;
            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(|c| cap2prio(c));

        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) {
                    Ordering::Greater
                } else if ka.starts_with(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.
    let has_lock_rules = !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;
    if has_lock_rules && LANDLOCK_ABI.is_none() {
        let _ = wln!(
            stderr(),
            "{} skipped landlock(7) rules: Landlock is not supported by the running kernel.",
            style("pandora:").bold().magenta(),
        );
    } else if has_lock_rules {
        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(|a, b| a.0.cmp(&b.0));

        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_with(path, uid, gid, home, cwd);
            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(),
        );
    }

    String::from_utf8(output).or(Err(Errno::EINVAL))
}

// Longest accepted log line. Longer lines are skipped whole.
const MAX_LINE: usize = 1 << 20;
// Consecutive read errors tolerated before giving up.
const MAX_READ_ERRORS: u32 = 100;

// Feed complete lines to f, skipping oversized and undecodable ones.
// Returns (lines seen, lines skipped).
fn for_each_line<R: BufRead>(mut input: R, mut f: impl FnMut(&str)) -> (u64, u64) {
    let mut buf = Vec::new();
    let mut seen = 0u64;
    let mut skipped = 0u64;
    let mut errors = 0u32;
    let mut oversized = false;
    loop {
        buf.clear();
        match input
            .by_ref()
            .take(MAX_LINE as u64)
            .read_until(b'\n', &mut buf)
        {
            Ok(0) => break,
            Ok(_) => errors = 0,
            Err(_) => {
                errors += 1;
                if errors >= MAX_READ_ERRORS {
                    break;
                }
                continue;
            }
        }
        if oversized {
            if buf.last() == Some(&b'\n') || buf.len() < MAX_LINE {
                oversized = false;
            }
            continue;
        }
        if buf.last() != Some(&b'\n') && buf.len() >= MAX_LINE {
            seen += 1;
            skipped += 1;
            oversized = true;
            continue;
        }
        seen += 1;
        match std::str::from_utf8(&buf) {
            Ok(line) => f(line.trim_end_matches('\n')),
            Err(_) => skipped += 1,
        }
    }
    (seen, skipped)
}

fn command_profile(opts: ProfileOpts) -> Result<ExitCode, Errno> {
    if Path::new(&opts.output).exists() {
        let _ = wln!(
            stderr(),
            "{} error creating output file: `{}' already exists!",
            style("pandora:").bold().magenta(),
            style(opts.output.clone()).bold().yellow(),
        );
        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) => {
            let _ = wln!(
                stderr(),
                "{} error creating pipe: {}!",
                style("pandora:").bold().magenta(),
                style(error.to_string()).bold().red()
            );
            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 = fstat(&fd_rw).map(|st| st.st_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 = match syd.spawn() {
        Ok(child) => child,
        Err(err) => {
            let _ = wln!(
                stderr(),
                "{} failed to spawn syd: {}!",
                style("pandora:").bold().magenta(),
                style(err.to_string()).bold().red(),
            );
            return Err(err2no(err));
        }
    };

    // 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,
        #[cfg(feature = "force")]
        &opts.hash,
        opts.limit,
        #[cfg(feature = "force")]
        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,
        #[cfg(feature = "force")]
        &opts.hash,
        opts.limit,
        #[cfg(feature = "force")]
        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)),
    }
}

fn do_inspect(
    input: Box<dyn std::io::BufRead>,
    output_path: &str,
    #[cfg(feature = "force")] hash_function: &str,
    path_limit: u8,
    #[cfg(feature = "force")] concurrency: usize,
    extra_options: Option<Vec<String>>,
) -> Result<ExitCode, Errno> {
    let mut access = AccessMap {
        path_limit,
        ..AccessMap::default()
    };
    let mut output = match Output::create(output_path) {
        Ok(output) => output,
        Err(errno) => {
            let _ = wln!(
                stderr(),
                "{} failed to open output {}: {}!",
                style("pandora:").bold().magenta(),
                style(output_path).bold().cyan(),
                style(errno.to_string()).bold().red(),
            );
            return Err(errno);
        }
    };
    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;
    let mut program_exited = false;

    let (log_lines, log_skipped) = for_each_line(input, |line| {
        if let Some(json) = access.parse_json_line(line) {
            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;
                    program_exited = true;
                }
                _ => {}
            }
        }
    });

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

    // Record landlock(7) ABI version in the header if supported.
    let landlock = match *LANDLOCK_ABI {
        Some(abi) => format!("\n# LANDLOCK: ABI v{abi}"),
        None => String::new(),
    };

    // 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}
# LOG: {log_lines} lines, {log_skipped} skipped{landlock}\n"
    )?;
    if !program_exited {
        wln!(&mut output, "# INCOMPLETE: no exit record in the log\n")?;
        let _ = wln!(
            stderr(),
            "{} log has no exit record, profile may be incomplete.",
            style("pandora:").bold().magenta(),
        );
    }
    if log_skipped > 0 {
        let _ = wln!(
            stderr(),
            "{} skipped {} unparseable log lines.",
            style("pandora:").bold().magenta(),
            style(log_skipped.to_string()).bold().red(),
        );
    }
    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, "")?;
    }

    let uid = Uid::current().to_string();
    let gid = Gid::current().to_string();
    let home = env::var("HOME").unwrap_or_else(|_| match User::from_uid(Uid::current()) {
        Ok(Some(user)) => user.dir.to_string_lossy().into_owned(),
        _ => String::new(),
    });
    let cwd = getcwd()
        .ok()
        .and_then(|cwd| std::fs::canonicalize(cwd).ok())
        .map(|cwd| cwd.to_string_lossy().to_string())
        .unwrap_or_default();
    #[cfg(feature = "force")]
    let force = std::mem::take(&mut access.force);
    let rules = render_rules(access, path_limit, &uid, &gid, &home, &cwd, true)?;
    w!(&mut output, "{rules}")?;

    // Print Force entries if available, using concurrency-limited
    // parallel checksums and multiple progress bars.
    #[cfg(feature = "force")]
    if !force.is_empty() && !*SYD_HAVE_FORCE {
        let _ = wln!(
            stderr(),
            "{} skipped force rules: Force sandboxing is not supported by Syd.",
            style("pandora:").bold().magenta(),
        );
    } else if !force.is_empty() {
        wln!(&mut output, "\n###\n# Executable Verification\n###")?;

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

        let force: Vec<_> = 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.clamp(1, 512))
            .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()).unwrap_or(0);
            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.
        pool.install(|| {
            pbs.par_iter()
                .for_each(|(path, pb)| match path2force(path, func, pb) {
                    Ok(rule) => {
                        // Extract "algorithm:hex" after the last two ':'.
                        let data = match rule
                            .rfind(':')
                            .and_then(|h| rule[..h].rfind(':').map(|a| &rule[a + 1..]))
                        {
                            Some(data) => data,
                            None => rule.as_str(),
                        };

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

        for path in &force {
            let Some(rule) = rules.get(path) else {
                let _ = wln!(
                    stderr(),
                    "{} skipped force rule for {}.",
                    style("pandora:").bold().magenta(),
                    style(mask_path(path)).bold().cyan(),
                );
                continue;
            };
            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()
        );
    }

    output.commit()?;

    if !program_exited {
        return Ok(ExitCode::from(124));
    }
    Ok(ExitCode::from(program_exit_code))
}

// Used to perform path-based hashing in parallel with a progress bar.
#[cfg(feature = "force")]
fn path2force(path: &PathBuf, func: &str, pb: &ProgressBar) -> Result<String, Errno> {
    let ohow = safe_open_how(OFlag::O_RDONLY | OFlag::O_NOCTTY | OFlag::O_NONBLOCK);
    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" => {
            let _ = wln!(
                stderr(),
                "{} syslog is a bounded ring: old entries may be missing.",
                style("pandora:").bold().magenta(),
            );
            Box::new(BufReader::new(match Syslog::open() {
                Ok(syslog) => syslog,
                Err(err) => {
                    let _ = wln!(
                        stderr(),
                        "{} failed to access syslog: {}!",
                        style("pandora:").bold().magenta(),
                        style(err.to_string()).bold().red(),
                    );
                    exit(1);
                }
            }))
        }
        path => Box::new(BufReader::new(
            #[expect(clippy::disallowed_methods)]
            match OpenOptions::new().read(true).open(path) {
                Ok(file) => file,
                Err(err) => {
                    let _ = wln!(
                        stderr(),
                        "{} failed to open file {}: {}!",
                        style("pandora:").bold().magenta(),
                        style(path).bold().yellow(),
                        style(err.to_string()).bold().red(),
                    );
                    exit(1);
                }
            },
        )),
    }
}

enum Output {
    Stdout(BufWriter<Stdout>),
    File {
        writer: BufWriter<File>,
        tmp: PathBuf,
        path: PathBuf,
        done: bool,
    },
}

impl Output {
    fn create(output: &str) -> Result<Self, Errno> {
        if output == "-" {
            return Ok(Output::Stdout(BufWriter::new(stdout())));
        }
        let path = PathBuf::from(output);
        let tmp = PathBuf::from(format!("{output}.{}.tmp", std::process::id()));
        #[expect(clippy::disallowed_methods)]
        let file = OpenOptions::new()
            .write(true)
            .create_new(true)
            .open(&tmp)
            .map_err(err2no)?;
        Ok(Output::File {
            writer: BufWriter::new(file),
            tmp,
            path,
            done: false,
        })
    }

    fn commit(&mut self) -> Result<(), Errno> {
        match self {
            Output::Stdout(writer) => writer.flush().map_err(err2no),
            Output::File {
                writer,
                tmp,
                path,
                done,
            } => {
                writer.flush().map_err(err2no)?;
                writer.get_ref().sync_all().map_err(err2no)?;
                std::fs::rename(&*tmp, &*path).map_err(err2no)?;
                *done = true;
                Ok(())
            }
        }
    }
}

impl Write for Output {
    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
        match self {
            Output::Stdout(writer) => writer.write(buf),
            Output::File { writer, .. } => writer.write(buf),
        }
    }

    fn flush(&mut self) -> std::io::Result<()> {
        match self {
            Output::Stdout(writer) => writer.flush(),
            Output::File { writer, .. } => writer.flush(),
        }
    }
}

impl Drop for Output {
    fn drop(&mut self) {
        if let Output::File { tmp, done, .. } = self {
            if !*done {
                let _ = std::fs::remove_file(tmp);
            }
        }
    }
}

// Replace path prefixes and components with Syd environment variables.
// Handles: ${SYD_UID}, ${SYD_GID}, ${SYD_HOME}, ${SYD_PWD}.
fn subst_env_with(path: &str, uid: &str, gid: &str, home: &str, cwd: &str) -> String {
    let path = subst_id_with(path, uid, gid);
    if !home.is_empty() && home != "/" {
        if path == home {
            return "${SYD_HOME}".to_string();
        }
        if let Some(rest) = path.strip_prefix(home) {
            if rest.starts_with('/') {
                return format!("${{SYD_HOME}}{rest}");
            }
        }
    }
    if !cwd.is_empty() && cwd != "/" {
        if path == cwd {
            return "${SYD_PWD}".to_string();
        }
        if let Some(rest) = path.strip_prefix(cwd) {
            if rest.starts_with('/') {
                return format!("${{SYD_PWD}}{rest}");
            }
        }
    }
    path
}

// Masks an untrusted Path, escaping it as hex if it contains control
// characters.
#[cfg(feature = "force")]
fn mask_path(path: &Path) -> String {
    let (mask, _) = mask_untrusted_buf(path.as_os_str().as_bytes());
    mask
}

// Masks 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.
#[cfg(feature = "force")]
fn mask_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.
#[cfg(feature = "force")]
fn contains_ascii_unprintable(buf: &[u8]) -> bool {
    buf.iter().any(|byte| !is_ascii_printable(*byte))
}

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

// Converts capability to a priority number for sorting.
fn cap2prio(cap: &str) -> usize {
    let cap = cap.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
    }
}

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

pub(crate) fn err2no(err: std::io::Error) -> Errno {
    err.raw_os_error()
        .map(Errno::from_raw)
        .unwrap_or(Errno::EIO)
}

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

    #[test]
    fn test_subst_env_with_1() {
        assert_eq!(
            subst_env_with("/home/al/x", "0", "0", "/home/al", ""),
            "${SYD_HOME}/x"
        );
        assert_eq!(
            subst_env_with("/home/al", "0", "0", "/home/al", ""),
            "${SYD_HOME}"
        );
        assert_eq!(
            subst_env_with("/home/alip/x", "0", "0", "/home/al", ""),
            "/home/alip/x"
        );
        assert_eq!(
            subst_env_with("/srv/buildx/y", "0", "0", "", "/srv/build"),
            "/srv/buildx/y"
        );
        assert_eq!(
            subst_env_with("/srv/build/y", "0", "0", "", "/srv/build"),
            "${SYD_PWD}/y"
        );
    }

    #[test]
    fn test_output_1() {
        let path = std::env::temp_dir().join(format!("pandora_out1_{}", std::process::id()));
        let path = path.to_str().unwrap().to_string();
        let mut out = Output::create(&path).unwrap();
        wln!(&mut out, "allow/read+/etc/hostname").unwrap();
        out.commit().unwrap();
        let text = std::fs::read_to_string(&path).unwrap();
        assert_eq!(text, "allow/read+/etc/hostname\n");
        assert!(!std::path::Path::new(&format!("{path}.{}.tmp", std::process::id())).exists());
        std::fs::remove_file(&path).unwrap();
    }

    #[test]
    fn test_output_2() {
        let path = std::env::temp_dir().join(format!("pandora_out2_{}", std::process::id()));
        let path = path.to_str().unwrap().to_string();
        {
            let mut out = Output::create(&path).unwrap();
            wln!(&mut out, "partial").unwrap();
        }
        assert!(!std::path::Path::new(&path).exists());
        assert!(!std::path::Path::new(&format!("{path}.{}.tmp", std::process::id())).exists());
    }

    #[test]
    fn test_for_each_line_1() {
        let mut out = Vec::new();
        let input = b"one\ntwo\nthree";
        let (seen, skipped) = for_each_line(&input[..], |l| out.push(l.to_string()));
        assert_eq!(out, ["one", "two", "three"]);
        assert_eq!(seen, 3);
        assert_eq!(skipped, 0);
    }

    #[test]
    fn test_for_each_line_2() {
        let mut big = vec![b'a'; MAX_LINE * 3];
        big.push(b'\n');
        big.extend_from_slice(b"tail\n");
        let mut out = Vec::new();
        let (seen, skipped) = for_each_line(&big[..], |l| out.push(l.to_string()));
        assert_eq!(out, ["tail"]);
        assert_eq!(seen, 2);
        assert_eq!(skipped, 1);
    }

    #[test]
    fn test_for_each_line_3() {
        let input = b"ok\n\xff\xfe\nafter\n";
        let mut out = Vec::new();
        let (seen, skipped) = for_each_line(&input[..], |l| out.push(l.to_string()));
        assert_eq!(out, ["ok", "after"]);
        assert_eq!(seen, 3);
        assert_eq!(skipped, 1);
    }

    #[test]
    fn test_render_rules_1() {
        let mut access = AccessMap::default();
        let line = r#"{"ctx":"access","cap":"sendfd","path":"!file"}"#;
        access.parse_json_line(line);
        let line = r#"{"ctx":"access","cap":"net/connect","addr":"tcp!10.0.0.0/8!22"}"#;
        access.parse_json_line(line);
        let line = r#"{"ctx":"access","cap":"read","path":"/etc/hostname"}"#;
        access.parse_json_line(line);
        let out = render_rules(
            access,
            3,
            "1000",
            "100",
            "/home/user",
            "/home/user/work",
            false,
        )
        .unwrap();
        assert!(out.contains("allow/cnet+tcp!10.0.0.0/8!22\n"));
        assert!(out.contains("allow/read+/etc/hostname\n"));
        assert!(out.contains("allow/sendfd+!file\n"));
    }

    #[test]
    fn test_render_rules_2() {
        let mut access = AccessMap::default();
        for line in [
            r#"{"ctx":"access","cap":"create","path":"!pipe"}"#,
            r#"{"ctx":"access","cap":"sendfd","path":"!pipe"}"#,
            r#"{"ctx":"access","cap":"recvfd","path":"!pipe"}"#,
            r#"{"ctx":"access","cap":"sendfd","path":"!cdev"}"#,
            r#"{"ctx":"access","cap":"recvfd","path":"!cdev"}"#,
            r#"{"ctx":"access","cap":"create","path":"!memfd:x"}"#,
            r#"{"ctx":"access","cap":"sendfd","path":"!memfd:x"}"#,
        ] {
            access.parse_json_line(line);
        }
        let out = render_rules(
            access,
            3,
            "1000",
            "100",
            "/home/user",
            "/home/user/work",
            false,
        )
        .unwrap();
        assert!(out.contains("allow/create,passfd+!pipe\n"), "{out}");
        assert!(out.contains("allow/passfd+!cdev\n"), "{out}");
        assert!(out.contains("allow/create,sendfd+!memfd:"), "{out}");
        assert!(!out.contains("allow/sendfd+!pipe"), "{out}");
        assert!(!out.contains("allow/sendfd+!cdev"), "{out}");
        assert!(!out.contains("allow/recvfd+"), "{out}");
    }
}
