//
// Syd: rock-solid application kernel
// src/utils/syd-pty.rs: Syd's PTY to STDIO bidirectional forwarder
//
// Copyright (c) 2025, 2026 Ali Polatel <alip@chesswob.org>
//
// SPDX-License-Identifier: GPL-3.0

use std::{
    env,
    os::fd::{AsFd, AsRawFd, FromRawFd, OwnedFd, RawFd},
    process::{exit, ExitCode},
};

use libseccomp::{scmp_cmp, ScmpAction, ScmpArch, ScmpFilterContext, ScmpSyscall};
use nix::{
    errno::Errno,
    fcntl::{fcntl, splice, FcntlArg, OFlag, SpliceFFlags},
    poll::PollTimeout,
    sched::{unshare, CloneFlags},
    sys::{
        epoll::{Epoll, EpollCreateFlags, EpollEvent, EpollFlags},
        resource::Resource,
        signal::{signal, sigprocmask, SigHandler, SigmaskHow, Signal},
        signalfd::{SfdFlags, SigSet, SignalFd},
        termios::{cfmakeraw, tcgetattr, tcsetattr, LocalFlags, OutputFlags, SetArg, Termios},
    },
    unistd::{chdir, chroot, pipe2},
};
use syd::{
    compat::{epoll_ctl_safe, set_dumpable},
    config::VDSO_SYSCALLS,
    confine::{
        confine_mdwe, confine_rlimit_zero, confine_scmp_madvise, extend_ioctl, CLONE_NEWTIME,
    },
    err::SydResult,
    fd::{close, closeexcept, set_exclusive, set_nonblock},
    ignore_signals,
    io::{winsize_get, winsize_set},
    ioctl::IoctlMap,
    landlock_policy::LandlockPolicy,
    rng::duprand,
    IgnoreSignalOpts,
};

// Set global allocator to GrapheneOS allocator.
#[cfg(all(
    not(coverage),
    not(feature = "prof"),
    not(target_os = "android"),
    target_page_size_4k,
    target_pointer_width = "64"
))]
#[global_allocator]
static GLOBAL: hardened_malloc::HardenedMalloc = hardened_malloc::HardenedMalloc;

// Set global allocator to tcmalloc if profiling is enabled.
#[cfg(feature = "prof")]
#[global_allocator]
static GLOBAL: tcmalloc::TCMalloc = tcmalloc::TCMalloc;

// This is from <linux/tty.h>
// libc does not export it...
const N_TTY_BUF_SIZE: usize = 4096;

// syd::config::PIPE_BUF may be larger...
const PIPE_BUF: usize = N_TTY_BUF_SIZE;

syd::main! {
    // Parse options.
    let opts = parse_options()?;

    // Ignore all signals except SIG{CHLD,KILL,STOP}.
    // This is used to ensure we can deny {rt_,}sigreturn(2) to mitigate SROP.
    ignore_signals(IgnoreSignalOpts::empty())?;

    // Close all file descriptors, except:
    // 1. Standard input, output, and error.
    // 2. The PID fd and the PTY fd passed by the Syd process.
    // Nothing can be done on closeexcept errors.
    // We do it early here so FD randomization doesn't effect performance.
    #[expect(clippy::cast_sign_loss)]
    {
        let fd1 = opts.fpid.as_raw_fd() as libc::c_uint;
        let fd2 = opts.fpty.as_raw_fd() as libc::c_uint;
        let _ = closeexcept(&if fd1 < fd2 {
            [0, 1, 2, fd1, fd2]
        } else {
            [0, 1, 2, fd2, fd1]
        });
    }

    let PtyOpts {
        fpid,
        fpty,
        is_debug: debug,
        ws_x,
        ws_y,
    } = opts;

    // SAFETY: Randomize pidfd for hardening.
    let fpid_fd = duprand(fpid.as_raw_fd(), OFlag::O_CLOEXEC)?;
    drop(fpid);
    let fpid = fpid_fd;

    // SAFETY: Randomize PTY fd for hardening.
    let fpty_fd = duprand(fpty.as_raw_fd(), OFlag::O_CLOEXEC)?;
    drop(fpty);
    let fpty = fpty_fd;

    // Create epoll instance.
    let epoll = Epoll::new(EpollCreateFlags::EPOLL_CLOEXEC)?;

    // SAFETY: Randomize the epoll fd for hardening.
    let epoll_fd = duprand(epoll.0.as_raw_fd(), OFlag::O_CLOEXEC)?;
    drop(epoll);
    let epoll = Epoll(epoll_fd);

    // Create zero-copy pipes for bidirectional splice(2).
    //
    // SAFETY: Randomize pipe fds for hardening.
    let (pipe_pty_rd, pipe_pty_wr) = {
        let (rd, wr) = pipe2(OFlag::O_DIRECT | OFlag::O_NONBLOCK | OFlag::O_CLOEXEC)?;
        let rd = duprand(rd.as_raw_fd(), OFlag::O_CLOEXEC)?;
        let wr = duprand(wr.as_raw_fd(), OFlag::O_CLOEXEC)?;
        (rd, wr)
    };
    let (pipe_std_rd, pipe_std_wr) = {
        let (rd, wr) = pipe2(OFlag::O_DIRECT | OFlag::O_NONBLOCK | OFlag::O_CLOEXEC)?;
        let rd = duprand(rd.as_raw_fd(), OFlag::O_CLOEXEC)?;
        let wr = duprand(wr.as_raw_fd(), OFlag::O_CLOEXEC)?;
        (rd, wr)
    };

    // SAFETY: Randomize stdio(3) fds for hardening.
    let fstd_rd = duprand(libc::STDIN_FILENO, OFlag::O_CLOEXEC)?;
    let fstd_wr = duprand(libc::STDOUT_FILENO, OFlag::O_CLOEXEC)?;

    // SAFETY: Set PTY to exclusive mode to harden against sniffing.
    set_exclusive(&fpty, true)?;

    // Set PTY fd non-blocking.
    set_nonblock(&fpty, true)?;

    // Set stdio(3) non-blocking.
    set_nonblock(&fstd_rd, true)?;
    set_nonblock(&fstd_wr, true)?;

    // SAFETY: Restore default handling for SIGWINCH
    // undoing the effect of syd::ignore_signals().
    unsafe { signal(Signal::SIGWINCH, SigHandler::SigDfl)? };

    // Block SIGWINCH and create signalfd.
    let mut mask = SigSet::empty();
    mask.add(Signal::SIGWINCH);
    sigprocmask(SigmaskHow::SIG_BLOCK, Some(&mask), None)?;

    // SAFETY: Randomize signal-fd for hardening.
    let fsig = {
        let fd = SignalFd::with_flags(&mask, SfdFlags::SFD_NONBLOCK | SfdFlags::SFD_CLOEXEC)?;
        duprand(fd.as_raw_fd(), OFlag::O_CLOEXEC).map(|fd| {
            // SAFETY: dup(3) returned duplicate of a valid signal fd.
            unsafe { SignalFd::from_owned_fd(fd) }
        })?
    };

    // Confine.
    // Print rules if SYD_PTY_RULES is set in the environment.
    let print = env::var_os("SYD_PTY_RULES").is_some();
    confine(fsig.as_raw_fd(), debug, print)?;

    // Refresh terminal settings.
    let tio = refresh_pty(&fstd_rd, &fpty)?;

    // Refresh window size.
    refresh_win(&fstd_rd, &fpty, ws_x, ws_y);

    // Close standard file descriptors
    // after randomization and rule printing.
    let _ = close(libc::STDIN_FILENO);
    let _ = close(libc::STDOUT_FILENO);
    let _ = close(libc::STDERR_FILENO);

    // Run the PTY forwarder.
    let result = run_pty_forwarder(
        &epoll,
        &fpid,
        &fpty,
        &fsig,
        &fstd_rd,
        &fstd_wr,
        &pipe_pty_rd,
        &pipe_pty_wr,
        &pipe_std_rd,
        &pipe_std_wr,
    );

    // Restore terminal settings at exit.
    tcsetattr(&fstd_rd, SetArg::TCSANOW, &tio)?;

    Ok(match result {
        Ok(_) => ExitCode::SUCCESS,
        Err(err) => ExitCode::from(err.errno().unwrap_or(Errno::ENOSYS) as i32 as u8),
    })
}

/// Run the PTY forwarder.
#[expect(clippy::too_many_arguments)]
fn run_pty_forwarder<
    F1: AsFd,
    F2: AsFd,
    F3: AsFd,
    F4: AsFd,
    F5: AsFd,
    F6: AsFd,
    F7: AsFd,
    F8: AsFd,
>(
    epoll: &Epoll,
    pid_fd: &F1,
    pty_fd: &F2,
    sig_fd: &SignalFd,
    std_rd: &F3,
    std_wr: &F4,
    pipe_pty_rd: &F5,
    pipe_pty_wr: &F6,
    pipe_std_rd: &F7,
    pipe_std_wr: &F8,
) -> SydResult<()> {
    // 1. Add PIDFd to epoll (becomes readable when process terminates).
    let event = libc::epoll_event {
        events: (EpollFlags::EPOLLET
            | EpollFlags::EPOLLIN
            | EpollFlags::EPOLLRDHUP
            | EpollFlags::EPOLLONESHOT)
            .bits() as u32,
        u64: pid_fd.as_fd().as_raw_fd() as u64,
    };
    epoll_ctl_safe(&epoll.0, pid_fd.as_fd().as_raw_fd(), Some(event))?;

    // 2. Add PTY main fd to epoll for read/write (not necessary to set EPOLL{ERR,HUP}).
    let event = libc::epoll_event {
        events: (EpollFlags::EPOLLET
            | EpollFlags::EPOLLIN
            | EpollFlags::EPOLLOUT
            | EpollFlags::EPOLLRDHUP)
            .bits() as u32,
        u64: pty_fd.as_fd().as_raw_fd() as u64,
    };
    epoll_ctl_safe(&epoll.0, pty_fd.as_fd().as_raw_fd(), Some(event))?;

    // 3. Add stdin fd to epoll read readiness (not necessary to set EPOLL{ERR,HUP}).
    let event = libc::epoll_event {
        events: (EpollFlags::EPOLLET | EpollFlags::EPOLLIN | EpollFlags::EPOLLRDHUP).bits() as u32,
        u64: std_rd.as_fd().as_raw_fd() as u64,
    };
    epoll_ctl_safe(&epoll.0, std_rd.as_fd().as_raw_fd(), Some(event))?;

    // 4. Add stdout fd to epoll write readiness (not necessary to set EPOLL{ERR,HUP}).
    let event = libc::epoll_event {
        events: (EpollFlags::EPOLLET | EpollFlags::EPOLLOUT | EpollFlags::EPOLLRDHUP).bits() as u32,
        u64: std_wr.as_fd().as_raw_fd() as u64,
    };
    epoll_ctl_safe(&epoll.0, std_wr.as_fd().as_raw_fd(), Some(event))?;

    // 5. Add signal fd to epoll read readiness (not necessary to set EPOLL{ERR,HUP}).
    let event = libc::epoll_event {
        events: (EpollFlags::EPOLLET | EpollFlags::EPOLLIN | EpollFlags::EPOLLRDHUP).bits() as u32,
        u64: sig_fd.as_fd().as_raw_fd() as u64,
    };
    epoll_ctl_safe(&epoll.0, sig_fd.as_fd().as_raw_fd(), Some(event))?;

    // TODO: MAX_EVENTS=1024 move to config.rs
    let mut events = [EpollEvent::empty(); 1024];
    loop {
        // Wait for events and handle EINTR.
        let n = match epoll.wait(&mut events, PollTimeout::NONE) {
            Ok(n) => n,
            Err(Errno::EINTR) => continue, // Retry if interrupted by a signal.
            Err(errno) => return Err(errno.into()),
        };

        let mut is_syd = false; // Handle Syd exited?
        'eventloop: for event in events.iter().take(n) {
            let fd = event.data() as RawFd;
            let mut event_flags = event.events();

            let is_inp = event_flags
                .contains(EpollFlags::EPOLLIN)
                .then(|| event_flags.remove(EpollFlags::EPOLLIN))
                .is_some();
            let is_out = event_flags
                .contains(EpollFlags::EPOLLOUT)
                .then(|| event_flags.remove(EpollFlags::EPOLLOUT))
                .is_some();
            let is_err = !event_flags.is_empty();

            if fd == pid_fd.as_fd().as_raw_fd() {
                // Syd exited, exit gracefully.
                is_syd = true;
                continue 'eventloop;
            }

            if is_inp && fd == sig_fd.as_raw_fd() {
                // Handle window resize event.
                loop {
                    let sig_info = match sig_fd.read_signal() {
                        Ok(Some(sig_info)) => {
                            // We caught a signal.
                            sig_info
                        }
                        Ok(None) => {
                            // No signals waiting.
                            continue 'eventloop;
                        }
                        Err(Errno::EINTR) => continue,
                        Err(errno) => return Err(errno.into()),
                    };

                    if sig_info.ssi_signo as i32 == Signal::SIGWINCH as i32 {
                        // Refresh window size.
                        refresh_win(std_rd, pty_fd, None, None);
                    }
                }
            }

            if is_inp {
                // Handle readable events.
                if fd == std_rd.as_fd().as_raw_fd() {
                    // splice from STDIN into PTY via pipe1.
                    splice_move(std_rd, pty_fd, pipe_pty_rd, pipe_pty_wr)?;
                } else if fd == pty_fd.as_fd().as_raw_fd() {
                    // splice from PTY into STDOUT via pipe2.
                    splice_move(pty_fd, std_wr, pipe_std_rd, pipe_std_wr)?;
                }
            }

            if is_out {
                // Handle writable events.
                if fd == std_wr.as_fd().as_raw_fd() {
                    // splice from pipe2 into STDOUT.
                    splice_pipe(pipe_std_rd, std_wr)?;
                } else if fd == pty_fd.as_fd().as_raw_fd() {
                    // splice from pipe1 into PTY.
                    splice_pipe(pipe_pty_rd, pty_fd)?;
                }
            }

            if is_err {
                // Drain other side on error.
                if fd == std_wr.as_fd().as_raw_fd() {
                    // splice from pipe1 into PTY.
                    splice_pipe(pipe_pty_rd, pty_fd)?;
                } else if fd == pty_fd.as_fd().as_raw_fd() {
                    // splice from pipe2 into STDOUT.
                    splice_pipe(pipe_std_rd, std_wr)?;
                }
            }
        }

        if is_syd {
            // Handle Syd exit gracefully.
            break;
        }
    }

    // Received EOF, flush remaining data.
    splice_move(std_rd, pty_fd, pipe_pty_rd, pipe_pty_wr)?;
    splice_move(pty_fd, std_wr, pipe_std_rd, pipe_std_wr)?;
    splice_pipe(pipe_std_rd, std_wr)?;
    splice_pipe(pipe_pty_rd, pty_fd)?;

    Ok(())
}

/// Transit this process to a confined state.
fn confine(sig_fd: RawFd, dry_run: bool, print_rules: bool) -> SydResult<()> {
    let mut ctx = new_filter(ScmpAction::KillProcess)?;

    let allow_call = [
        // can exit.
        "exit",
        "exit_group",
        // can handle signals limitedly.
        "sigaltstack",
        // can {{dr}e,}allocate memory.
        // mmap{,2} and mprotect are further confined to disable PROT_EXEC.
        "brk",
        //"madvise", advice are confined.
        "mremap",
        "munmap",
        // can close files.
        "close",
        // can do I/O with splice.
        "splice",
        // can use EPoll API,
        // can not create new EPoll FDs.
        "epoll_ctl",
        "epoll_wait",
        "epoll_pwait",
        "epoll_pwait2",
    ];

    // Default allowlist.
    for name in allow_call.iter().chain(VDSO_SYSCALLS) {
        if let Ok(syscall) = ScmpSyscall::from_name(name) {
            ctx.add_rule(ScmpAction::Allow, syscall)?;
        }
    }

    // Allow safe madvise(2) advice.
    confine_scmp_madvise(&mut ctx)?;

    // Allow read(2) to the signal fd only.
    #[expect(clippy::disallowed_methods)]
    let syscall = ScmpSyscall::from_name("read").unwrap();
    ctx.add_rule_conditional(
        ScmpAction::Allow,
        syscall,
        &[scmp_cmp!($arg0 == sig_fd as u64)],
    )?;

    // Allow ioctl(2) requests:
    // 1. TCGETS{,2}, aka tcgetattr(3)
    // 2. TCSETS{,2}, aka tcsetattr(3) with TCSANOW
    // 3. TIOCGWINSZ, aka winsize_get
    // 4. TIOCSWINSZ, aka winsize_set
    //
    // For *2, we use hardcoded versions because not all libcs define them.
    // They are portable as `struct termios2` has the same size
    // across 32-bit and 64-bit architectures.
    let arch = ScmpArch::native();
    let ioctl = IoctlMap::new(None, true);
    let names = [
        "TCGETS",
        "TCSETS",
        "TCGETS2",
        "TCSETS2",
        "TIOCGWINSZ",
        "TIOCSWINSZ",
    ];
    let mut iotty = Vec::with_capacity(names.len());
    for name in names {
        if let Some(req) = ioctl.get_value(name, arch) {
            #[allow(clippy::unnecessary_cast)]
            iotty.push(req as u64);
        }
    }

    #[expect(clippy::disallowed_methods)]
    let syscall = ScmpSyscall::from_name("ioctl").unwrap();
    for request in iotty {
        ctx.add_rule_conditional(ScmpAction::Allow, syscall, &[scmp_cmp!($arg1 == request)])?;
        if let Some(request) = extend_ioctl(request) {
            ctx.add_rule_conditional(ScmpAction::Allow, syscall, &[scmp_cmp!($arg1 == request)])?;
        }
    }

    // Allow safe fcntl(2) utility calls.
    for name in ["fcntl", "fcntl64"] {
        let syscall = match ScmpSyscall::from_name(name) {
            Ok(syscall) => syscall,
            Err(_) => continue,
        };

        for op in [libc::F_GETFD, libc::F_GETFL] {
            ctx.add_rule_conditional(ScmpAction::Allow, syscall, &[scmp_cmp!($arg1 == op as u64)])?;
        }
    }

    // Allow safe prctl(2) operations.
    let sysname = "prctl";
    if let Ok(syscall) = ScmpSyscall::from_name(sysname) {
        let op = libc::PR_SET_VMA as u64;
        ctx.add_rule_conditional(ScmpAction::Allow, syscall, &[scmp_cmp!($arg0 == op)])?;
    }

    // Prevent executable memory.
    const PROT_EXEC: u64 = libc::PROT_EXEC as u64;
    for name in ["mmap", "mmap2", "mprotect"] {
        #[expect(clippy::disallowed_methods)]
        let syscall = ScmpSyscall::from_name(name).unwrap();
        ctx.add_rule_conditional(
            ScmpAction::Allow,
            syscall,
            &[scmp_cmp!($arg2 & PROT_EXEC == 0)],
        )?;
    }

    // We will ignore unshare errors next step and here we keep
    // with the expectation that we're inside the safe directory.
    chdir("/proc/self/fdinfo")?;

    if !dry_run {
        // SAFETY: Default panic hook won't play well with seccomp.
        std::panic::set_hook(Box::new(|_| {}));

        // Set up namespace isolation for all available namespaces.
        // In addition we chroot into `/proc/self/fdinfo`.
        // Ignore errors as unprivileged userns may not be supported.
        let namespaces = CloneFlags::CLONE_NEWUSER
            | CloneFlags::CLONE_NEWCGROUP
            | CloneFlags::CLONE_NEWIPC
            | CloneFlags::CLONE_NEWNET
            | CloneFlags::CLONE_NEWNS
            | CloneFlags::CLONE_NEWPID
            | CloneFlags::CLONE_NEWUTS
            | CLONE_NEWTIME;
        if unshare(namespaces).is_ok() {
            chroot(".")?; // /proc/self/fdinfo.
            chdir("/")?; // prevent cwd leaking.
        }

        // Set up a Landlock sandbox:
        // Disallow all filesystem and network access.
        let abi = syd::landlock::ABI::new_current();
        let policy = LandlockPolicy {
            scoped_abs: true,
            scoped_sig: true,

            ..Default::default()
        };
        let _ = policy.restrict_self(abi);

        // Set up Memory-Deny-Write-Execute protections.
        // Ignore errors as PR_SET_MDWE may not be supported.
        let _ = confine_mdwe(false);

        // Set the process dumpable attribute to not-dumpable.
        let _ = set_dumpable(false);

        // Set nfiles, nprocs, and filesize rlimits to zero.
        // Set locks, memory lock and msgqueue rlimits to zero.
        // Set core dump file size to zero.
        confine_rlimit_zero(&[
            Resource::RLIMIT_CORE,
            Resource::RLIMIT_FSIZE,
            Resource::RLIMIT_NOFILE,
            Resource::RLIMIT_NPROC,
            Resource::RLIMIT_LOCKS,
            Resource::RLIMIT_MEMLOCK,
            Resource::RLIMIT_MSGQUEUE,
        ])?;
    }

    if print_rules {
        // Dump filter to standard error.
        eprintln!("# syd-pty rules");
        let _ = ctx.export_pfc(std::io::stderr());
    }

    if !dry_run {
        // All done, load seccomp filter and begin confinement.
        ctx.load()?;
    }

    Ok(())
}

fn new_filter(action: ScmpAction) -> SydResult<ScmpFilterContext> {
    let mut filter = ScmpFilterContext::new(action)?;

    // Enforce the NO_NEW_PRIVS functionality before
    // loading the seccomp filter into the kernel.
    filter.set_ctl_nnp(true)?;

    // Kill process for bad arch.
    filter.set_act_badarch(ScmpAction::KillProcess)?;

    // Use a binary tree sorted by syscall number, if possible.
    let _ = filter.set_ctl_optimize(2);

    Ok(filter)
}

// splice(2) helper
fn splice_data<Fd1: AsFd, Fd2: AsFd>(src: Fd1, dst: Fd2) -> Result<usize, Errno> {
    splice(
        src,
        None,
        dst,
        None,
        PIPE_BUF,
        SpliceFFlags::SPLICE_F_NONBLOCK | SpliceFFlags::SPLICE_F_MORE,
    )
}

fn splice_pipe<Fd1: AsFd, Fd2: AsFd>(src: Fd1, dst: Fd2) -> Result<(), Errno> {
    loop {
        return match splice_data(&src, &dst) {
            Ok(0) | Err(Errno::EAGAIN) => Ok(()),
            Ok(_) | Err(Errno::EINTR) => continue,
            Err(errno) => Err(errno),
        };
    }
}

fn splice_move<Fd1: AsFd, Fd2: AsFd, Fd3: AsFd, Fd4: AsFd>(
    src: Fd1,
    dst: Fd2,
    pipe_rd: Fd3,
    pipe_wr: Fd4,
) -> Result<bool, Errno> {
    loop {
        match splice_data(&src, &pipe_wr) {
            Ok(0) => return Ok(true),
            Ok(_) => splice_pipe(&pipe_rd, &dst)?,
            Err(Errno::EINTR) => {}
            Err(Errno::EAGAIN) => return Ok(false),
            Err(errno) => return Err(errno),
        }
    }
}

// Handle window resize propagation.
fn refresh_win<Fd1: AsFd, Fd2: AsFd>(
    src: Fd1,
    dst: Fd2,
    ws_x: Option<libc::c_ushort>,
    ws_y: Option<libc::c_ushort>,
) {
    if let Some(ws_row) = ws_x {
        if let Some(ws_col) = ws_y {
            let ws = libc::winsize {
                ws_row,
                ws_col,
                ws_xpixel: 0,
                ws_ypixel: 0,
            };
            let _ = winsize_set(&dst, ws);
            return;
        }
    }

    if let Ok(mut ws) = winsize_get(&src) {
        if let Some(ws_row) = ws_x {
            ws.ws_row = ws_row;
        }
        if let Some(ws_col) = ws_y {
            ws.ws_col = ws_col;
        }
        let _ = winsize_set(&dst, ws);
    }
}

// Handle terminal settings.
fn refresh_pty<Fd1: AsFd, Fd2: AsFd>(src: Fd1, dst: Fd2) -> Result<Termios, Errno> {
    let mut tio = tcgetattr(&src)?;

    // Inherit host terminal settings for PTY.
    tcsetattr(&dst, SetArg::TCSANOW, &tio)?;

    // Set raw mode for input TTY.
    // Disable background processes from writing.
    // Enable output processing.
    let orig_tio = tio.clone();
    cfmakeraw(&mut tio);
    tio.local_flags.insert(LocalFlags::TOSTOP);
    tio.output_flags.insert(OutputFlags::OPOST);
    tcsetattr(&src, SetArg::TCSANOW, &tio)?;

    Ok(orig_tio)
}

// Parse command line options.
struct PtyOpts {
    // -i pty-fd
    fpty: OwnedFd,

    // -p pid-fd
    fpid: OwnedFd,

    // -d
    // UNSAFE! Run in debug mode without confinement.
    is_debug: bool,

    // -x row-size
    ws_x: Option<libc::c_ushort>,

    // -y row-size
    ws_y: Option<libc::c_ushort>,
}

fn parse_options() -> SydResult<PtyOpts> {
    use lexopt::prelude::*;

    // Parse CLI options.
    let mut opt_fpid = None;
    let mut opt_fpty = None;
    let mut opt_ws_x = None;
    let mut opt_ws_y = None;

    // Skip confinement if SYD_PTY_DEBUG environment variable is set.
    // Another way to achieve the same is the `-d` CLI option.
    let mut opt_debug = env::var_os("SYD_PTY_DEBUG").is_some();

    let mut parser = lexopt::Parser::from_env();
    while let Some(arg) = parser.next()? {
        match arg {
            Short('h') => {
                help();
                exit(0);
            }
            Short('d') => opt_debug = true,
            Short('p') => opt_fpid = Some(parser.value()?.parse::<String>()?),
            Short('i') => opt_fpty = Some(parser.value()?.parse::<String>()?),
            Short('x') => {
                opt_ws_x = Some(
                    parser
                        .value()?
                        .parse::<String>()?
                        .parse::<libc::c_ushort>()?,
                )
            }
            Short('y') => {
                opt_ws_y = Some(
                    parser
                        .value()?
                        .parse::<String>()?
                        .parse::<libc::c_ushort>()?,
                )
            }
            _ => return Err(arg.unexpected().into()),
        }
    }

    let fpid = if let Some(fpid) = opt_fpid {
        // Parse file descriptor.
        let fpid = fpid.parse::<RawFd>()?;
        if fpid < 0 {
            return Err(Errno::EBADF.into());
        }

        // SAFETY: We will validate the FD below.
        let fpid = unsafe { OwnedFd::from_raw_fd(fpid) };

        // Validate file descriptor.
        // F_GETFD returns EBADF for bad-fd.
        fcntl(&fpid, FcntlArg::F_GETFD)?;

        fpid
    } else {
        eprintln!("Error: -p is required.");
        help();
        exit(1);
    };

    let fpty = if let Some(fpty) = opt_fpty {
        // Parse file descriptor.
        let fpty = fpty.parse::<RawFd>()?;
        if fpty < 0 {
            return Err(Errno::EBADF.into());
        }

        // SAFETY: We will validate the FD below.
        let fpty = unsafe { OwnedFd::from_raw_fd(fpty) };

        // Validate file descriptor.
        // F_GETFD returns EBADF for bad-fd.
        fcntl(&fpty, FcntlArg::F_GETFD)?;

        fpty
    } else {
        eprintln!("syd-pty: Error: -i is required.");
        help();
        exit(1);
    };

    Ok(PtyOpts {
        fpty,
        fpid,
        is_debug: opt_debug,
        ws_x: opt_ws_x,
        ws_y: opt_ws_y,
    })
}

fn help() {
    println!("Usage: syd-pty [-dh] [-x x-size] [-y y-size] -p <pid-fd> -i <pty-fd>");
    println!("Syd's PTY to STDIO bidirectional forwarder");
    println!("Forwards data between the given pty(7) main file descriptor and stdio(3).");
    println!("PID file descriptor is used to track the exit of Syd process.");
    println!("  -h             Print this help message and exit.");
    println!("  -d             Run in debug mode without confinement.");
    println!("  -p <pid-fd>    PID file descriptor of Syd process.");
    println!("  -i <pty-fd>    PTY main file descriptor.");
    println!("  -x <x-size>    Specify window row size (default: inherit).");
    println!("  -y <y-size>    Specify window column size (default: inherit).");
}
