artifactize-tools 0.15.0

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::{ExitStatus, 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)
        .env_clear()
        .envs(crate::environment::user())
        .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 !opened(&status, is_wsl()) {
        return Err(io::Error::other(format!(
            "Desktop opener exited with {status}."
        )));
    }
    Ok(())
}

/// On WSL, xdg-utils opens files with `explorer.exe`, which exits 1 even after opening the
/// file, and xdg-open then reports "the action failed" with this status (#210).
const WSL_EXPLORER_STATUS: i32 = 4;

/// Whether the opener's exit status means the target was handed to the desktop.
fn opened(status: &ExitStatus, wsl: bool) -> bool {
    status.success() || (wsl && status.code() == Some(WSL_EXPLORER_STATUS))
}

/// Whether this is WSL, by the test xdg-open itself uses: `microsoft` in `/proc/version`.
fn is_wsl() -> bool {
    cfg!(target_os = "linux")
        && std::fs::read_to_string("/proc/version")
            .is_ok_and(|version| version.contains("microsoft"))
}

// The opener's exit status exists only where an opener process runs: Unix, WSL included.
// Windows hands the target to ShellExecute instead.
#[cfg(test)]
mod tests {
    use super::*;
    use std::os::unix::process::ExitStatusExt;

    /// An exit status with `code`, in the wait-status encoding `from_raw` takes.
    fn exited(code: i32) -> ExitStatus {
        ExitStatus::from_raw(code << 8)
    }

    #[test]
    fn wsl_explorer_status_counts_as_opened_only_on_wsl() {
        assert!(opened(&exited(0), false));
        assert!(opened(&exited(0), true));
        assert!(opened(&exited(WSL_EXPLORER_STATUS), true));
        assert!(!opened(&exited(WSL_EXPLORER_STATUS), false));
        for code in [1, 2, 3] {
            assert!(!opened(&exited(code), true));
        }
    }
}