syd 3.52.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::{
    env,
    os::{
        fd::{AsFd, AsRawFd, FromRawFd, 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, set_no_new_privs, OpenHow, ResolveFlag, TIOCGPTPEER},
    config::LANDLOCK_ABI,
    confine::{confine_landlock_scope, safe_drop_caps},
    err::{err2no, SydResult},
    error,
    fd::{is_dev_ptmx, pidfd_open, set_cloexec, SafeOwnedFd, AT_BADFD},
    info,
    landlock::{AccessFs, AccessNet},
    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<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.
    #[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 { 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::ENODEV);
    }

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

/// Set up PTY sandboxing.
pub fn pty_setup(
    pty_ws_x: Option<libc::c_ushort>,
    pty_ws_y: Option<libc::c_ushort>,
    pty_debug: bool,
) -> SydResult<SafeOwnedFd> {
    // TIP to be used in logging.
    const TIP: &str = "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. LD_LIBRARY_PATH
    // 2. SYD_PTY_RULES
    // We do not need to pass SYD_PTY_DEBUG because we use -d as needed.
    let safe_env: &[&[u8]] = &[b"LD_LIBRARY_PATH", b"SYD_PTY_RULES"];

    // Spawn syd-pty process, and pass PTY main end to it.
    // pty_init sets process name which syd(1) recognizes.
    let mut cmd = Command::new("/proc/self/exe");
    cmd.arg0("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: `pty_init` performs only async-signal-safe
    // and fork-safe operations (prctl, ioctl, syscall).
    unsafe { cmd.pre_exec(|| Ok(pty_init()?)) };
    let syd_pty = 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);
    // SAFETY: Save syd-pty PID for signal protections.
    let mut buf = itoa::Buffer::new();
    env::set_var("SYD_PID_PTY", buf.format(syd_pty.id()));
    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)
}

// Initialize PTY sandboxing.
//
// This runs early in fork process before syd-pty(1) is spawned.
// Confinement happens in two-stages:
// 1. in Command::pre_exec before syd-pty(1) is spawned.
// 2. syd-pty(1) confining itself before main loop.
//
// This confinement is somewhat repetitive, however it reduces the blast
// radius when Syd is misguided into executing a malicious syd-pty(1)
// binary.
fn pty_init() -> SydResult<()> {
    // Steps before exec:
    // 0. Set name for easier identification.
    // 1. Drop all Linux capabilities(7).
    // 2. Set no-new-privs attribute.
    // 3. Confine landlock-scope on Linux>=6.12.
    // 4. 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).
    //
    // `AccessFs::from_write` includes IoctlDev.
    // The ioctl(2) requests called by syd-pty(1) are permitted by landlock(7),
    // therefore the added IoctlDev access right does not prevent functionality.
    let _ = set_name(c"syd-pty");
    safe_drop_caps()?;
    set_no_new_privs()?;
    confine_landlock_scope(
        None::<SafeOwnedFd>, /* unused */
        AccessFs::from_write(*LANDLOCK_ABI),
        AccessNet::all(),
        true, /* scoped_abs */
    )?;
    umask(Mode::from_bits_truncate(0o777));
    Ok(())
}