//! Windows, following libuv (`src/win/error.c`, `src/win/process.c`, `src/win/fs.c`) and Node's `child_process`. use super::{OpenMode, ShellConfig, path_exists, synthetic}; use crate::errors::Failure; use serde_json::{Map, Value}; use std::collections::hash_map::RandomState; use std::ffi::OsStr; use std::fs::{File, Metadata}; use std::hash::{BuildHasher, Hasher}; use std::io; use std::os::windows::ffi::OsStrExt; use std::os::windows::fs::FileExt; use std::os::windows::io::{AsRawHandle, FromRawHandle}; use std::os::windows::process::CommandExt; use std::path::Path; use std::process::{Child, Command, ExitStatus, Stdio}; use std::sync::OnceLock; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::time::UNIX_EPOCH; use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE}; use windows_sys::Win32::Storage::FileSystem::{ BY_HANDLE_FILE_INFORMATION, CREATE_ALWAYS, CreateFileW, FILE_APPEND_DATA, FILE_ATTRIBUTE_NORMAL, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_GENERIC_READ, FILE_GENERIC_WRITE, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, FILE_WRITE_DATA, GetFileInformationByHandle, MOVEFILE_REPLACE_EXISTING, MoveFileExW, OPEN_ALWAYS, OPEN_EXISTING, }; use windows_sys::Win32::System::IO::CancelSynchronousIo; use windows_sys::Win32::System::JobObjects::{ AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_BREAKAWAY_OK, JOB_OBJECT_LIMIT_DIE_ON_UNHANDLED_EXCEPTION, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK, JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation, SetInformationJobObject, }; use windows_sys::Win32::System::Threading::CREATE_NO_WINDOW; /// libuv's `uv_translate_sys_error` for the errors file and process operations produce. pub fn os_error_name(error: &io::Error) -> &'static str { let Some(code) = error.raw_os_error() else { return "UNKNOWN"; }; match code { 1 => "EISDIR", // ERROR_INVALID_FUNCTION 2 | 3 | 15 | 123 | 161 | 4392 => "ENOENT", // FILE_NOT_FOUND, PATH_NOT_FOUND, INVALID_DRIVE, INVALID_NAME, BAD_PATHNAME, INVALID_REPARSE_DATA 4 => "EMFILE", // ERROR_TOO_MANY_OPEN_FILES 5 | 1314 => "EPERM", // ACCESS_DENIED, PRIVILEGE_NOT_HELD 6 => "EBADF", // ERROR_INVALID_HANDLE 8 | 14 => "ENOMEM", // NOT_ENOUGH_MEMORY, OUTOFMEMORY 17 => "EXDEV", // ERROR_NOT_SAME_DEVICE 19 => "EROFS", // ERROR_WRITE_PROTECT 32 | 33 => "EBUSY", // SHARING_VIOLATION, LOCK_VIOLATION 39 | 82 | 112 => "ENOSPC", // HANDLE_DISK_FULL, CANNOT_MAKE, DISK_FULL 50 => "ENOTSUP", // ERROR_NOT_SUPPORTED 80 | 183 => "EEXIST", // FILE_EXISTS, ALREADY_EXISTS 13 | 87 | 122 | 131 | 1464 => "EINVAL", // INVALID_DATA, INVALID_PARAMETER, INSUFFICIENT_BUFFER, NEGATIVE_SEEK, SYMLINK_NOT_SUPPORTED 126 => "ENOENT", // ERROR_MOD_NOT_FOUND 145 => "ENOTEMPTY", // ERROR_DIR_NOT_EMPTY 206 => "ENAMETOOLONG", // ERROR_FILENAME_EXCED_RANGE 267 => "ENOENT", // ERROR_DIRECTORY (libuv maps it to ENOENT) 998 => "EFAULT", // ERROR_NOACCESS 740 | 1920 => "EACCES", // ELEVATION_REQUIRED, CANT_ACCESS_FILE 1921 => "ELOOP", // ERROR_CANT_RESOLVE_FILENAME _ => "UNKNOWN", } } pub const PATH_SEPARATOR: &str = "\\"; pub fn read_at(file: &File, buffer: &mut [u8], offset: u64) -> io::Result { file.seek_read(buffer, offset) } /// Modification time as seconds and nanoseconds since the epoch; before 1970 the seconds are negative. pub fn mtime(metadata: &Metadata) -> (i64, i64) { let Ok(time) = metadata.modified() else { return (0, 0); }; match time.duration_since(UNIX_EPOCH) { Ok(after) => (after.as_secs() as i64, i64::from(after.subsec_nanos())), Err(before) => { let before = before.duration(); let nanos = i64::from(before.subsec_nanos()); if nanos == 0 { (-(before.as_secs() as i64), 0) } else { (-(before.as_secs() as i64) - 1, 1_000_000_000 - nanos) } } } } /// File index numbers need a handle per file; the creation time tells a replaced file or directory apart instead. pub fn identity(metadata: &Metadata) -> (u64, u64) { use std::os::windows::fs::MetadataExt; (0, metadata.creation_time()) } /// Node's `os.tmpdir()` on Windows. pub fn tmpdir() -> String { let variable = |key: &str| std::env::var(key).ok().filter(|value| !value.is_empty()); let mut path = variable("TEMP") .or_else(|| variable("TMP")) .unwrap_or_else(|| { format!( "{}\\temp", variable("SystemRoot") .or_else(|| variable("windir")) .unwrap_or_default() ) }); if path.len() > 1 && path.ends_with('\\') && !path.ends_with(":\\") { path.pop(); } path } /// libuv's `uv_os_homedir`: `USERPROFILE` first. pub fn home() -> String { std::env::var("USERPROFILE").unwrap_or_default() } /// A NUL-terminated wide path; an embedded NUL fails like the standard library's path conversion (Node rejects such /// paths before any system call, which the client reports with the same code). fn wide(text: &str) -> io::Result> { if text.contains('\0') { return Err(io::Error::new( io::ErrorKind::InvalidInput, "path contains a NUL byte", )); } Ok(OsStr::new(text) .encode_wide() .chain(std::iter::once(0)) .collect()) } /// libuv's `fs__mkdtemp`: `prefix` plus six characters from `[a-zA-Z0-9]`, retried while the name exists. pub fn mkdtemp(prefix: &str) -> io::Result { const CHARACTERS: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; static COUNTER: AtomicU64 = AtomicU64::new(0); let state = RandomState::new(); for _ in 0..1000 { let mut hasher = state.build_hasher(); hasher.write_u64(COUNTER.fetch_add(1, Ordering::Relaxed)); let mut random = hasher.finish(); let mut path = prefix.to_string(); for _ in 0..6 { path.push(CHARACTERS[(random % CHARACTERS.len() as u64) as usize] as char); random /= CHARACTERS.len() as u64; } match std::fs::create_dir(&path) { Ok(()) => return Ok(path), Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, Err(error) => return Err(error), } } Err(io::Error::from_raw_os_error(80)) } /// Windows cannot open a directory as a file and has no `O_NOFOLLOW`: check the path first, as `NodeExecutionEnv` does /// there (not race-free). pub fn open_reader(path: &str, no_follow: bool) -> Result { if no_follow && std::fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_symlink()) { return Err(Failure::new("SYMLINK", "Refusing to follow a symbolic link").path(path)); } if std::fs::metadata(path).is_ok_and(|metadata| metadata.is_dir()) { return Err( Failure::new("EISDIR", "EISDIR: illegal operation on a directory, read").path(path), ); } File::open(path).map_err(|error| Failure::io(&error, "open", path)) } /// libuv's `fs__open` for Node's write modes: `CreateFileW` with its access, sharing and disposition, always with /// `FILE_FLAG_BACKUP_SEMANTICS`, so a directory opens and later calls on it fail as in Node; creating a file where a /// directory exists reports `EISDIR`. (Rust's `File::create` opens with `OPEN_ALWAYS` and truncates afterwards.) pub fn open(path: &str, mode: OpenMode) -> io::Result { let (access, disposition) = match mode { OpenMode::Read => (FILE_GENERIC_READ, OPEN_EXISTING), OpenMode::Write => (FILE_GENERIC_WRITE, CREATE_ALWAYS), OpenMode::Append => ( (FILE_GENERIC_WRITE & !FILE_WRITE_DATA) | FILE_APPEND_DATA, OPEN_ALWAYS, ), OpenMode::ReadWrite => (FILE_GENERIC_READ | FILE_GENERIC_WRITE, OPEN_EXISTING), }; let name = wide(path)?; // SAFETY: a NUL-terminated wide path; the returned handle is owned by the File on success. let handle = unsafe { CreateFileW( name.as_ptr(), access, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, std::ptr::null(), disposition, FILE_ATTRIBUTE_NORMAL | FILE_FLAG_BACKUP_SEMANTICS, std::ptr::null_mut(), ) }; if handle == INVALID_HANDLE_VALUE { let error = io::Error::last_os_error(); // ERROR_FILE_EXISTS while creating means the path is a directory. if error.raw_os_error() == Some(80) && matches!(mode, OpenMode::Write | OpenMode::Append) { return Err(synthetic("EISDIR")); } return Err(error); } // SAFETY: `handle` is a valid, owned file handle. Ok(unsafe { File::from_raw_handle(handle as _) }) } /// libuv's `uv_fs_realpath`: the final path without the `\\?\` prefix. pub fn realpath(path: &str) -> io::Result { let resolved = std::fs::canonicalize(path)?.to_string_lossy().into_owned(); Ok(if let Some(unc) = resolved.strip_prefix("\\\\?\\UNC\\") { format!("\\\\{unc}") } else { resolved .strip_prefix("\\\\?\\") .map(str::to_string) .unwrap_or(resolved) }) } /// libuv's `fs__rename`: `MoveFileExW` replacing an existing destination. pub fn rename(from: &str, to: &str) -> io::Result<()> { let (from, to) = (wide(from)?, wide(to)?); // SAFETY: both arguments are NUL-terminated wide strings that outlive the call. if unsafe { MoveFileExW(from.as_ptr(), to.as_ptr(), MOVEFILE_REPLACE_EXISTING) } == 0 { return Err(io::Error::last_os_error()); } Ok(()) } /// Node's `rm` on Windows: a symbolic link or junction to a directory is removed as a directory entry (libuv's /// `fs__unlink`), and a read-only file is retried after making it writable. pub fn remove_file(path: &str) -> io::Result<()> { use std::os::windows::fs::FileTypeExt; if std::fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_symlink_dir()) { return std::fs::remove_dir(path); } match std::fs::remove_file(path) { Err(error) if error.kind() == io::ErrorKind::PermissionDenied => { let mut permissions = std::fs::metadata(path)?.permissions(); #[allow(clippy::permissions_set_readonly_false)] permissions.set_readonly(false); std::fs::set_permissions(path, permissions)?; std::fs::remove_file(path) } other => other, } } /// A pipe a reader thread reads with blocking calls; `interrupt_reader` cancels a blocked read. pub trait Pipe: io::Read {} impl Pipe for T {} /// Read `pipe` until it ends, `on_data` returns false, or `stop` is set; the pipe closes when this returns. pub fn read_pipe(mut pipe: impl Pipe, stop: &AtomicBool, mut on_data: impl FnMut(&[u8]) -> bool) { let mut buffer = vec![0u8; 64 * 1024]; while !stop.load(Ordering::SeqCst) { match pipe.read(&mut buffer) { Ok(0) | Err(_) => return, Ok(read) => { if !on_data(&buffer[..read]) { return; } } } } } /// Cancel a reader blocked in `ReadFile`, so it sees `stop` and closes its pipe. pub fn interrupt_reader(reader: &std::thread::JoinHandle<()>) { // SAFETY: the handle of a live or finished thread; cancelling no pending I/O is harmless. unsafe { CancelSynchronousIo(reader.as_raw_handle() as _); } } /// libuv's `search_path` for an argv command: a name with a directory is tried as given, otherwise the working /// directory and then each `PATH` entry; a name without an extension also tries `.com` and `.exe`. Node refuses /// batch files without a shell. pub fn resolve_program( program: &str, cwd: &str, env: &Map, ) -> Result { let not_found = || Failure::new("spawn_error", format!("spawn {program} ENOENT")); let has_extension = Path::new(program).extension().is_some(); let candidates = |base: &Path| -> Vec { let mut names = Vec::new(); if has_extension { names.push(base.to_path_buf()); } for extension in ["com", "exe"] { let mut name = base.as_os_str().to_owned(); name.push("."); name.push(extension); names.push(name.into()); } names }; let found = if program.contains(['\\', '/', ':']) { candidates(&Path::new(cwd).join(program)) .into_iter() .find(|path| path.is_file()) } else { let path_variable = env .iter() .find(|(key, _)| key.eq_ignore_ascii_case("PATH")) .and_then(|(_, value)| value.as_str().map(str::to_string)) .or_else(|| std::env::var("PATH").ok()) .unwrap_or_default(); std::iter::once(std::path::PathBuf::from(cwd)) .chain(std::env::split_paths(&path_variable)) .flat_map(|directory| candidates(&directory.join(program))) .find(|path| path.is_file()) }; let found = found.ok_or_else(not_found)?; let lower = found.to_string_lossy().to_lowercase(); if lower.ends_with(".bat") || lower.ends_with(".cmd") { return Err(Failure::new( "spawn_error", format!("spawn {program} EINVAL"), )); } Ok(found.to_string_lossy().into_owned()) } /// Node's `isLegacyWslBashPath`: `^[a-z]:\\windows\\(system32|sysnative)\\bash\.exe$`, case-insensitive. fn is_legacy_wsl_bash(path: &str) -> bool { let normalized = path.replace('/', "\\").to_lowercase(); let mut characters = normalized.chars(); characters .next() .is_some_and(|drive| drive.is_ascii_lowercase()) && matches!( characters.as_str(), ":\\windows\\system32\\bash.exe" | ":\\windows\\sysnative\\bash.exe" ) } fn bash_config(program: &str) -> ShellConfig { if is_legacy_wsl_bash(program) { ShellConfig { program: program.into(), args: vec!["-s".into()], command_on_stdin: true, } } else { ShellConfig { program: program.into(), args: vec!["-c".into()], command_on_stdin: false, } } } fn where_bash() -> Option { let output = Command::new("where") .arg("bash.exe") .creation_flags(CREATE_NO_WINDOW) .stdin(Stdio::null()) .stderr(Stdio::null()) .output() .ok()?; if !output.status.success() { return None; } let first = String::from_utf8_lossy(&output.stdout) .trim() .lines() .next()? .trim() .to_string(); path_exists(&first).then_some(first) } /// Node's shell resolution on Windows: a configured shell, Git Bash, `where bash.exe`, or `shell_unavailable`. pub fn shell_config(shell_path: Option<&str>) -> Result { if let Some(configured) = shell_path { if path_exists(configured) { return Ok(bash_config(configured)); } return Err(Failure::new( "shell_unavailable", format!("Custom shell path not found: {configured}"), )); } let mut candidates = Vec::new(); for variable in ["ProgramFiles", "ProgramFiles(x86)"] { if let Ok(directory) = std::env::var(variable) { candidates.push(format!("{directory}\\Git\\bin\\bash.exe")); } } if let Some(found) = candidates.iter().find(|candidate| path_exists(candidate)) { return Ok(bash_config(found)); } if let Some(found) = where_bash() { return Ok(bash_config(&found)); } let searched: String = candidates .iter() .map(|candidate| format!(" {candidate}\n")) .collect(); Err(Failure::new( "shell_unavailable", format!( "No bash shell found. Options:\n 1. Install Git for Windows: https://git-scm.com/download/win\n 2. Add your bash to PATH (Cygwin, MSYS2, etc.)\n 3. Configure an explicit shellPath\n\nSearched Git Bash in:\n{}", searched.trim_end() ), )) } /// libuv's `quote_cmd_arg`: how one argument is written into the command line the child splits again. pub fn quote_argument(argument: &str) -> String { if argument.is_empty() { return "\"\"".into(); } if !argument.contains([' ', '\t', '"']) { return argument.into(); } if !argument.contains(['"', '\\']) { return format!("\"{argument}\""); } // Walk backwards: backslashes before a quote (or the closing quote) are doubled, and quotes are escaped. let mut reversed = Vec::new(); let mut quote_hit = true; for character in argument.chars().rev() { reversed.push(character); if quote_hit && character == '\\' { reversed.push('\\'); } else if character == '"' { quote_hit = true; reversed.push('\\'); } else { quote_hit = false; } } let mut quoted = String::from("\""); quoted.extend(reversed.into_iter().rev()); quoted.push('"'); quoted } /// Variables libuv copies from the parent into every child environment that lacks them. const REQUIRED_VARIABLES: [&str; 11] = [ "HOMEDRIVE", "HOMEPATH", "LOGONSERVER", "PATH", "SYSTEMDRIVE", "SYSTEMROOT", "TEMP", "USERDOMAIN", "USERNAME", "USERPROFILE", "WINDIR", ]; /// The child environment as Node builds it on Windows: the variables in object order, sorted by UTF-16 code units, /// keeping the first of names that differ only in case; then libuv adds required variables the result lacks. fn child_environment(env: &Map, inherit_env: bool) -> Vec<(String, String)> { let mut entries: Vec<(String, String)> = Vec::new(); let mut set = |key: String, value: String| match entries.iter_mut().find(|(name, _)| *name == key) { Some(entry) => entry.1 = value, None => entries.push((key, value)), }; if inherit_env { for (key, value) in std::env::vars_os() { set( key.to_string_lossy().into_owned(), value.to_string_lossy().into_owned(), ); } } for (key, value) in env { if let Some(value) = value.as_str() { set(key.clone(), value.to_string()); } } entries.sort_by(|(a, _), (b, _)| a.encode_utf16().cmp(b.encode_utf16())); let mut seen = std::collections::HashSet::new(); entries.retain(|(name, _)| seen.insert(name.to_uppercase())); for required in REQUIRED_VARIABLES { if !entries .iter() .any(|(name, _)| name.eq_ignore_ascii_case(required)) && let Ok(value) = std::env::var(required) { entries.push((required.to_string(), value)); } } entries } /// Arguments quoted as libuv quotes them, Node's environment, and a hidden console. pub fn configure( command: &mut Command, args: &[String], env: &Map, inherit_env: bool, ) { for argument in args { command.raw_arg(quote_argument(argument)); } command.env_clear(); for (key, value) in child_environment(env, inherit_env) { command.env(key, value); } command.creation_flags(CREATE_NO_WINDOW); } /// libuv puts every child it starts into one job that ends its processes when the daemon exits. fn job() -> Option { static JOB: OnceLock> = OnceLock::new(); *JOB.get_or_init(|| { // SAFETY: plain Win32 calls with a zeroed, correctly sized information struct. unsafe { let job = CreateJobObjectW(std::ptr::null(), std::ptr::null()); if job.is_null() { return None; } let mut information: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = std::mem::zeroed(); information.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_BREAKAWAY_OK | JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK | JOB_OBJECT_LIMIT_DIE_ON_UNHANDLED_EXCEPTION | JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; SetInformationJobObject( job, JobObjectExtendedLimitInformation, (&information as *const JOBOBJECT_EXTENDED_LIMIT_INFORMATION).cast(), std::mem::size_of::() as u32, ); Some(job as isize) } }) } pub fn after_spawn(child: &Child) { if let Some(job) = job() { // SAFETY: both handles are valid for the duration of the call. unsafe { AssignProcessToJobObject(job as _, child.as_raw_handle() as _); } } } /// Node's `killProcessTree` on Windows: `taskkill /F /T`, not waited for. pub fn kill_tree(pid: u32) { let system_root = std::env::var("SystemRoot").unwrap_or_else(|_| "C:\\Windows".into()); let _ = Command::new(format!("{system_root}\\System32\\taskkill.exe")) .args(["/F", "/T", "/PID", &pid.to_string()]) .creation_flags(CREATE_NO_WINDOW) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn(); } /// Exit codes are unsigned 32-bit values on Windows (an access violation is 3221225477). pub fn exit_code(status: ExitStatus) -> i64 { i64::from(status.code().unwrap_or(1) as u32) } /// Windows listings keep the file system's order; nothing sorts by raw names. pub fn raw_name(_name: &OsStr) -> Option> { None } /// The per-drive working directories of the `=C:` variables, which Node's `path.resolve` uses for drive-relative paths. pub fn drive_cwds() -> Map { std::env::vars_os() .filter_map(|(key, value)| { let key = key.to_str()?; let bytes = key.as_bytes(); (bytes.len() == 3 && bytes[0] == b'=' && bytes[1].is_ascii_alphabetic() && bytes[2] == b':') .then(|| { ( key[1..].to_ascii_uppercase(), Value::String(value.to_string_lossy().into_owned()), ) }) }) .collect() } /// libuv's `scandir` and `opendir` report a file as `ENOTDIR` (elsewhere `ERROR_DIRECTORY` is `ENOENT`). pub fn read_dir(path: &str) -> io::Result { std::fs::read_dir(path).map_err(|error| { if error.raw_os_error() == Some(267) { synthetic("ENOTDIR") } else { error } }) } /// Volume serial number and file index of `path`, as libuv's `stat` reports `dev` and `ino`: the creation time is not /// enough, because NTFS gives a file created under a recently removed name the old one's creation time ("tunneling"). /// Opens a handle, like libuv; falls back to the metadata when the path cannot be opened. pub fn path_identity(path: &str, metadata: &Metadata, follow: bool) -> (u64, u64) { let Ok(name) = wide(path) else { return identity(metadata); }; let flags = FILE_FLAG_BACKUP_SEMANTICS | if follow { 0 } else { FILE_FLAG_OPEN_REPARSE_POINT }; // SAFETY: a NUL-terminated wide path; the handle is closed below. let handle = unsafe { CreateFileW( name.as_ptr(), 0, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, std::ptr::null(), OPEN_EXISTING, flags, std::ptr::null_mut(), ) }; if handle == INVALID_HANDLE_VALUE { return identity(metadata); } // SAFETY: a valid handle and a zeroed buffer for the call to fill. let mut information: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() }; let found = unsafe { GetFileInformationByHandle(handle, &mut information) } != 0; // SAFETY: the handle opened above. unsafe { CloseHandle(handle) }; if !found { return identity(metadata); } ( u64::from(information.dwVolumeSerialNumber), (u64::from(information.nFileIndexHigh) << 32) | u64::from(information.nFileIndexLow), ) } #[cfg(test)] mod tests { use super::quote_argument; #[test] fn quotes_arguments_like_libuv() { assert_eq!(quote_argument(""), "\"\""); assert_eq!(quote_argument("plain"), "plain"); assert_eq!(quote_argument("a b"), "\"a b\""); assert_eq!(quote_argument("*.txt"), "*.txt"); assert_eq!(quote_argument("say \"hi\""), "\"say \\\"hi\\\"\""); assert_eq!( quote_argument("C:\\dir with space\\"), "\"C:\\dir with space\\\\\"" ); assert_eq!(quote_argument("a\\\\b c"), "\"a\\\\b c\""); } }