//
// Syd: rock-solid application kernel
// src/pty.rs: PTY utilities
//
// Copyright (c) 2026 Ali Polatel <alip@chesswob.org>
//
// SPDX-License-Identifier: GPL-3.0

//! Set of functions to manage pseudoterminals

use std::{
    env,
    os::{
        fd::{AsFd, AsRawFd, FromRawFd, OwnedFd, RawFd},
        unix::{ffi::OsStrExt, process::CommandExt},
    },
    process::{Command, Stdio},
};

use libc::{syscall, SYS_ioctl};
use memchr::arch::all::is_equal;
use nix::{
    errno::Errno,
    fcntl::OFlag,
    pty::{grantpt, unlockpt, PtyMaster, Winsize},
    sys::stat::{umask, Mode},
    unistd::Pid,
};

use crate::{
    compat::{openat2, set_name, OpenHow, ResolveFlag, TIOCGPTPEER},
    err::{err2no, SydResult},
    error,
    fd::{is_dev_ptmx, pidfd_open, set_cloexec, AT_BADFD},
    info,
    retry::retry_on_eintr,
    warn,
};

/// Given the main PTY device returns a FD to the peer PTY.
///
/// This is safer than using open(2) on the return value of ptsname(3).
pub fn openpts<Fd: AsFd>(fd: Fd, flags: OFlag) -> Result<OwnedFd, Errno> {
    let fd = fd.as_fd().as_raw_fd();
    let flags = flags.bits();

    // SAFETY: In libc we trust.
    #[expect(clippy::cast_possible_truncation)]
    Errno::result(unsafe { syscall(SYS_ioctl, fd, TIOCGPTPEER, flags) }).map(|fd| {
        // SAFETY: TIOCGPTPEER returns a valid fd on success.
        unsafe { OwnedFd::from_raw_fd(fd as RawFd) }
    })
}

/// Open the PTY device.
pub fn openpt(flags: OFlag) -> Result<PtyMaster, Errno> {
    // 1. This function is called early at startup before proc_init,
    //    so we cannot use safe_open with RESOLVE_BENEATH.
    // 2. `/dev/ptmx` may be a symbolic link to `/dev/pts/ptmx`,
    //    so we cannot use safe_open_abs with RESOLVE_NO_SYMLINKS.
    //    This is the case on Gentoo Linux.
    // 3. We cannot directly open `/dev/pts/ptmx` either,
    //    because we may not have sufficient permissions.
    //    This is the case on Arch Linux and Fedora Linux.
    let how = OpenHow::new()
        .flags(flags)
        .resolve(ResolveFlag::RESOLVE_NO_MAGICLINKS);
    #[expect(clippy::disallowed_methods)]
    let fd = retry_on_eintr(|| openat2(AT_BADFD, c"/dev/ptmx", how))?;

    // SAFETY: Validate what we've opened is indeed `/dev/ptmx`.
    // This guard against potential symlink issues.
    if !is_dev_ptmx(&fd).unwrap_or(false) {
        return Err(Errno::ENODEV);
    }

    // SAFETY: fd is a valid PTY device.
    Ok(unsafe { PtyMaster::from_owned_fd(fd) })
}

/// Get window-size from the given FD.
pub fn winsize_get<Fd: AsFd>(fd: Fd) -> Result<Winsize, Errno> {
    let fd = fd.as_fd().as_raw_fd();
    let mut ws = Winsize {
        ws_row: 0,
        ws_col: 0,
        ws_xpixel: 0,
        ws_ypixel: 0,
    };

    // SAFETY: In libc we trust.
    Errno::result(unsafe { syscall(SYS_ioctl, fd, libc::TIOCGWINSZ, &mut ws) })?;

    Ok(ws)
}

/// Set window-size for the given FD.
pub fn winsize_set<Fd: AsFd>(fd: Fd, ws: Winsize) -> Result<(), Errno> {
    let fd = fd.as_fd().as_raw_fd();

    // SAFETY: In libc we trust.
    Errno::result(unsafe { syscall(SYS_ioctl, fd, libc::TIOCSWINSZ, &ws) }).map(drop)
}

/// Set up PTY sandboxing.
#[expect(clippy::cognitive_complexity)]
pub fn pty_setup(
    pty_ws_x: Option<libc::c_ushort>,
    pty_ws_y: Option<libc::c_ushort>,
    pty_debug: bool,
) -> SydResult<OwnedFd> {
    // TIP to be used in logging.
    const TIP: &str = "put syd-pty(1) in PATH, set CARGO_BIN_EXE_syd-pty, or set sandbox/pty:off";

    // Create a PIDFd of this process and clear O_CLOEXEC.
    // PIDFD_NONBLOCK is equivalent to O_NONBLOCK,
    // we use the latter because bionic libc doesn't define former yet.
    #[expect(clippy::cast_sign_loss)]
    let pidfd = pidfd_open(Pid::this(), OFlag::O_NONBLOCK.bits() as u32).inspect_err(|errno| {
        error!("ctx": "setup_pty", "op": "pidfd_open",
                "msg": format!("syd-pty pidfd_open error: {errno}"),
                "tip": TIP, "err": *errno as i32);
    })?;
    set_cloexec(&pidfd, false)?;

    // Open main pseudoterminal device and clear O_CLOEXEC.
    let pty_main = openpt(OFlag::O_RDWR | OFlag::O_NOCTTY).inspect_err(|errno| {
        error!("ctx": "setup_pty", "op": "openpt",
                "msg": format!("syd-pty openpt error: {errno}"),
                "tip": TIP, "err": *errno as i32);
    })?;
    set_cloexec(&pty_main, false)?;

    // Grant access to PTY and unlock.
    grantpt(&pty_main)?;
    unlockpt(&pty_main)?;

    // Open peer device.
    // We are going to pass this end to the sandbox process.
    // This uses TIOCGPTPEER ioctl(2) so O_NOFOLLOW is not needed.
    let pty_peer = openpts(
        &pty_main,
        OFlag::O_RDWR | OFlag::O_NOCTTY | OFlag::O_CLOEXEC,
    )
    .inspect_err(|errno| {
        error!("ctx": "setup_pty", "op": "openpts",
                "msg": format!("syd-pty openpts error: {errno}"),
                "tip": TIP, "err": *errno as i32);
    })?;

    // Prepare environment of the syd-pty process.
    // Filter the environment variables to only include the list below:
    // 1. PATH
    // 2. LD_LIBRARY_PATH
    // 3. SYD_PTY_RULES
    // We do not need to pass SYD_PTY_DEBUG because we use -d as needed.
    let safe_env: &[&[u8]] = &[b"PATH", b"LD_LIBRARY_PATH", b"SYD_PTY_RULES"];

    // Spawn syd-pty process, and pass PTY main end to it.
    let mut cmd = if let Some(cmd) = env::var_os("CARGO_BIN_EXE_syd-pty") {
        Command::new(cmd)
    } else {
        Command::new("syd-pty")
    };
    cmd.stdin(Stdio::inherit());
    cmd.stdout(Stdio::inherit());
    cmd.env_clear();
    cmd.envs(
        env::vars_os().filter(|(key, _)| safe_env.iter().any(|env| is_equal(key.as_bytes(), env))),
    );
    if pty_debug {
        cmd.arg("-d");
        cmd.stderr(Stdio::inherit());
    } else {
        cmd.stderr(Stdio::null());
    }
    let mut buf = itoa::Buffer::new();
    cmd.arg("-p");
    cmd.arg(buf.format(pidfd.as_raw_fd()));
    cmd.arg("-i");
    cmd.arg(buf.format(pty_main.as_raw_fd()));
    if let Some(ws) = pty_ws_x {
        cmd.arg("-x");
        cmd.arg(buf.format(ws));
    }
    if let Some(ws) = pty_ws_y {
        cmd.arg("-y");
        cmd.arg(buf.format(ws));
    }
    // SAFETY:
    // Set umask(2) to a sane value.
    // To let syd-pty(1) restore terminal at exit:
    // 1. Do not set parent-death-signal.
    // 2. Do not call setsid(2).
    unsafe {
        cmd.pre_exec(|| {
            let _ = set_name(c"syd_pty");
            umask(Mode::from_bits_truncate(0o777));
            Ok(())
        })
    };
    cmd.spawn().inspect_err(|error| {
        let errno = err2no(error);
        error!("ctx": "setup_pty", "op": "spawn",
            "msg": format!("syd-pty spawn error: {error}"),
            "tip": TIP, "err": errno as i32);
    })?;
    drop(pidfd);
    drop(pty_main);
    if pty_debug {
        warn!("ctx": "setup_pty", "op": "forward_tty",
            "msg": "syd-pty is now forwarding terminal I/O");
    } else {
        info!("ctx": "setup_pty", "op": "forward_tty",
            "msg": "syd-pty is now forwarding terminal I/O");
    }

    // Pass the other end of the PTY pair to the sandbox process.
    Ok(pty_peer)
}
