diff --git a/CHANGELOG.md b/CHANGELOG.md index 505c1af30..8d5cfa856 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Changelog +- **Fixed** On Windows, tasks with automatic file-access tracking can now run Git Bash's `sh` and other MSYS2 or Cygwin programs, which aborted with `fatal error - MapViewOfFileEx 'shared.5'(…) Win32 error 487` ([#830](https://github.com/voidzero-dev/vite-task/issues/830), [#952](https://github.com/voidzero-dev/vite-task/pull/952)). - **Fixed** On Linux, cached tasks no longer fail to spawn child processes in restricted sandboxes, for example rootless bubblewrap that denies the `seccomp` syscall. When the `seccomp` filter for file-access tracking cannot be installed, the process runs untracked instead and the run is reported as not cached. The not-cached message now reads "automatic file tracking couldn't record everything this task did", which fits both this case and a full tracking channel ([#700](https://github.com/voidzero-dev/vite-task/issues/700), [#701](https://github.com/voidzero-dev/vite-task/pull/701)). - **Fixed** `vp run ` in a package that doesn't define the task now reports `Task "" not found` when another package's `vite.config.*` fails to load, instead of failing with that package's error. Showing the tasks to choose from loads every package's config, so the task selector or task list is skipped then ([#987](https://github.com/voidzero-dev/vite-task/pull/987)). - **Fixed** A task specifier like `vp run nope#build`, where no workspace package is named `nope`, now fails with `Package 'nope' not found in the workspace` and exits 1, including from a task's command, instead of running nothing and exiting 0. A short name that auto-completes to several scoped packages, like `app` for `@a/app` and `@b/app`, now fails as ambiguous. `--filter nope` still only warns and exits 0, unless `--fail-if-no-match` is set ([#984](https://github.com/voidzero-dev/vite-task/pull/984)). diff --git a/crates/fspy_nostd/Cargo.toml b/crates/fspy_nostd/Cargo.toml index bddf0046d..6ad5ef3b0 100644 --- a/crates/fspy_nostd/Cargo.toml +++ b/crates/fspy_nostd/Cargo.toml @@ -30,6 +30,7 @@ windows-sys = { workspace = true, features = [ "Win32_Storage_FileSystem", "Win32_System_LibraryLoader", "Win32_System_Memory", + "Win32_System_Threading", ] } [lints] diff --git a/crates/fspy_nostd/src/mm/windows.rs b/crates/fspy_nostd/src/mm/windows.rs index f2a48eb57..bd1a4927a 100644 --- a/crates/fspy_nostd/src/mm/windows.rs +++ b/crates/fspy_nostd/src/mm/windows.rs @@ -4,10 +4,19 @@ use core::{ }; use bitflags::bitflags; -use windows_sys::Win32::System::Memory::{ - CreateFileMappingW, FILE_MAP_READ, FILE_MAP_WRITE, MEM_COMMIT, MEM_RELEASE, MEM_RESERVE, - MEMORY_MAPPED_VIEW_ADDRESS, MapViewOfFile, PAGE_READWRITE, UnmapViewOfFile, VirtualAlloc, - VirtualFree, +use windows_sys::Win32::{ + Foundation::HANDLE, + System::{ + LibraryLoader::GetProcAddress, + Memory::{ + CreateFileMappingW, FILE_MAP_READ, FILE_MAP_WRITE, MEM_ADDRESS_REQUIREMENTS, + MEM_COMMIT, MEM_EXTENDED_PARAMETER, MEM_EXTENDED_PARAMETER_0, MEM_EXTENDED_PARAMETER_1, + MEM_RELEASE, MEM_RESERVE, MEMORY_MAPPED_VIEW_ADDRESS, MapViewOfFile, + MemExtendedParameterAddressRequirements, PAGE_READWRITE, UnmapViewOfFile, + VIRTUAL_ALLOCATION_TYPE, VirtualAlloc, VirtualFree, + }, + Threading::GetCurrentProcess, + }, }; use crate::{BorrowedHandle, OwnedHandle, Result, SecurityAttributes}; @@ -149,6 +158,111 @@ pub fn create_file_mapping( Ok(unsafe { OwnedHandle::from_raw_handle(mapping) }) } +/// The signature of `MapViewOfFile3`. +type MapViewOfFile3Fn = unsafe extern "system" fn( + file_mapping: HANDLE, + process: HANDLE, + base_address: *const c_void, + offset: u64, + view_size: usize, + allocation_type: VIRTUAL_ALLOCATION_TYPE, + page_protection: u32, + extended_parameters: *mut MEM_EXTENDED_PARAMETER, + parameter_count: u32, +) -> MEMORY_MAPPED_VIEW_ADDRESS; + +/// `MapViewOfFile3`, which `kernelbase.dll` exports from Windows 10 1803 on. +/// +/// A value proves that this process has the function; only +/// [`get_map_view_of_file3`] creates one. The value is `Copy`, so a caller +/// that maps often can keep it instead of looking the function up again. +#[derive(Clone, Copy)] +pub struct MapViewOfFile3 { + function: MapViewOfFile3Fn, +} + +/// Looks [`MapViewOfFile3`] up at run time rather than importing it, so this +/// crate still loads on Windows releases that predate it. +/// +/// The lookup is safe under the loader lock: `kernelbase.dll` is loaded before +/// any other DLL's `DllMain` runs, and neither `GetModuleHandleW` nor +/// `GetProcAddress` loads a module, since `kernelbase.dll` implements +/// `MapViewOfFile3` rather than forwarding it. +/// +/// Returns `None` before Windows 10 1803. +#[must_use] +pub fn get_map_view_of_file3() -> Option { + let kernelbase = crate::windows::get_module_handle(crate::wide_cstr!("kernelbase.dll")).ok()?; + // SAFETY: `kernelbase` is a loaded module handle and the symbol name is a + // NUL-terminated ANSI string. + let symbol = unsafe { GetProcAddress(kernelbase.as_ptr(), c"MapViewOfFile3".as_ptr().cast()) }?; + // SAFETY: `MapViewOfFile3` has exactly the signature of `MapViewOfFile3Fn`. + let function = unsafe { + core::mem::transmute:: isize, MapViewOfFile3Fn>(symbol) + }; + Some(MapViewOfFile3 { function }) +} + +impl MapViewOfFile3 { + /// Calls `MapViewOfFile3` to map `bytes_to_map` bytes of `mapping`, + /// starting at `offset`, into the current process at a system-chosen + /// address, no lower than `lowest_address` when one is given. + /// + /// # Errors + /// + /// Returns the error reported by `MapViewOfFile3`, for example when no + /// free range of `bytes_to_map` bytes exists above `lowest_address`. + pub fn call( + self, + mapping: BorrowedHandle<'_>, + protection: PageProtection, + offset: u64, + bytes_to_map: usize, + lowest_address: Option, + ) -> Result { + let mut requirements = MEM_ADDRESS_REQUIREMENTS { + LowestStartingAddress: ptr::without_provenance_mut(lowest_address.unwrap_or(0)), + // Null means the highest address the process can use. + HighestEndingAddress: ptr::null_mut(), + // Zero means the allocation granularity. + Alignment: 0, + }; + let mut parameter = MEM_EXTENDED_PARAMETER { + // The type occupies the low 8 bits. + Anonymous1: MEM_EXTENDED_PARAMETER_0 { + _bitfield: MemExtendedParameterAddressRequirements.cast_unsigned().into(), + }, + Anonymous2: MEM_EXTENDED_PARAMETER_1 { Pointer: (&raw mut requirements).cast() }, + }; + let (parameters, parameter_count) = + if lowest_address.is_some() { (&raw mut parameter, 1) } else { (ptr::null_mut(), 0) }; + // SAFETY: `self.function` is `MapViewOfFile3`. `mapping` keeps the + // opaque handle open for the call, and `parameters` is null or points + // at `parameter`, which points at `requirements`; both outlive the + // call. A null base address makes the system choose a free range, so + // the view cannot alias an existing allocation. Windows validates the + // handle, offset, size, protection, and address range; any mismatch is + // reported as a null result. + let view = unsafe { + (self.function)( + mapping.as_raw_handle(), + GetCurrentProcess(), + ptr::null(), + offset, + bytes_to_map, + 0, + protection as u32, + parameters, + parameter_count, + ) + }; + let Some(ptr) = NonNull::new(view.Value.cast::()) else { + return Err(crate::Error::last_os_error()); + }; + Ok(MappingView { ptr }) + } +} + /// Calls `MapViewOfFile` and returns the new owned view. /// /// # Errors @@ -178,3 +292,46 @@ pub fn map_view_of_file( }; Ok(MappingView { ptr }) } + +#[cfg(test)] +mod tests { + use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE; + + use super::*; + + const SIZE: u32 = 64 * 1024; + + fn pagefile_mapping() -> OwnedHandle { + // SAFETY: `CreateFileMappingW` defines `INVALID_HANDLE_VALUE` as a + // request for memory backed by the paging file. + let pagefile = unsafe { BorrowedHandle::borrow_raw(INVALID_HANDLE_VALUE) }; + create_file_mapping(pagefile, None, PageProtection::ReadWrite, 0, SIZE).unwrap() + } + + fn map_with_map_view_of_file3(lowest_address: Option) -> MappingView { + let mapping = pagefile_mapping(); + let view = get_map_view_of_file3() + .expect("Windows 10 1803 and later export MapViewOfFile3") + .call(mapping.as_handle(), PageProtection::ReadWrite, 0, SIZE as usize, lowest_address) + .unwrap(); + // SAFETY: the view is `SIZE` writable bytes that only this test uses. + unsafe { + view.as_ptr().add(SIZE as usize - 1).write(7); + assert_eq!(view.as_ptr().add(SIZE as usize - 1).read(), 7); + } + view + } + + #[test] + fn map_view_of_file3_maps_at_or_above_the_lowest_address() { + const LOWEST_ADDRESS: usize = 0x7000_0000_0000; + + let view = map_with_map_view_of_file3(Some(LOWEST_ADDRESS)); + assert!(view.as_ptr().addr() >= LOWEST_ADDRESS, "view at {:p}", view.as_ptr()); + } + + #[test] + fn map_view_of_file3_maps_without_a_lowest_address() { + let _view = map_with_map_view_of_file3(None); + } +} diff --git a/crates/fspy_shm/README.md b/crates/fspy_shm/README.md index 8d845c060..e5b815f14 100644 --- a/crates/fspy_shm/README.md +++ b/crates/fspy_shm/README.md @@ -40,6 +40,8 @@ The crate is `no_std`: paths arrive as platform C strings and errors are raw OS `remove` unlinks the path on Unix. On Windows it relies on [POSIX delete semantics](https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/ntddk/ns-ntddk-_file_disposition_information_ex), which requires NTFS on Windows 10 1607 or newer: the name goes away at once, while existing handles keep working and [mapped views keep the data alive](https://learn.microsoft.com/en-us/windows/win32/api/memoryapi/nf-memoryapi-createfilemappingw) until the last one goes away. +On Windows, `map` asks `MapViewOfFile3` for an address at or above `0x7000_0000_0000`, the top of Cygwin's mmap arena. The fspy DLL maps its view before `msys-2.0.dll` starts, and a 4 GiB view placed by the system lands on the address ranges Cygwin and MSYS2 programs such as Git Bash's `sh.exe` claim at fixed addresses, which makes them abort with Win32 error 487 ([#830](https://github.com/voidzero-dev/vite-task/issues/830)). Windows releases before Windows 10 1803 have no `MapViewOfFile3`; there, or when no room is left that high, `map` lets the system choose the address. + ## Options considered | Option | Decision | diff --git a/crates/fspy_shm/src/lib.rs b/crates/fspy_shm/src/lib.rs index 5070a6fed..bacf233f6 100644 --- a/crates/fspy_shm/src/lib.rs +++ b/crates/fspy_shm/src/lib.rs @@ -252,6 +252,25 @@ mod tests { } } + /// A 4 GiB view placed by the system lands on Cygwin's fixed-address + /// shared regions, which breaks every MSYS2 program a tracked task starts + /// (vite-task#830). Windows 10 1803 and later can place it above them. + #[cfg(windows)] + #[test] + fn four_gib_mapping_lies_above_the_cygwin_fixed_layout() { + const PRODUCTION_SIZE: usize = 4 * 1024 * 1024 * 1024; + + let path = BackingPath::new(); + let handle = create(path.as_c_str(), PRODUCTION_SIZE).unwrap(); + for mapping in [handle.map().unwrap(), open(path.as_c_str()).unwrap().map().unwrap()] { + assert!( + mapping.as_ptr() as usize >= super::windows::ABOVE_CYGWIN_FIXED_LAYOUT, + "view at {:p}", + mapping.as_ptr(), + ); + } + } + #[cfg(windows)] fn backing_file_sizes(path: &BackingPath) -> (u64, u64) { let file = File::open(&path.path).unwrap(); diff --git a/crates/fspy_shm/src/windows.rs b/crates/fspy_shm/src/windows.rs index a8c5142b0..6c8554ede 100644 --- a/crates/fspy_shm/src/windows.rs +++ b/crates/fspy_shm/src/windows.rs @@ -23,6 +23,12 @@ use windows_sys::Win32::{ System::{IO::DeviceIoControl, Ioctl::FSCTL_SET_SPARSE}, }; +/// The top of Cygwin's mmap arena (`MMAP_STORAGE_HIGH` in +/// `winsup/cygwin/local_includes/memory_layout.h`). Everything Cygwin places +/// at a fixed address, from the executable at `0x100400000` up through the +/// arena, lies below it, and user space continues to `0x7FFF_FFFE_FFFF`. +pub const ABOVE_CYGWIN_FIXED_LAYOUT: usize = 0x7000_0000_0000; + const SHARE_ALL: FileShare = FileShare::READ.union(FileShare::WRITE).union(FileShare::DELETE); /// Opened shared memory that is not mapped yet. @@ -237,13 +243,36 @@ impl ShmHandle { 0, 0, )?; - let view = fspy_nostd::mm::map_view_of_file( - mapping.as_handle(), - MappingAccess::READ | MappingAccess::WRITE, - 0, - 0, - len, - )?; + // Keep the view out of every range an MSYS2/Cygwin process maps at a + // fixed address. The injected DLL maps it before msys-2.0.dll starts, + // and a 4 GiB view placed by the system lands on Cygwin's shared + // regions, so `sh.exe` dies with Win32 error 487 (vite-task#830). + let above_cygwin = fspy_nostd::mm::get_map_view_of_file3().and_then(|map_view_of_file3| { + map_view_of_file3 + .call( + mapping.as_handle(), + PageProtection::ReadWrite, + 0, + len, + Some(ABOVE_CYGWIN_FIXED_LAYOUT), + ) + .ok() + }); + let view = match above_cygwin { + Some(view) => view, + // Before Windows 10 1803 there is no `MapViewOfFile3`. A failure + // above Cygwin's layout, such as a 64-bit process linked without + // `/LARGEADDRESSAWARE`, rarely leaves room lower down either, but + // letting the system choose costs nothing and keeps the old + // behavior. + None => fspy_nostd::mm::map_view_of_file( + mapping.as_handle(), + MappingAccess::READ | MappingAccess::WRITE, + 0, + 0, + len, + )?, + }; // The view remains valid after its mapping-object handle closes. Ok(Mapping { view, len }) }