artifactize-tools 0.14.2

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

use std::{
    ffi::OsStr,
    io, mem,
    os::windows::{
        ffi::OsStrExt,
        io::{AsRawHandle, FromRawHandle, OwnedHandle},
    },
    path::Path,
    ptr,
};

use windows_sys::Win32::{
    Foundation::INVALID_HANDLE_VALUE,
    System::Diagnostics::ToolHelp::{
        CreateToolhelp32Snapshot, TH32CS_SNAPTHREAD, THREADENTRY32, Thread32First, Thread32Next,
    },
    System::JobObjects::{
        AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
        JOBOBJECT_BASIC_LIMIT_INFORMATION, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
        JobObjectExtendedLimitInformation, SetInformationJobObject,
    },
    System::Threading::{CREATE_SUSPENDED, OpenThread, ResumeThread, THREAD_SUSPEND_RESUME},
    UI::{Shell::ShellExecuteW, WindowsAndMessaging::SW_SHOWNORMAL},
};

/// An extensionless program name is tried with each `PATHEXT` suffix.
pub(crate) const USES_PATHEXT: bool = true;

/// Windows has no execute bit; any regular file is a candidate.
pub(crate) fn is_executable(path: &Path) -> bool {
    path.metadata().is_ok_and(|metadata| metadata.is_file())
}

/// A started program in a kill-on-close Job Object, so that its children die with it.
pub(crate) struct Tree {
    pub(crate) child: tokio::process::Child,
    job: Option<OwnedHandle>,
}

/// Start `command` suspended, put it in a new kill-on-close job, then let it run: every
/// process it starts is in the job from its first instruction.
pub(crate) fn spawn_tree(command: &mut tokio::process::Command) -> io::Result<Tree> {
    // SAFETY: no attributes and no name create a private, non-inheritable job.
    let handle = unsafe { CreateJobObjectW(ptr::null(), ptr::null()) };
    if handle.is_null() {
        return Err(io::Error::last_os_error());
    }
    // SAFETY: CreateJobObjectW returned a new handle that nothing else owns.
    let job = unsafe { OwnedHandle::from_raw_handle(handle) };
    let limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION {
        BasicLimitInformation: JOBOBJECT_BASIC_LIMIT_INFORMATION {
            LimitFlags: JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
            ..Default::default()
        },
        ..Default::default()
    };
    // SAFETY: the information class matches the structure and its size.
    if unsafe {
        SetInformationJobObject(
            job.as_raw_handle(),
            JobObjectExtendedLimitInformation,
            ptr::from_ref(&limits).cast(),
            mem::size_of_val(&limits) as u32,
        )
    } == 0
    {
        return Err(io::Error::last_os_error());
    }
    command.creation_flags(CREATE_SUSPENDED);
    let mut suspended = SuspendedChild(command.spawn()?);
    let admitted = (|| {
        let process = suspended.0.raw_handle().ok_or(io::ErrorKind::NotFound)?;
        // SAFETY: a live job handle and the handle of the suspended child just started.
        if unsafe { AssignProcessToJobObject(job.as_raw_handle(), process) } == 0 {
            return Err(io::Error::last_os_error());
        }
        resume(&suspended)
    })();
    if let Err(error) = admitted {
        // A child that never joined the job is killed directly; it never ran.
        let _ = suspended.0.start_kill();
        return Err(error);
    }
    Ok(Tree {
        child: suspended.0,
        job: Some(job),
    })
}

/// A child created with CREATE_SUSPENDED by this launcher. Owning the child
/// keeps its process identity live until job admission and resumption finish.
struct SuspendedChild(tokio::process::Child);

/// ResumeThread uses DWORD_MAX as its error sentinel, not as a suspend count.
const RESUME_THREAD_FAILED: u32 = u32::MAX;

/// Resume the primary thread of the child this launcher created suspended.
fn resume(child: &SuspendedChild) -> io::Result<()> {
    let pid = child.0.id().ok_or(io::ErrorKind::NotFound)?;
    // SAFETY: a snapshot of all threads; the result is checked and then owned.
    let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0) };
    if snapshot == INVALID_HANDLE_VALUE {
        return Err(io::Error::last_os_error());
    }
    // SAFETY: CreateToolhelp32Snapshot returned a new handle that nothing else owns.
    let snapshot = unsafe { OwnedHandle::from_raw_handle(snapshot) };
    let mut entry = THREADENTRY32 {
        dwSize: mem::size_of::<THREADENTRY32>() as u32,
        ..Default::default()
    };
    let mut resumed = false;
    // SAFETY: an open snapshot and an entry whose dwSize is set.
    let mut more = unsafe { Thread32First(snapshot.as_raw_handle(), &mut entry) } != 0;
    while more {
        if entry.th32OwnerProcessID == pid {
            // SAFETY: the result is checked and then owned.
            let thread = unsafe { OpenThread(THREAD_SUSPEND_RESUME, 0, entry.th32ThreadID) };
            if thread.is_null() {
                return Err(io::Error::last_os_error());
            }
            // SAFETY: OpenThread returned a new handle that nothing else owns.
            let thread = unsafe { OwnedHandle::from_raw_handle(thread) };
            // SAFETY: an open thread handle with THREAD_SUSPEND_RESUME.
            if unsafe { ResumeThread(thread.as_raw_handle()) } == RESUME_THREAD_FAILED {
                return Err(io::Error::last_os_error());
            }
            resumed = true;
        }
        // SAFETY: as for Thread32First.
        more = unsafe { Thread32Next(snapshot.as_raw_handle(), &mut entry) } != 0;
    }
    if resumed {
        Ok(())
    } else {
        Err(io::Error::other(
            "the suspended child has no thread to resume",
        ))
    }
}

impl Tree {
    /// Close the job, which kills every process still in it.
    pub(crate) fn kill(&mut self) {
        self.job.take();
    }
}

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

/// Hand the target to ShellExecute's default verb on a blocking thread.
pub(crate) async fn open_desktop(target: &OsStr) -> io::Result<()> {
    let target = target.to_owned();
    match tokio::task::spawn_blocking(move || shell_execute(&target)).await {
        Ok(result) => result,
        Err(error) if error.is_panic() => std::panic::resume_unwind(error.into_panic()),
        Err(error) => Err(io::Error::other(error)),
    }
}

/// ShellExecuteW returns an error code at or below 32; greater values mean a successful
/// desktop handoff, not a handle that this process owns.
const SHELL_EXECUTE_ERROR_MAX: isize = 32;

fn shell_execute(target: &OsStr) -> io::Result<()> {
    handoff(target, |target| {
        // SAFETY: handoff supplies a NUL-terminated target alive for this call.
        // No verb, parameters or directory: the file/URL is one literal target.
        unsafe {
            ShellExecuteW(
                ptr::null_mut(),
                ptr::null(),
                target.as_ptr(),
                ptr::null(),
                ptr::null(),
                SW_SHOWNORMAL,
            ) as isize
        }
    })
}

/// The native handoff boundary can be observed without changing a user's default apps.
fn handoff(target: &OsStr, execute: impl FnOnce(&[u16]) -> isize) -> io::Result<()> {
    let mut target: Vec<_> = target.encode_wide().collect();
    if target.contains(&0) {
        return Err(io::Error::new(
            io::ErrorKind::InvalidInput,
            "Open target contains NUL.",
        ));
    }
    target.push(0);
    let result = execute(&target);
    // ShellExecute returns a value greater than 32 on success, not an owned handle.
    if result <= SHELL_EXECUTE_ERROR_MAX {
        return Err(io::Error::other(format!(
            "ShellExecute could not open the target (code {result})."
        )));
    }
    Ok(())
}

// Windows has a ShellExecute handoff, not a Unix opener subprocess. Observe its
// native argument seam without launching a browser or changing file associations.
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn target_is_one_literal_argument() {
        for target in [
            "notes with spaces.md",
            "https://example.invalid/?a=1&b=two words",
            "한글 문서.md",
        ] {
            let mut calls = Vec::new();
            handoff(OsStr::new(target), |argument| {
                calls.push(argument.to_vec());
                33 // ShellExecute's smallest successful handoff value.
            })
            .unwrap();
            assert_eq!(calls.len(), 1);
            assert_eq!(calls[0].last(), Some(&0));
            assert_eq!(
                String::from_utf16(&calls[0][..calls[0].len() - 1]).unwrap(),
                target
            );
        }
    }

    #[test]
    fn invalid_target_never_reaches_the_desktop_and_native_errors_are_reported() {
        let invalid = handoff(OsStr::new("before\0after"), |_| {
            panic!("NUL reached ShellExecute")
        });
        assert_eq!(invalid.unwrap_err().kind(), io::ErrorKind::InvalidInput);
        assert!(
            handoff(OsStr::new("missing.md"), |_| 2)
                .unwrap_err()
                .to_string()
                .contains("code 2")
        );
    }
}