artifactize-tools 0.13.1

Portable scoped tools and safe file access for artifactize.
Documentation
//! Unix program lookup details and the desktop opener.

use std::{
    ffi::OsStr, io, os::unix::fs::PermissionsExt, path::Path, process::Stdio, time::Duration,
};

/// Unix names programs exactly; extensions carry no meaning for lookup.
pub(crate) const USES_PATHEXT: bool = false;

/// Any execute bit is the declared-command preflight rule.
const EXECUTE_BITS: u32 = 0o111;

pub(crate) fn is_executable(path: &Path) -> bool {
    path.metadata().is_ok_and(|metadata| {
        metadata.is_file() && metadata.permissions().mode() & EXECUTE_BITS != 0
    })
}

/// A started program in a process group of its own, so that its children can be killed
/// with it.
pub(crate) struct Tree {
    pub(crate) child: tokio::process::Child,
    group: Option<GroupId>,
}

/// A process group created by this launcher, never an arbitrary process identifier.
struct GroupId(rustix::process::Pid);

/// Start `command` as the leader of a new process group.
pub(crate) fn spawn_tree(command: &mut tokio::process::Command) -> io::Result<Tree> {
    command.process_group(0);
    let child = command.spawn()?;
    let group = child.id().map(|pid| {
        let pid = i32::try_from(pid).expect("Unix process IDs fit pid_t");
        GroupId(rustix::process::Pid::from_raw(pid).expect("a child's process ID is positive"))
    });
    Ok(Tree { child, group })
}

impl Tree {
    /// Kill every process left in the group; a group that has already gone is fine.
    pub(crate) fn kill(&mut self) {
        if let Some(group) = self.group.take() {
            // A group that has already gone is the expected outcome, not an error.
            let _ = rustix::process::kill_process_group(group.0, rustix::process::Signal::KILL);
        }
    }
}

impl Drop for Tree {
    fn drop(&mut self) {
        self.kill();
    }
}

/// Desktop handoff must not hold a sign-in or Human tool indefinitely.
const OPEN_TIMEOUT: Duration = Duration::from_secs(3);

/// The desktop's own opener program: `open` on macOS; `xdg-open` elsewhere, WSL included.
#[cfg(target_os = "macos")]
const OPENER: &str = "open";
#[cfg(not(target_os = "macos"))]
const OPENER: &str = "xdg-open";

/// Run the desktop opener with the target as its one argument, never through a shell.
pub(crate) async fn open_desktop(target: &OsStr) -> io::Result<()> {
    let cwd = std::env::current_dir()?;
    let program = crate::program::resolve(OsStr::new(OPENER), &cwd)?;
    let mut command = tokio::process::Command::new(program);
    command
        .arg(target)
        .stdin(Stdio::null())
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .kill_on_drop(true);
    let status = tokio::time::timeout(OPEN_TIMEOUT, command.status())
        .await
        .map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "Desktop opener timed out."))??;
    if !status.success() {
        return Err(io::Error::other(format!(
            "Desktop opener exited with {status}."
        )));
    }
    Ok(())
}