From 53e9753d23fd78e55244482714c311e45a11bdd3 Mon Sep 17 00:00:00 2001 From: roboomp Date: Sun, 26 Jul 2026 18:46:57 +0000 Subject: [PATCH] fix(shell): implement native Windows stat builtin The vendored uu-stat routed every non-Unix invocation to a stub that printed "stat: unsupported on this platform" and exited 1, so the bash tool's stat builtin was dead on native Windows even though it was still advertised and registered. Add a Windows-native backend that reimplements the GNU stat directives on top of std::fs::Metadata, the windows_by_handle metadata extensions (inode, hard-link count, and device via GetFileInformationByHandle), and the Win32 volume APIs for --file-system mode. Permission bits, file type, and timestamps are synthesized from Windows attributes; ownership fields that have no Windows equivalent report 0/UNKNOWN. The format parser, printer, and BSD-compat layer are now shared across platforms and Unix behavior is unchanged. Fixes #6723 --- Cargo.lock | 1 + crates/vendor/uu-stat/Cargo.toml | 6 + crates/vendor/uu-stat/src/stat.rs | 609 +++++++++++++++++++++++++++-- packages/coding-agent/CHANGELOG.md | 4 + 4 files changed, 598 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index de2fa2468..8c27a6483 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7776,6 +7776,7 @@ dependencies = [ "tempfile", "thiserror 2.0.19", "uucore 0.8.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/crates/vendor/uu-stat/Cargo.toml b/crates/vendor/uu-stat/Cargo.toml index 5c7f30de8..a4c6607c6 100644 --- a/crates/vendor/uu-stat/Cargo.toml +++ b/crates/vendor/uu-stat/Cargo.toml @@ -18,6 +18,12 @@ thiserror = "2.0.3" uucore = { version = "0.8.0", features = ["entries", "libc", "fs", "fsext", "time"] } pi-uutils-ctx = { path = "../../pi-uutils-ctx" } +[target.'cfg(windows)'.dependencies] +windows-sys = { workspace = true, features = [ + "Win32_Foundation", + "Win32_Storage_FileSystem", +] } + [dev-dependencies] parking_lot = "0.12" tempfile = "3" diff --git a/crates/vendor/uu-stat/src/stat.rs b/crates/vendor/uu-stat/src/stat.rs index f26131eed..b11ee51b1 100644 --- a/crates/vendor/uu-stat/src/stat.rs +++ b/crates/vendor/uu-stat/src/stat.rs @@ -4,6 +4,8 @@ // file that was distributed with this source code. // spell-checker:ignore datetime +#![cfg_attr(windows, feature(windows_by_handle))] + // pi-uutils: vendored from uutils/coreutils 0.8.0 and patched to run in-process // as a shell builtin. Every filesystem syscall (stat/lstat/statfs/readlink) // resolves its path operand against the shell working directory via @@ -12,26 +14,29 @@ // typed). All process-global stdio is routed through `pi_uutils_ctx`, // `translate!` strings are literalized from locales/en-US.ftl, QUOTING_STYLE is // read from the scope environment, SELinux support is dropped, and the entry -// point no longer calls `std::process::exit`. The upstream implementation is -// unix-only (it relies on `std::os::unix`), so it lives behind `#[cfg(unix)]`; -// non-unix targets get a stub that reports the builtin as unsupported. +// point no longer calls `std::process::exit`. The upstream file-status code is +// Unix-only (it relies on `std::os::unix`), so on Windows a native backend +// reimplements the metadata directives on top of `std::fs` and the Win32 +// volume APIs; targets that are neither Unix nor Windows get a stub that +// reports the builtin as unsupported. // BSD-style invocations (`stat -f FORMAT`, macOS muscle memory) are detected // and translated to the GNU format language before argument parsing; see // `rewrite_bsd_invocation`. -#[cfg(unix)] +#[cfg(any(unix, windows))] pub use imp::{run, uu_app}; -/// pi-uutils: non-unix stub — upstream stat cannot be built off unix. -#[cfg(not(unix))] +/// pi-uutils: stub for exotic targets (wasm, etc.) that are neither Unix nor +/// Windows — upstream stat cannot be built there. +#[cfg(not(any(unix, windows)))] pub fn run(_argv: Vec) -> i32 { use std::io::Write; let _ = writeln!(pi_uutils_ctx::stderr(), "stat: unsupported on this platform"); 1 } -/// pi-uutils: minimal non-unix counterpart of the real `uu_app`. -#[cfg(not(unix))] +/// pi-uutils: minimal counterpart of the real `uu_app` for exotic targets. +#[cfg(not(any(unix, windows)))] pub fn uu_app() -> clap::Command { clap::Command::new("stat") .version(uucore::crate_version!()) @@ -39,25 +44,33 @@ pub fn uu_app() -> clap::Command { .override_usage(pi_uutils_ctx::format_usage("stat [OPTION]... FILE...")) } -#[cfg(unix)] +#[cfg(any(unix, windows))] mod imp { + #[cfg(unix)] + use std::os::unix::fs::{FileTypeExt, MetadataExt}; + #[cfg(windows)] + use std::os::windows::fs::MetadataExt; use std::{ borrow::Cow, cell::OnceCell, ffi::{OsStr, OsString}, fs::{self, FileType, Metadata}, io::Write, - os::unix::fs::{FileTypeExt, MetadataExt}, path::Path, }; use clap::{Arg, ArgAction, ArgMatches, Command, builder::ValueParser}; use pi_uutils_ctx::format_usage; use thiserror::Error; + #[cfg(windows)] + use uucore::time::{FormatSystemTimeFallback, format_system_time, system_time_to_sec}; use uucore::{ display::Quotable, - entries, error::{UError, UResult, USimpleError}, + }; + #[cfg(unix)] + use uucore::{ + entries, fs::{display_permissions, major, minor}, fsext::{ FsMeta, MetadataTimeField, StatFs, metadata_get_time, pretty_filetype, pretty_fstype, @@ -214,6 +227,8 @@ for details about the options it supports."; /// /// On Unix systems, this preserves non-UTF8 data by printing raw bytes /// On other platforms, falls back to lossy string conversion + // pi-uutils: raw-byte output is only reachable via `%m` (OsStr) on Unix. + #[cfg(unix)] fn pad_and_print_bytes( mut writer: W, bytes: &[u8], @@ -249,6 +264,7 @@ for details about the options it supports."; /// write padding based on a writer W and n size /// writer is genric to be any buffer like: `std::io::stdout` /// n is the calculated padding size + #[cfg(unix)] fn write_padding(writer: &mut W, n: usize) -> Result<(), std::io::Error> { for _ in 0..n { writer.write_all(b" ")?; @@ -523,18 +539,28 @@ for details about the options it supports."; /// * `width` - The width of the field for the printed string. /// * `precision` - How many digits of precision, if any. fn print_os_str(s: &OsString, flags: Flags, width: usize, precision: Precision) { - // pi-uutils: this module is unix-only, so upstream's `cfg(not(unix))` - // lossy fallback branch is dropped; bytes go to the context stdout. - use std::os::unix::ffi::OsStrExt; - - let bytes = s.as_bytes(); - - if pad_and_print_bytes(pi_uutils_ctx::stdout(), bytes, flags.left, width, precision).is_err() + // pi-uutils: on Unix, preserve non-UTF8 operands by printing raw bytes + // (upstream behavior); the `OsStr` output type is only produced by the + // `%m` mount-point directive, which is Unix-only. Elsewhere fall back to + // a lossy string so the code compiles and behaves sensibly. + #[cfg(unix)] { - // if an error occurred while trying to print bytes fall back to normal lossy - // string so it can be printed - let fallback_string = s.to_string_lossy(); - print_str(&fallback_string, flags, width, precision); + use std::os::unix::ffi::OsStrExt; + + let bytes = s.as_bytes(); + + if pad_and_print_bytes(pi_uutils_ctx::stdout(), bytes, flags.left, width, precision) + .is_err() + { + // if an error occurred while trying to print bytes fall back to normal lossy + // string so it can be printed + let fallback_string = s.to_string_lossy(); + print_str(&fallback_string, flags, width, precision); + } + } + #[cfg(not(unix))] + { + print_str(&s.to_string_lossy(), flags, width, precision); } } @@ -589,6 +615,7 @@ for details about the options it supports."; } } + #[cfg(unix)] fn process_token_filesystem(t: &Token, meta: &StatFs, display_name: &str) { match *t { Token::Byte(byte) => write_raw_byte(byte), @@ -1033,6 +1060,7 @@ for details about the options it supports."; Ok(tokens) } + #[cfg(unix)] fn populate_mount_list() -> UResult> { let mut mount_list = read_fs_list() .map_err(|e| { @@ -1101,6 +1129,7 @@ for details about the options it supports."; }) } + #[cfg(unix)] fn find_mount_point>(&self, p: P) -> Option<&OsString> { if !self.mount_list_needed { return None; @@ -1129,6 +1158,7 @@ for details about the options it supports."; .find(|root| path.starts_with(root)) } + #[cfg(unix)] fn exec(&self) -> i32 { let mut stdin_is_fifo = false; if let Ok(md) = fs::metadata("/dev/stdin") { @@ -1142,6 +1172,7 @@ for details about the options it supports."; ret } + #[cfg(unix)] fn process_token_files( &self, t: &Token, @@ -1280,6 +1311,7 @@ for details about the options it supports."; Ok(()) } + #[cfg(unix)] fn do_stat(&self, file: &OsStr, stdin_is_fifo: bool) -> i32 { let display_name = file.to_string_lossy(); let file = if display_name == "-" { @@ -1822,6 +1854,7 @@ for details about the options it supports."; const PRETTY_DATETIME_FORMAT: &str = "%Y-%m-%d %H:%M:%S.%N %z"; + #[cfg(unix)] fn pretty_time(meta: &Metadata, md_time_field: MetadataTimeField) -> String { if let Some(time) = metadata_get_time(meta, md_time_field) { let mut tmp = Vec::new(); @@ -1934,6 +1967,424 @@ for details about the options it supports."; assert_eq!(precision_trunc(123.456, Precision::Number(5)), "123.45600"); } } + /// pi-uutils: Windows-native metadata helpers for `stat`. Upstream's + /// file-status path is Unix-only (`std::os::unix`); this reimplements the + /// GNU directives on top of `std::fs::Metadata`, the `windows_by_handle` + /// metadata extensions (inode / link count / device via + /// `GetFileInformationByHandle`), and the Win32 volume APIs for + /// `--file-system` mode. + #[cfg(windows)] + mod win { + use std::{ + ffi::OsStr, fs::Metadata, os::windows::fs::MetadataExt, path::Path, time::SystemTime, + }; + + /// `FILE_ATTRIBUTE_READONLY`. + const READONLY: u32 = 0x0000_0001; + + // POSIX `st_mode` type bits, synthesized for `%f`/`%F`/`%A`/`%a`. + const S_IFDIR: u32 = 0o040000; + const S_IFREG: u32 = 0o100000; + const S_IFLNK: u32 = 0o120000; + + /// Which timestamp a directive refers to. Windows exposes creation, + /// access, and write times; it has no POSIX "status change" time, so + /// `%z`/`%Z` reuse the write time (last data modification), matching + /// how ports such as Cygwin's `stat` behave. + #[derive(Clone, Copy)] + pub enum TimeField { + Access, + Modification, + Change, + Birth, + } + + /// Resolve a [`TimeField`] to the corresponding [`SystemTime`], or + /// `None` when the platform cannot supply it. + pub fn md_time(md: &Metadata, field: TimeField) -> Option { + match field { + TimeField::Access => md.accessed().ok(), + TimeField::Modification | TimeField::Change => md.modified().ok(), + TimeField::Birth => md.created().ok(), + } + } + + /// Synthesize a POSIX-style `st_mode` from Windows attributes: the file + /// type bits plus a best-effort permission mask (read-only files/dirs + /// drop their write bits; directories and symlinks are traversable). + pub fn synth_mode(md: &Metadata) -> u32 { + let readonly = md.file_attributes() & READONLY != 0; + let ft = md.file_type(); + if ft.is_symlink() { + S_IFLNK | 0o777 + } else if ft.is_dir() { + S_IFDIR | if readonly { 0o555 } else { 0o755 } + } else { + S_IFREG | if readonly { 0o444 } else { 0o644 } + } + } + + /// Human-readable file type for `%F`, mirroring uucore's + /// `pretty_filetype` for the types reachable on Windows. + pub fn file_type_str(mode: u32, size: u64) -> String { + match mode & 0o170000 { + S_IFDIR => "directory", + S_IFLNK => "symbolic link", + _ if size == 0 => "regular empty file", + _ => "regular file", + } + .to_string() + } + + /// `ls -l`-style permission string for `%A` derived from the synthetic + /// mode (e.g. `drwxr-xr-x`). + pub fn perms_string(mode: u32) -> String { + let mut s = String::with_capacity(10); + s.push(match mode & 0o170000 { + S_IFDIR => 'd', + S_IFLNK => 'l', + _ => '-', + }); + for shift in [6u32, 3, 0] { + let bits = (mode >> shift) & 0o7; + s.push(if bits & 0o4 != 0 { 'r' } else { '-' }); + s.push(if bits & 0o2 != 0 { 'w' } else { '-' }); + s.push(if bits & 0o1 != 0 { 'x' } else { '-' }); + } + s + } + + /// File-system status collected for `stat --file-system` on Windows. + pub struct StatFs { + pub fs_type: String, + pub serial: u64, + pub name_len: u64, + pub cluster_size: u64, + pub total_blocks: u64, + pub free_blocks: u64, + } + + /// Query volume information for the file's containing volume via Win32. + /// Returns a human-readable error string on failure (mapped to the GNU + /// "cannot read file system information" message by the caller). + pub fn statfs(path: &Path) -> Result { + use std::os::windows::ffi::OsStrExt; + + use windows_sys::Win32::Storage::FileSystem::{ + GetDiskFreeSpaceW, GetVolumeInformationW, GetVolumePathNameW, + }; + + fn wide(s: &OsStr) -> Vec { + s.encode_wide().chain(std::iter::once(0)).collect() + } + + fn wide_to_string(buf: &[u16]) -> String { + let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len()); + String::from_utf16_lossy(&buf[..len]) + } + + let file_wide = wide(path.as_os_str()); + // Resolve the mount root (e.g. `C:\`) that owns the path. + let mut root = [0u16; 260]; + // SAFETY: `file_wide` is NUL-terminated and `root` is a valid + // mutable buffer whose capacity is passed as `root.len()`. + if unsafe { GetVolumePathNameW(file_wide.as_ptr(), root.as_mut_ptr(), root.len() as u32) } + == 0 + { + return Err(std::io::Error::last_os_error().to_string()); + } + + let mut fs_name = [0u16; 260]; + let mut serial: u32 = 0; + let mut max_component: u32 = 0; + let mut flags: u32 = 0; + // SAFETY: `root` is a NUL-terminated path; the serial/flag out + // params are valid `&mut u32`; `fs_name` is a valid buffer sized by + // `fs_name.len()`; the volume-name buffer is null with size 0. + if unsafe { + GetVolumeInformationW( + root.as_ptr(), + std::ptr::null_mut(), + 0, + &mut serial, + &mut max_component, + &mut flags, + fs_name.as_mut_ptr(), + fs_name.len() as u32, + ) + } == 0 + { + return Err(std::io::Error::last_os_error().to_string()); + } + + let mut sectors_per_cluster: u32 = 0; + let mut bytes_per_sector: u32 = 0; + let mut free_clusters: u32 = 0; + let mut total_clusters: u32 = 0; + // SAFETY: `root` is a NUL-terminated path and every out param is a + // valid `&mut u32`. + if unsafe { + GetDiskFreeSpaceW( + root.as_ptr(), + &mut sectors_per_cluster, + &mut bytes_per_sector, + &mut free_clusters, + &mut total_clusters, + ) + } == 0 + { + return Err(std::io::Error::last_os_error().to_string()); + } + + let cluster_size = u64::from(sectors_per_cluster) * u64::from(bytes_per_sector); + Ok(StatFs { + fs_type: wide_to_string(&fs_name), + serial: u64::from(serial), + name_len: u64::from(max_component), + cluster_size, + total_blocks: u64::from(total_clusters), + free_blocks: u64::from(free_clusters), + }) + } + } + + /// pi-uutils: Windows counterpart of `pretty_time`, formatting a + /// `std::fs::Metadata` timestamp through the shared datetime format. + #[cfg(windows)] + fn pretty_time(meta: &Metadata, field: win::TimeField) -> String { + if let Some(time) = win::md_time(meta, field) { + let mut tmp = Vec::new(); + if format_system_time( + &mut tmp, + time, + PRETTY_DATETIME_FORMAT, + FormatSystemTimeFallback::Float, + ) + .is_ok() + { + return String::from_utf8(tmp).unwrap(); + } + } + "-".to_string() + } + + /// pi-uutils: Windows counterpart of `process_token_filesystem`. + #[cfg(windows)] + fn process_token_filesystem(t: &Token, meta: &win::StatFs, display_name: &str) { + match *t { + Token::Byte(byte) => write_raw_byte(byte), + Token::Char(c) => { + let _ = write!(pi_uutils_ctx::stdout(), "{c}"); + }, + Token::Directive { flag, width, precision, format } => { + let output = match format { + // free blocks available to non-superuser + 'a' => OutputType::Unsigned(meta.free_blocks), + // total data blocks in file system + 'b' => OutputType::Unsigned(meta.total_blocks), + // total / free file nodes (not tracked on Windows) + 'c' | 'd' => OutputType::Unsigned(0), + // free blocks in file system + 'f' => OutputType::Unsigned(meta.free_blocks), + // file system ID in hex (volume serial number) + 'i' => OutputType::UnsignedHex(meta.serial), + // maximum length of filenames + 'l' => OutputType::Unsigned(meta.name_len), + // file name + 'n' => OutputType::Str(display_name.to_string()), + // block size (for faster transfers) + 's' => OutputType::Unsigned(meta.cluster_size), + // fundamental block size (for block counts) + 'S' => OutputType::Integer(meta.cluster_size as i64), + // file system type in hex (no numeric magic on Windows) + 't' => OutputType::UnsignedHex(0), + // file system type in human readable form + 'T' => OutputType::Str(meta.fs_type.clone()), + _ => OutputType::Unknown, + }; + print_it(&output, flag, width, precision); + }, + } + } + + #[cfg(windows)] + impl Stater { + fn exec(&self) -> i32 { + let mut ret = 0; + for f in &self.files { + ret |= self.do_stat(f); + } + ret + } + + fn process_token_files( + &self, + t: &Token, + meta: &Metadata, + display_name: &str, + resolved: &Path, + file_type: FileType, + from_user: bool, + ) -> Result<(), i32> { + match *t { + Token::Byte(byte) => write_raw_byte(byte), + Token::Char(c) => { + let _ = write!(pi_uutils_ctx::stdout(), "{c}"); + }, + Token::Directive { flag, width, precision, format } => { + let mode = win::synth_mode(meta); + let output = match format { + // access rights in octal + 'a' => OutputType::UnsignedOct(0o7777 & mode), + // access rights in human readable form + 'A' => OutputType::Str(win::perms_string(mode)), + // number of blocks allocated (512-byte units, see %B) + 'b' => OutputType::Unsigned(meta.len().div_ceil(512)), + // the size in bytes of each block reported by %b + 'B' => OutputType::Unsigned(512), + // SELinux security context string (unsupported) + 'C' => OutputType::Str("unsupported for this operating system".to_string()), + // device number: Windows volume serial number + 'd' if flag.major || flag.minor => OutputType::Unsigned(0), + 'd' => OutputType::Unsigned(meta.volume_serial_number().map_or(0, u64::from)), + // device number in hex + 'D' => OutputType::UnsignedHex(meta.volume_serial_number().map_or(0, u64::from)), + // raw mode in hex + 'f' => OutputType::UnsignedHex(u64::from(mode)), + // file type + 'F' => OutputType::Str(win::file_type_str(mode, meta.len())), + // group ID of owner (not modeled on Windows) + 'g' => OutputType::Unsigned(0), + // group name of owner + 'G' => OutputType::Str("UNKNOWN".to_string()), + // number of hard links + 'h' => OutputType::Unsigned(meta.number_of_links().map_or(1, u64::from)), + // inode number (NTFS file index) + 'i' => OutputType::Unsigned(meta.file_index().unwrap_or(0)), + // mount point (not resolved on Windows) + 'm' => OutputType::Str(String::new()), + // file name + 'n' => OutputType::Str(display_name.to_string()), + // quoted file name with dereference if symbolic link + 'N' => OutputType::Str(get_quoted_file_name( + display_name, + resolved, + file_type, + from_user, + )?), + // optimal I/O transfer size hint + 'o' => OutputType::Unsigned(4096), + // total size, in bytes + 's' => OutputType::Integer(meta.len() as i64), + // device type (no special files on Windows) + 't' | 'T' => OutputType::UnsignedHex(0), + // user ID of owner (not modeled on Windows) + 'u' => OutputType::Unsigned(0), + // user name of owner + 'U' => OutputType::Str("UNKNOWN".to_string()), + // time of file birth, human-readable; - if unknown + 'w' => OutputType::Str(pretty_time(meta, win::TimeField::Birth)), + // time of file birth, seconds since Epoch; 0 if unknown + 'W' => OutputType::Integer( + win::md_time(meta, win::TimeField::Birth) + .map_or(0, |x| system_time_to_sec(x).0), + ), + // time of last access, human-readable + 'x' => OutputType::Str(pretty_time(meta, win::TimeField::Access)), + // time of last access, seconds since Epoch + 'X' => { + let (sec, nsec) = win::md_time(meta, win::TimeField::Access) + .map_or((0, 0), system_time_to_sec); + OutputType::Float(sec as f64 + nsec as f64 / 1_000_000_000.0) + }, + // time of last data modification, human-readable + 'y' => OutputType::Str(pretty_time(meta, win::TimeField::Modification)), + // time of last data modification, seconds since Epoch + 'Y' => { + let (sec, nsec) = win::md_time(meta, win::TimeField::Modification) + .map_or((0, 0), system_time_to_sec); + OutputType::Float(sec as f64 + nsec as f64 / 1_000_000_000.0) + }, + // time of last status change, human-readable (write time) + 'z' => OutputType::Str(pretty_time(meta, win::TimeField::Change)), + // time of last status change, seconds since Epoch + 'Z' => { + let (sec, nsec) = win::md_time(meta, win::TimeField::Change) + .map_or((0, 0), system_time_to_sec); + OutputType::Float(sec as f64 + nsec as f64 / 1_000_000_000.0) + }, + // rdev (no device special files on Windows) + 'R' => OutputType::UnsignedHex(0), + 'r' => OutputType::Unsigned(0), + _ => OutputType::Unknown, + }; + print_it(&output, flag, width, precision); + }, + } + Ok(()) + } + + fn do_stat(&self, file: &OsStr) -> i32 { + let display_name = file.to_string_lossy(); + // pi-uutils: resolve the operand against the shell working + // directory; `display_name` keeps the operand as typed for `%n` + // and error messages. + let resolved = pi_uutils_ctx::resolve(file); + if self.show_fs { + match win::statfs(&resolved) { + Ok(meta) => { + for t in &self.default_tokens { + process_token_filesystem(t, &meta, &display_name); + } + }, + Err(error) => { + let _ = writeln!( + pi_uutils_ctx::stderr(), + "stat: {}", + StatError::CannotReadFilesystemInfo { + file: display_name.quote().to_string(), + error, + } + ); + return 1; + }, + } + } else { + let result = if self.follow { + fs::metadata(&resolved) + } else { + fs::symlink_metadata(&resolved) + }; + match result { + Ok(meta) => { + let file_type = meta.file_type(); + // Windows has no character/block special files, so the + // device-type default format is never selected. + for t in &self.default_tokens { + if let Err(code) = self.process_token_files( + t, + &meta, + &display_name, + &resolved, + file_type, + self.from_user, + ) { + return code; + } + } + }, + Err(e) => { + let _ = writeln!(pi_uutils_ctx::stderr(), "stat: {}", StatError::CannotStat { + file: display_name.quote().to_string(), + error: e.to_string(), + }); + return 1; + }, + } + } + 0 + } + } } #[cfg(all(test, unix))] @@ -2173,3 +2624,117 @@ mod tests { ); } } + +#[cfg(all(test, windows))] +mod win_tests { + use std::{collections::HashMap, ffi::OsString, fs, io::Write, path::PathBuf, sync::Arc}; + + use parking_lot::Mutex; + use pi_uutils_ctx::ScopeIo; + + use super::run; + + fn run_in(cwd: PathBuf, args: Vec<&str>) -> (i32, String, String) { + let stdout_buf = Arc::new(Mutex::new(Vec::new())); + let stderr_buf = Arc::new(Mutex::new(Vec::new())); + + #[derive(Clone)] + struct SharedWriter { + buf: Arc>>, + } + impl Write for SharedWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.buf.lock().write(buf) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.buf.lock().flush() + } + } + + let io = ScopeIo { + stdin: Box::new(std::io::empty()), + stdin_fd: None, + stdin_is_search_input: false, + stdout: Box::new(SharedWriter { buf: stdout_buf.clone() }), + stderr: Box::new(SharedWriter { buf: stderr_buf.clone() }), + cwd, + env: HashMap::new(), + cancel: Arc::new(std::sync::atomic::AtomicBool::new(false)), + }; + + let argv: Vec = std::iter::once("stat") + .chain(args) + .map(OsString::from) + .collect(); + + let code = pi_uutils_ctx::scope(io, || run(argv)); + let out_str = String::from_utf8(stdout_buf.lock().clone()).unwrap(); + let err_str = String::from_utf8(stderr_buf.lock().clone()).unwrap(); + (code, out_str, err_str) + } + + fn tempdir() -> (tempfile::TempDir, PathBuf) { + let dir = tempfile::tempdir().unwrap(); + let canon = fs::canonicalize(dir.path()).unwrap(); + (dir, canon) + } + + /// Regression for #6723: on native Windows `stat` deterministically exited + /// 1 ("unsupported on this platform"). It must now succeed and report the + /// real size and file type. + #[test] + fn reports_size_and_type_for_regular_file() { + let (_dir, root) = tempdir(); + fs::write(root.join("data.bin"), b"hello world!").unwrap(); + + let (code, stdout, stderr) = run_in(root, vec!["-c", "%s %F", "data.bin"]); + assert_eq!(code, 0); + assert_eq!(stdout, "12 regular file\n"); + assert_eq!(stderr, ""); + } + + #[test] + fn empty_file_reports_regular_empty_file() { + let (_dir, root) = tempdir(); + fs::write(root.join("empty.bin"), b"").unwrap(); + + let (code, stdout, stderr) = run_in(root, vec!["-c", "%F", "empty.bin"]); + assert_eq!(code, 0); + assert_eq!(stdout, "regular empty file\n"); + assert_eq!(stderr, ""); + } + + #[test] + fn percent_n_prints_operand_as_typed() { + let (_dir, root) = tempdir(); + fs::write(root.join("data.bin"), b"x").unwrap(); + + let (code, stdout, stderr) = run_in(root, vec!["-c", "%n", "data.bin"]); + assert_eq!(code, 0); + assert_eq!(stdout, "data.bin\n"); + assert_eq!(stderr, ""); + } + + #[test] + fn nonexistent_file_reports_cannot_stat() { + let (_dir, root) = tempdir(); + + let (code, stdout, stderr) = run_in(root, vec!["-c", "%s", "missing.bin"]); + assert_eq!(code, 1); + assert_eq!(stdout, ""); + assert!(stderr.contains("cannot stat"), "unexpected stderr: {stderr:?}"); + } + + /// `--file-system` mode goes through the Win32 volume backend and must not + /// error on a real path. + #[test] + fn file_system_mode_succeeds() { + let (_dir, root) = tempdir(); + fs::write(root.join("data.bin"), b"x").unwrap(); + + let (code, _stdout, stderr) = run_in(root, vec!["-f", "-c", "%T", "data.bin"]); + assert_eq!(code, 0); + assert_eq!(stderr, ""); + } +} diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index e4352d056..3aeb3d32f 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed the bash tool's `stat` builtin failing on native Windows with `stat: unsupported on this platform` (exit 1) for every invocation. The vendored `uu-stat` now ships a Windows-native backend that maps the GNU format directives onto `std::fs::Metadata`, the `windows_by_handle` metadata extensions (inode, hard-link count, and device via `GetFileInformationByHandle`), and the Win32 volume APIs for `--file-system` mode; Unix behavior is unchanged ([#6723](https://github.com/can1357/oh-my-pi/issues/6723)). + ## [17.1.4] - 2026-07-26 ### Added