From 3a2de3a3afc65b481addac93dc376b0753d169c7 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Mon, 12 Oct 2026 14:38:55 +0800 Subject: [PATCH 1/2] fix(fspy): keep the main executable at dyld image index 0 on macOS dyld lists DYLD_INSERT_LIBRARIES images ahead of the main executable, so injecting fspy's preload moves the main executable off image index 0. Node.js single executable applications (postject's `getsectdata(..) + _dyld_get_image_vmaddr_slide(0)`) and `deno compile` outputs (denoland/sui, same pattern) then add the preload's slide to their payload address and fail: pnpm 11's standalone binary aborts with `Assertion failed: (magic) == (kMagic)`, and Deno reports `Did not find magic bytes`. Interpose `_dyld_get_image_header`, `_dyld_get_image_vmaddr_slide` and `_dyld_get_image_name` to present the main executable at index 0 followed by the inserted libraries, the same permutation dyld's own `normalizeImageIndex` applies to old x86_64 apps. Add an fspy test that builds a Node SEA with postject and runs it under tracking. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + crates/fspy/tests/node_sea.rs | 92 +++++++++++++++++++ .../src/interceptions/dyld.rs | 68 ++++++++++++++ .../src/interceptions/mod.rs | 2 + crates/fspy_preload_unix/src/libc.rs | 22 +++++ packages/tools/package.json | 1 + pnpm-lock.yaml | 21 +++++ pnpm-workspace.yaml | 1 + 8 files changed, 208 insertions(+) create mode 100644 crates/fspy/tests/node_sea.rs create mode 100644 crates/fspy_preload_unix/src/interceptions/dyld.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0836435a3..d3077af05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Changelog +- **Fixed** On macOS, tasks with automatic file-access tracking can now run Node.js single executable applications, such as pnpm 11's standalone `pnpm` binary, which previously aborted with `Assertion failed: (magic) == (kMagic)`, and `deno compile` outputs, which previously failed with `Did not find magic bytes` ([#???](https://github.com/voidzero-dev/vite-task/pull/???)). - **Fixed** Reinstalling dependencies with pnpm, or installing them at another path, no longer causes cache misses, and tasks that run `pnpm run` or `pnpm exec` no longer miss the cache after pnpm rechecks dependencies ([#823](https://github.com/voidzero-dev/vite-task/pull/823), [#824](https://github.com/voidzero-dev/vite-task/pull/824), [#825](https://github.com/voidzero-dev/vite-task/pull/825)). - **Added** Vite+ now builds and runs on platforms without file-access tracking support, such as FreeBSD. On those platforms, automatic input and output tracking is unavailable, so tasks that rely on it report that tracking is unsupported and must configure their `input` and `output` manually to be cached ([#829](https://github.com/voidzero-dev/vite-task/pull/829)). - **Changed** The `vp run` summary now reads like `3/4 cache hit (75%), 9/10 successful, 2s saved.` The cache hit count leaves out tasks with caching disabled, and the count of successful tasks out of all tasks replaces the count of failed tasks. When no task has caching enabled, the summary shows only the successful count. In `vp run --verbose` and `vp run --last-details`, the cache hit rate also leaves out tasks with caching disabled, and says `no task has caching enabled` when there are none ([#821](https://github.com/voidzero-dev/vite-task/pull/821)). diff --git a/crates/fspy/tests/node_sea.rs b/crates/fspy/tests/node_sea.rs new file mode 100644 index 000000000..3b432a6f0 --- /dev/null +++ b/crates/fspy/tests/node_sea.rs @@ -0,0 +1,92 @@ +mod test_utils; + +use std::{ + env::var_os, + fs, + path::{Path, PathBuf}, + process::Command, +}; + +use fspy::AccessMode; +use test_log::test; +use test_utils::assert_contains; + +fn run(command: &mut Command) -> anyhow::Result> { + let output = command.output()?; + anyhow::ensure!( + output.status.success(), + "{command:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + Ok(output.stdout) +} + +/// Builds a Node.js single executable application that reads the file named +/// by its first argument. +/// +fn build_node_sea(dir: &Path) -> anyhow::Result { + let manifest_dir = PathBuf::from(var_os("CARGO_MANIFEST_DIR").unwrap()); + let postject = manifest_dir + .parent() + .unwrap() + .parent() + .unwrap() + .join("packages/tools/node_modules/postject/dist/cli.js"); + + // `node` on PATH may be a version manager shim; copy the real binary. + let node = PathBuf::from( + String::from_utf8(run(Command::new("node").args(["-p", "process.execPath"]))?)?.trim_end(), + ); + + fs::write(dir.join("main.js"), "require('node:fs').readFileSync(process.argv[2]);")?; + fs::write( + dir.join("sea-config.json"), + r#"{ "main": "main.js", "output": "sea-prep.blob", "disableExperimentalSEAWarning": true }"#, + )?; + run(Command::new(&node) + .arg("--experimental-sea-config") + .arg("sea-config.json") + .current_dir(dir))?; + + let sea = dir.join(if cfg!(windows) { "sea.exe" } else { "sea" }); + fs::copy(&node, &sea)?; + #[cfg(target_os = "macos")] + run(Command::new("codesign").arg("--remove-signature").arg(&sea))?; + + let mut inject = Command::new(&node); + inject.arg(postject).arg(&sea).args([ + "NODE_SEA_BLOB", + "sea-prep.blob", + "--sentinel-fuse", + "NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2", + ]); + #[cfg(target_os = "macos")] + inject.args(["--macho-segment-name", "NODE_SEA"]); + run(inject.current_dir(dir))?; + + #[cfg(target_os = "macos")] + run(Command::new("codesign").args(["--sign", "-"]).arg(&sea))?; + Ok(sea) +} + +// On macOS, the injected preload must not hide the embedded application from +// Node's lookup. +#[test(tokio::test)] +#[ignore = "requires node and `pnpm install` (workspace root)"] +async fn node_sea() -> anyhow::Result<()> { + let tmpdir = tempfile::tempdir()?; + // on macOS, tmpdir.path() may be a symlink, so we need to canonicalize it + let tmpdir_path = fs::canonicalize(tmpdir.path())?; + let sea = build_node_sea(&tmpdir_path)?; + let input = tmpdir_path.join("input.txt"); + fs::write(&input, "")?; + + let mut command = fspy::Command::new(&sea); + command.arg(&input); + let termination = + command.spawn(tokio_util::sync::CancellationToken::new()).await?.wait_handle.await?; + assert!(termination.status.success(), "{:?}", termination.status); + let accesses = termination.path_accesses.expect("the test region holds every record"); + assert_contains(&accesses, &input, AccessMode::READ); + Ok(()) +} diff --git a/crates/fspy_preload_unix/src/interceptions/dyld.rs b/crates/fspy_preload_unix/src/interceptions/dyld.rs new file mode 100644 index 000000000..9ea93d026 --- /dev/null +++ b/crates/fspy_preload_unix/src/interceptions/dyld.rs @@ -0,0 +1,68 @@ +//! dyld lists `DYLD_INSERT_LIBRARIES` images before the main executable, so +//! injecting the preload moves the main executable away from image index 0. +//! Code that assumes index 0 is the main executable then reads another +//! image's header or slide: Node.js single executable applications (via +//! postject's `getsectdata(..) + _dyld_get_image_vmaddr_slide(0)`) and +//! `deno compile` outputs fail to find their embedded payload. These +//! interposers present the main executable at index 0, followed by the +//! inserted libraries. +//! +//! dyld moves inserted libraries ahead of the main executable "for correct +//! flat namespace lookups" ([`RuntimeState::loadInsertedLibraries`]) and +//! applies the same permutation itself, but only for `x86_64` apps targeting +//! macOS before 12 ([`normalizeImageIndex`]). Where dyld already applies it, +//! the main executable is at index 0 and the mapping below is the identity. +//! +//! [`RuntimeState::loadInsertedLibraries`]: https://github.com/apple-oss-distributions/dyld/blob/fd8d0c4d52320ebf64db34f3cb280310d905c5ae/dyld/DyldRuntimeState.cpp#L676-L680 +//! [`normalizeImageIndex`]: https://github.com/apple-oss-distributions/dyld/blob/fd8d0c4d52320ebf64db34f3cb280310d905c5ae/dyld/DyldAPIs.cpp#L238-L253 + +use crate::{ + libc::{c_char, intptr_t, mach_header}, + macros::intercept, +}; + +/// `MH_EXECUTE` from ``. +const MH_EXECUTE: u32 = 0x2; + +/// Maps an index in the presented order to dyld's actual index. +/// +/// The main executable is looked up on every call rather than cached: its +/// index stays fixed only because of undocumented dyld behavior. +fn actual_image_index(image_index: u32) -> u32 { + let mut main_index = 0; + loop { + // SAFETY: dyld returns null for out-of-range indices. + let header = unsafe { _dyld_get_image_header::original()(main_index) }; + if header.is_null() { + return image_index; + } + // SAFETY: a non-null header points to the loaded image's mach header. + if unsafe { (*header).filetype } == MH_EXECUTE { + break; + } + main_index += 1; + } + match image_index { + 0 => main_index, + index if index <= main_index => index - 1, + index => index, + } +} + +intercept!(_dyld_get_image_header: unsafe extern "C" fn(image_index: u32) -> *const mach_header); +unsafe extern "C" fn _dyld_get_image_header(image_index: u32) -> *const mach_header { + // SAFETY: forwarding to the original dyld function with a remapped index. + unsafe { _dyld_get_image_header::original()(actual_image_index(image_index)) } +} + +intercept!(_dyld_get_image_vmaddr_slide: unsafe extern "C" fn(image_index: u32) -> intptr_t); +unsafe extern "C" fn _dyld_get_image_vmaddr_slide(image_index: u32) -> intptr_t { + // SAFETY: forwarding to the original dyld function with a remapped index. + unsafe { _dyld_get_image_vmaddr_slide::original()(actual_image_index(image_index)) } +} + +intercept!(_dyld_get_image_name: unsafe extern "C" fn(image_index: u32) -> *const c_char); +unsafe extern "C" fn _dyld_get_image_name(image_index: u32) -> *const c_char { + // SAFETY: forwarding to the original dyld function with a remapped index. + unsafe { _dyld_get_image_name::original()(actual_image_index(image_index)) } +} diff --git a/crates/fspy_preload_unix/src/interceptions/mod.rs b/crates/fspy_preload_unix/src/interceptions/mod.rs index 0d3742ea7..4d9712dd1 100644 --- a/crates/fspy_preload_unix/src/interceptions/mod.rs +++ b/crates/fspy_preload_unix/src/interceptions/mod.rs @@ -1,5 +1,7 @@ mod access; mod dirent; +#[cfg(target_os = "macos")] +mod dyld; mod open; mod spawn; mod stat; diff --git a/crates/fspy_preload_unix/src/libc.rs b/crates/fspy_preload_unix/src/libc.rs index 656779817..55f5ac544 100644 --- a/crates/fspy_preload_unix/src/libc.rs +++ b/crates/fspy_preload_unix/src/libc.rs @@ -1,5 +1,16 @@ pub use libc::*; +/// The leading fields of `mach_header` from ``, which +/// `libc` deprecates in favor of the `mach2` crate. +#[cfg(target_os = "macos")] +#[repr(C)] +pub struct mach_header { + pub magic: u32, + pub cputype: i32, + pub cpusubtype: i32, + pub filetype: u32, +} + unsafe extern "C" { // On macOS x86_64, directory functions use $INODE64 symbol suffix for 64-bit inode support. // On arm64, 64-bit inodes are the only option so no suffix is needed. @@ -43,4 +54,15 @@ unsafe extern "C" { buf_len: usize, basep: *mut i64, ) -> isize; + + // Declared here because `libc` deprecates its dyld bindings in favor of + // the `mach2` crate. + #[cfg(target_os = "macos")] + pub unsafe fn _dyld_get_image_header(image_index: u32) -> *const mach_header; + + #[cfg(target_os = "macos")] + pub unsafe fn _dyld_get_image_vmaddr_slide(image_index: u32) -> intptr_t; + + #[cfg(target_os = "macos")] + pub unsafe fn _dyld_get_image_name(image_index: u32) -> *const c_char; } diff --git a/packages/tools/package.json b/packages/tools/package.json index a786af72f..c14f1d66b 100644 --- a/packages/tools/package.json +++ b/packages/tools/package.json @@ -27,6 +27,7 @@ "oxlint-tsgolint": "catalog:", "playwright": "catalog:", "pnpm": "catalog:", + "postject": "catalog:", "typescript": "7.1.0-dev.20261001.1", "vite": "catalog:", "vitest": "catalog:" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f315ffbf8..af708038a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -54,6 +54,9 @@ catalogs: pnpm: specifier: 11.24.0 version: 11.24.0 + postject: + specifier: 1.0.0-alpha.6 + version: 1.0.0-alpha.6 typescript: specifier: ^6.0.3 version: 6.0.3 @@ -139,6 +142,9 @@ importers: pnpm: specifier: 'catalog:' version: 11.24.0 + postject: + specifier: 'catalog:' + version: 1.0.0-alpha.6 typescript: specifier: 7.1.0-dev.20261001.1 version: 7.1.0-dev.20261001.1 @@ -1193,6 +1199,10 @@ packages: resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} engines: {node: '>=18'} + commander@9.5.0: + resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} + engines: {node: ^12.20.0 || >=14} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -1425,6 +1435,11 @@ packages: resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} engines: {node: ^10 || ^12 || >=14} + postject@1.0.0-alpha.6: + resolution: {integrity: sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==} + engines: {node: '>=14.0.0'} + hasBin: true + pretty-format@27.5.1: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} @@ -2363,6 +2378,8 @@ snapshots: commander@12.1.0: {} + commander@9.5.0: {} + convert-source-map@2.0.0: {} cross-env@10.1.0: @@ -2642,6 +2659,10 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postject@1.0.0-alpha.6: + dependencies: + commander: 9.5.0 + pretty-format@27.5.1: dependencies: ansi-regex: 5.0.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 503f17e46..bbcd7b67c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -34,6 +34,7 @@ catalog: playwright: 1.63.0 # The e2e tests run this pnpm; keep it in sync with `packageManager`. pnpm: 11.24.0 + postject: 1.0.0-alpha.6 typescript: ^6.0.3 vite: npm:@voidzero-dev/vite-plus-core@1.1.0 vite-plus: 1.1.0 From c4f0ac96b2b01aef33fc8078ecb57756c9a149cc Mon Sep 17 00:00:00 2001 From: wan9chi Date: Mon, 12 Oct 2026 14:39:23 +0800 Subject: [PATCH 2/2] docs(changelog): link #840 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d3077af05..7597d50ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -- **Fixed** On macOS, tasks with automatic file-access tracking can now run Node.js single executable applications, such as pnpm 11's standalone `pnpm` binary, which previously aborted with `Assertion failed: (magic) == (kMagic)`, and `deno compile` outputs, which previously failed with `Did not find magic bytes` ([#???](https://github.com/voidzero-dev/vite-task/pull/???)). +- **Fixed** On macOS, tasks with automatic file-access tracking can now run Node.js single executable applications, such as pnpm 11's standalone `pnpm` binary, which previously aborted with `Assertion failed: (magic) == (kMagic)`, and `deno compile` outputs, which previously failed with `Did not find magic bytes` ([#840](https://github.com/voidzero-dev/vite-task/pull/840)). - **Fixed** Reinstalling dependencies with pnpm, or installing them at another path, no longer causes cache misses, and tasks that run `pnpm run` or `pnpm exec` no longer miss the cache after pnpm rechecks dependencies ([#823](https://github.com/voidzero-dev/vite-task/pull/823), [#824](https://github.com/voidzero-dev/vite-task/pull/824), [#825](https://github.com/voidzero-dev/vite-task/pull/825)). - **Added** Vite+ now builds and runs on platforms without file-access tracking support, such as FreeBSD. On those platforms, automatic input and output tracking is unavailable, so tasks that rely on it report that tracking is unsupported and must configure their `input` and `output` manually to be cached ([#829](https://github.com/voidzero-dev/vite-task/pull/829)). - **Changed** The `vp run` summary now reads like `3/4 cache hit (75%), 9/10 successful, 2s saved.` The cache hit count leaves out tasks with caching disabled, and the count of successful tasks out of all tasks replaces the count of failed tasks. When no task has caching enabled, the summary shows only the successful count. In `vp run --verbose` and `vp run --last-details`, the cache hit rate also leaves out tasks with caching disabled, and says `no task has caching enabled` when there are none ([#821](https://github.com/voidzero-dev/vite-task/pull/821)).