syd 3.58.0

rock-solid application kernel
Documentation
//
// 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::{
    os::fd::{AsFd, AsRawFd, FromRawFd, RawFd},
    panic::{catch_unwind, AssertUnwindSafe},
};

use libc::{c_ushort, syscall, SYS_ioctl, O_CLOEXEC, O_NOCTTY, O_RDWR, TIOCGWINSZ, TIOCSWINSZ};
use nix::{
    errno::Errno,
    fcntl::OFlag,
    pty::{grantpt, unlockpt, PtyMaster, Winsize},
    sys::signal::Signal,
};

use crate::{
    bins::{
        pty::{pty_bin_run, PtyBinOpts},
        SYD_PID_PTY,
    },
    compat::{openat2, set_pdeathsig, OpenHow, ResolveFlag, TIOCGPTPEER},
    cookie::safe_exit_group,
    err::SydResult,
    error,
    fd::{is_dev_ptmx, SafeOwnedFd, AT_BADFD},
    info,
    retry::retry_on_eintr,
    warn, xfmt,
};

/// 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<SafeOwnedFd, Errno> {
    let fd = fd.as_fd().as_raw_fd();
    let flags = flags.bits();

    // SAFETY: `fd` is a valid open PTY fd from `AsFd`;
    // `TIOCGPTPEER` is a valid ioctl request; `flags` are
    // open(2) flags. Kernel validates all arguments.
    Errno::result(unsafe { syscall(SYS_ioctl, fd, TIOCGPTPEER, flags) }).map(|fd| {
        // SAFETY: TIOCGPTPEER returns a valid fd on success.
        unsafe { SafeOwnedFd::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))?;

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

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

/// 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: `fd` is a valid open fd from `AsFd`;
    // `ws` is a valid, writable `Winsize` pointer.
    Errno::result(unsafe { syscall(SYS_ioctl, fd, 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: `fd` is a valid open fd from `AsFd`;
    // `ws` is a valid, readable `Winsize` reference.
    Errno::result(unsafe { syscall(SYS_ioctl, fd, TIOCSWINSZ, &ws) }).map(drop)
}

/// Set up PTY sandboxing.
///
/// # Safety
///
/// This function calls fork(2) and isn't thread-safe.
#[expect(clippy::cognitive_complexity)]
pub fn pty_setup(
    pty_ws_x: Option<c_ushort>,
    pty_ws_y: Option<c_ushort>,
    pty_debug: bool,
) -> SydResult<SafeOwnedFd> {
    // TIP to be used in logging.
    const TIP: &str = "set sandbox/pty:off";

    // Flags to be used for open(2).
    const PTY_OFLAGS: OFlag = OFlag::from_bits_retain(O_RDWR | O_NOCTTY | O_CLOEXEC);

    // Open main pseudoterminal device.
    let pty_main = openpt(PTY_OFLAGS).inspect_err(|errno| {
        error!("ctx": "setup_pty", "op": "openpt",
                "msg": xfmt!("syd-pty openpt error: {errno}"),
                "tip": TIP, "err": *errno as i32);
    })?;

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

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

    // Spawn syd-pty process, and pass PTY main end to it.
    //
    // SAFETY: Syd is single-threaded at this point.
    #[expect(clippy::disallowed_methods)]
    let syd_pty_pid = match unsafe { nix::unistd::fork() }.inspect_err(|errno| {
        error!("ctx": "setup_pty", "op": "spawn",
            "msg": xfmt!("syd-pty spawn error: {errno}"),
            "tip": TIP, "err": *errno as i32);
    })? {
        nix::unistd::ForkResult::Parent { child } => child,
        nix::unistd::ForkResult::Child => {
            // Confine and run PTY forwarder.
            let result = catch_unwind(AssertUnwindSafe(|| {
                ns_child_pty(pty_main.into(), pty_ws_x, pty_ws_y, pty_debug)
            }));
            let code = match result {
                Ok(Ok(())) => 0,
                Ok(Err(err)) => err.errno().map(|errno| errno as i32).unwrap_or(128),
                Err(_) => 128,
            };

            // Exit with cookies.
            safe_exit_group(code);
        }
    };
    drop(pty_main);

    // SAFETY: Save syd-pty PID for signal protections.
    SYD_PID_PTY.set(syd_pty_pid).or(Err(Errno::EAGAIN))?;

    if pty_debug {
        warn!("ctx": "setup_pty", "op": "forward_tty",
            "pty": syd_pty_pid.as_raw(),
            "msg": "syd-pty is now forwarding terminal I/O");
    } else {
        info!("ctx": "setup_pty", "op": "forward_tty",
            "pty": syd_pty_pid.as_raw(),
            "msg": "syd-pty is now forwarding terminal I/O");
    }

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

// Run syd-pty(1) in child process.
fn ns_child_pty(
    fpty: SafeOwnedFd,
    ws_x: Option<c_ushort>,
    ws_y: Option<c_ushort>,
    is_debug: bool,
) -> SydResult<()> {
    // Set parent death signal to SIGKILL.
    //
    // Creating a new session with setsid(2) here breaks window resizing.
    set_pdeathsig(Some(Signal::SIGKILL))?;

    let opts = PtyBinOpts {
        fpty,
        ws_x,
        ws_y,
        is_debug,
    };
    pty_bin_run(Some(opts))
}