syd 3.56.0

rock-solid application kernel
Documentation
//
// Syd: rock-solid application kernel
// src/utils/syd-fd.rs: Interact with remote file descriptors
//
// Copyright (c) 2025, 2026 Ali Polatel <alip@chesswob.org>
//
// SPDX-License-Identifier: GPL-3.0

#![expect(clippy::disallowed_methods)]
#![expect(clippy::disallowed_types)]

use std::{
    env,
    ffi::OsString,
    io::{self, IoSlice, Write},
    os::{
        fd::{AsRawFd, RawFd},
        unix::process::CommandExt,
    },
    process::{Command, ExitCode},
};

use btoi::btoi;
use memchr::memchr;
use nix::{
    errno::Errno,
    fcntl::{open, OFlag},
    sys::{
        socket::{
            connect, sendmsg, socket, AddressFamily, ControlMessage, MsgFlags, SockFlag, SockType,
            UnixAddr,
        },
        stat::Mode,
    },
    unistd::{dup2_raw, getpid, read, Pid},
};
use syd::{
    compat::{getdents64, readlinkat},
    config::*,
    err::err2no,
    fd::{fd_status_flags, parse_fd, pidfd_getfd, pidfd_open, set_cloexec, PIDFD_THREAD},
    path::{XPath, XPathBuf},
    rng::duprand,
};

// Set global allocator to GrapheneOS allocator.
#[cfg(all(
    not(coverage),
    not(feature = "prof"),
    not(target_os = "android"),
    not(target_arch = "riscv64"),
    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;

syd::main! {
    use lexopt::prelude::*;

    syd::set_sigpipe_dfl()?;

    // Parse CLI options.
    //
    // Note, option parsing is POSIXly correct:
    // POSIX recommends that no more options are parsed after the first
    // positional argument. The other arguments are then all treated as
    // positional arguments.
    // See: https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap12.html#tag_12_02
    let mut opt_pid = None;
    let mut opt_cmd = None;
    let mut opt_arg = Vec::new();
    let mut opt_fds = Vec::new();
    let mut opt_msg: Option<OsString> = None;
    let mut opt_unix: Option<OsString> = None;
    let mut opt_wait = false;

    let mut parser = lexopt::Parser::from_env();
    while let Some(arg) = parser.next()? {
        match arg {
            Short('h') => {
                help();
                return Ok(ExitCode::SUCCESS);
            }
            Short('p') => {
                let pid = parser.value()?;
                opt_pid = match pid.parse::<libc::pid_t>() {
                    Ok(pid) if pid > 0 => Some(Pid::from_raw(pid)),
                    _ => {
                        eprintln!("syd-fd: Invalid PID specified with -p!");
                        return Err(Errno::EINVAL.into());
                    }
                };
            }
            Short('f') => {
                let fd = parser.value()?;

                // Validate UTF-8.
                match fd.to_str() {
                    Some(fd) => opt_fds.push(fd.to_string()),
                    None => {
                        eprintln!("syd-fd: Invalid UTF-8 in FD argument!");
                        return Err(Errno::EILSEQ.into());
                    }
                };
            }
            Short('u') => {
                opt_unix = Some(parser.value()?);
            }
            Short('m') => {
                opt_msg = Some(parser.value()?);
            }
            Short('w') => opt_wait = true,
            Value(prog) => {
                opt_cmd = Some(prog);
                opt_arg.extend(parser.raw_args()?);
            }
            _ => return Err(arg.unexpected().into()),
        }
    }

    // Send file descriptors over a UNIX socket with -u.
    if let Some(addr) = opt_unix {
        let mut fds = Vec::new();
        for val in &opt_fds {
            for fd in val.split(',') {
                match btoi::<RawFd>(fd.as_bytes()) {
                    Ok(fd) if fd >= 0 => fds.push(fd),
                    _ => {
                        eprintln!("syd-fd: Invalid FD specified with -f!");
                        return Err(Errno::EINVAL.into());
                    }
                }
            }
        }

        if opt_cmd.is_some() {
            eprintln!("syd-fd: Unexpected positional argument with -u!");
            return Err(Errno::EINVAL.into());
        }

        unix_send_fds(XPathBuf::from(addr), opt_msg, fds, opt_wait)?;
        return Ok(ExitCode::SUCCESS);
    }

    // Parse -f arguments as remote_fd[:local_fd] pairs.
    let mut fds = Vec::new();
    for fd in &opt_fds {
        if let Some(idx) = memchr(b':', fd.as_bytes()) {
            // Parse remote fd.
            let remote_fd = &fd[..idx];
            let remote_fd = match btoi::<RawFd>(remote_fd.as_bytes()) {
                Ok(fd) if fd >= 0 => fd,
                _ => {
                    eprintln!("syd-fd: Invalid FD specified with -f!");
                    return Err(Errno::EINVAL.into());
                }
            };

            // Parse optional local fd.
            let local_fd = &fd[idx + 1..];
            let local_fd = match local_fd {
                "rand" => Some(libc::AT_FDCWD),
                fd => match btoi::<RawFd>(fd.as_bytes()) {
                    Ok(fd) if fd >= 0 => Some(fd),
                    _ => {
                        eprintln!("syd-fd: Invalid FD specified with -f!");
                        return Err(Errno::EINVAL.into());
                    }
                },
            };

            fds.push((remote_fd, local_fd));
        } else {
            // Parse remote fd.
            let remote_fd = match btoi::<RawFd>(fd.as_bytes()) {
                Ok(fd) if fd >= 0 => fd,
                _ => {
                    eprintln!("syd-fd: Invalid FD specified with -f!");
                    return Err(Errno::EINVAL.into());
                }
            };

            fds.push((remote_fd, None));
        }
    }
    let opt_fds = fds;

    let pid = if opt_fds.is_empty() {
        // List /proc/$pid/fd.
        let fds = proc_pid_fd(opt_pid)?;

        // Serialize as line-oriented compact JSON.
        for fd in fds {
            #[expect(clippy::disallowed_methods)]
            let fd = serde_json::to_string(&fd).expect("JSON");
            println!("{fd}");
        }

        return Ok(ExitCode::SUCCESS);
    } else if let Some(pid) = opt_pid {
        pid
    } else {
        eprintln!("PID must be specified with -p!");
        return Err(Errno::EINVAL.into());
    };

    // Open a PIDFd to the specified PID or TID.
    let pid_fd = pidfd_open(pid, PIDFD_THREAD)?;

    // Transfer remote fds.
    for (remote_fd, local_fd) in opt_fds {
        // Transfer fd with pidfd_getfd(2).
        let fd = pidfd_getfd(&pid_fd, remote_fd)?;

        // Handle local fd.
        let fd = match local_fd {
            Some(libc::AT_FDCWD) => {
                let fd_rand = duprand(fd.as_raw_fd(), OFlag::empty())?;
                drop(fd);
                fd_rand
            }
            Some(newfd) => {
                // SAFETY: User should ensure no double-close happens.
                let fd_dup = unsafe { dup2_raw(&fd, newfd) }?;
                drop(fd);
                fd_dup.into()
            }
            None => fd,
        };

        // Log progress.
        let flags = fd_status_flags(&fd).unwrap_or(OFlag::empty());
        eprintln!("syd-fd: GETFD {remote_fd} -> {} (flags: {flags:?})",
            fd.as_raw_fd());

        // Prepare to pass the fd to the child.
        set_cloexec(&fd, false)?;

        // Leak fd on purpose, child will take over.
        std::mem::forget(fd);
    }

    // Command is /bin/sh by default.
    let opt_cmd = opt_cmd.unwrap_or_else(|| env::var_os(ENV_SH).unwrap_or(OsString::from(SYD_SH)));

    // Log progress.
    eprintln!("syd-fd: EXEC {}", XPathBuf::from(opt_cmd.clone()));

    // Execute command.
    Ok(ExitCode::from(
        127 + Command::new(opt_cmd)
            .args(opt_arg)
            .exec()
            .raw_os_error()
            .unwrap_or(0) as u8,
    ))
}

fn help() {
    println!("Usage: syd-fd [-h] [-p pid] [-f remote_fd[:local_fd]].. {{command [args...]}}");
    println!("       syd-fd [-hw] -u socket [-m line] [-f fd]..");
    println!("Interact with remote file descriptors");
    println!("Execute the given command or `/bin/sh' with inherited remote fds.");
    println!("List remote file descriptors with the given PID if no -f is given.");
    println!("Use -p to specify PID.");
    println!("Use -f remote_fd to specify remote file descriptor to transfer.");
    println!("Optionally append a colon and a local fd to use as the target.");
    println!("Use `rand' as target fd to duplicate to a random valid slot.");
    println!("Use -u to send fds over the UNIX socket with SCM_RIGHTS.");
    println!("Use -f fd1,fd2,... to specify file descriptors to send with -u.");
    println!("Use -m message to specify message to send with -u.");
    println!("Use -w to wait for response with -u.");
}

fn unix_send_fds(
    addr: XPathBuf,
    line: Option<OsString>,
    fds: Vec<RawFd>,
    wait: bool,
) -> Result<(), Errno> {
    // Prepare UNIX socket address, `@' prefix implies abstract socket.
    let addr = if matches!(addr.first(), Some(b'@')) {
        UnixAddr::new_abstract(&addr.as_bytes()[1..])?
    } else {
        UnixAddr::new(addr.as_bytes())?
    };

    // Connect to UNIX socket.
    let sock = socket(
        AddressFamily::Unix,
        SockType::Stream,
        SockFlag::SOCK_CLOEXEC,
        None,
    )?;
    connect(sock.as_raw_fd(), &addr)?;

    // Send message line and file descriptors in one message.
    let mut data = line
        .map(|line| XPathBuf::from(line).into_vec())
        .unwrap_or_default();

    if !data.ends_with(b"\n") {
        data.push(b'\n');
    }

    let iov = [IoSlice::new(&data)];
    let cmsgs = if fds.is_empty() {
        Vec::new()
    } else {
        vec![ControlMessage::ScmRights(&fds)]
    };
    sendmsg::<UnixAddr>(sock.as_raw_fd(), &iov, &cmsgs, MsgFlags::empty(), None)?;

    // Return unless -w is given.
    if !wait {
        return Ok(());
    }

    // Print response until EOF.
    let mut buf = [0u8; 4096];
    let mut stdout = io::stdout().lock();
    loop {
        match read(&sock, &mut buf) {
            Ok(0) => break,
            Ok(n) => {
                stdout.write_all(&buf[..n]).map_err(|e| err2no(&e))?;
                stdout.flush().map_err(|e| err2no(&e))?;
            }
            Err(Errno::EINTR) => {}
            Err(errno) => return Err(errno),
        }
    }

    Ok(())
}

// List `/proc/pid/fd` contents.
//
// Return a vector of `(RawFd, XPathBuf)` tuples, where each `RawFd`
// is the file descriptor number and the `XPathBuf` is the path it points to.
//
// Useful for debugging file descriptor leaks.
#[expect(clippy::type_complexity)]
fn proc_pid_fd(pid: Option<Pid>) -> Result<Vec<(RawFd, XPathBuf)>, Errno> {
    let pid = pid.unwrap_or_else(getpid);

    let mut dir = XPathBuf::try_from("/proc")?;
    dir.try_push_pid(pid)?;
    dir.try_push(b"fd")?;

    #[expect(clippy::disallowed_methods)]
    let dir = open(
        &dir,
        OFlag::O_RDONLY | OFlag::O_DIRECTORY | OFlag::O_CLOEXEC,
        Mode::empty(),
    )?;

    let mut res = vec![];
    let mut seen_dot = false;
    let mut seen_dotdot = false;
    loop {
        let mut entries = match getdents64(&dir, DIRENT_BUF_SIZE) {
            Ok(entries) => entries,
            Err(Errno::ECANCELED) => break, // EOF or empty directory
            Err(errno) => return Err(errno),
        };

        for entry in &mut entries {
            if !seen_dot && entry.is_dot() {
                seen_dot = true;
                continue;
            }
            if !seen_dotdot && entry.is_dotdot() {
                seen_dotdot = true;
                continue;
            }
            let entry = XPath::from_bytes(entry.name_bytes());
            let fd = parse_fd(entry)?;
            let target = readlinkat(&dir, entry)?;

            res.push((fd, target));
        }
    }

    Ok(res)
}