From c2749bd7ec271b0aaae40e8e3548d12b1b946e48 Mon Sep 17 00:00:00 2001 From: Aaron Reisman Date: Fri, 2 Oct 2026 14:33:56 +0700 Subject: [PATCH 1/4] fix(fspy): read the whole shebang line fspy read only the first 128 bytes of a script to find its interpreter, so an interpreter path longer than 126 bytes was cut short. The traced exec then ran the truncated path, which fails or runs a different program, while the same script runs fine without tracing. Read as much as the kernel does instead: 512 bytes on macOS and 256 on Linux. Where the kernel refuses a line that doesn't fit, return ENOEXEC like it does rather than running a truncated path. --- CHANGELOG.md | 1 + crates/fspy/tests/shebang.rs | 34 ++++ crates/fspy_shared_unix/src/exec/shebang.rs | 180 +++++++++++++++++--- 3 files changed, 192 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7597d50ec..23a483b1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Changelog +- **Fixed** Cached tasks can now run scripts whose shebang names an interpreter path longer than 126 bytes. Before, the path was cut short and the script failed to start or ran the wrong program ([#???](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)). diff --git a/crates/fspy/tests/shebang.rs b/crates/fspy/tests/shebang.rs index 9195b5a84..aec46f5bf 100644 --- a/crates/fspy/tests/shebang.rs +++ b/crates/fspy/tests/shebang.rs @@ -41,3 +41,37 @@ async fn spawn_sh_shebang() -> anyhow::Result<()> { Ok(()) } + +#[test(tokio::test)] +async fn spawn_shebang_with_long_interpreter_path() -> anyhow::Result<()> { + let tmp_dir = tempfile::TempDir::new()?; + + // Kernels read 256 (Linux) or 512 (macOS) bytes of the shebang line, so a + // 200-byte interpreter path must be kept whole. + let mut interpreter = tmp_dir.path().to_path_buf(); + while interpreter.as_os_str().len() < 150 { + interpreter.push("d".repeat(40)); + } + fs::create_dir_all(&interpreter).await?; + interpreter.push("x".repeat(200 - interpreter.as_os_str().len() - 1)); + assert_eq!(interpreter.as_os_str().len(), 200); + fs::symlink("/bin/sh", &interpreter).await?; + + let script_path = tmp_dir.path().join("long_shebang.sh"); + fs::write(&script_path, format!("#!{}\necho ok\n", interpreter.display())).await?; + fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755)).await?; + let script_path = script_path.into_os_string().into_string().unwrap(); + + let accesses = track_fn!(script_path, |script_path: String| { + let output = Command::new(&script_path) + .stdin(Stdio::null()) + .output() + .expect("Failed to execute shebang script"); + assert_eq!(output.stdout, b"ok\n", "stderr: {}", String::from_utf8_lossy(&output.stderr)); + }) + .await?; + + assert_contains(&accesses, &interpreter, AccessMode::READ); + + Ok(()) +} diff --git a/crates/fspy_shared_unix/src/exec/shebang.rs b/crates/fspy_shared_unix/src/exec/shebang.rs index 245b19b46..231f95db5 100644 --- a/crates/fspy_shared_unix/src/exec/shebang.rs +++ b/crates/fspy_shared_unix/src/exec/shebang.rs @@ -15,48 +15,86 @@ const fn is_whitespace(c: u8) -> bool { #[derive(Clone, Copy, Debug)] pub struct ParseShebangOptions { pub split_arguments: bool, // TODO: recursive + /// How many bytes of the file the kernel reads to find the shebang line. + pub max_line_len: usize, + /// Whether the line must end with a newline within `max_line_len` bytes. + /// + /// macOS refuses a script without one. Linux accepts it as long as the + /// interpreter path is followed by a space or tab, so only the + /// arguments can have been cut off. + pub require_newline: bool, +} + +/// Size of the buffer the macOS kernel reads the shebang line into (`IMG_SHSIZE`). +const MACOS_MAX_LINE_LEN: usize = 512; +/// Size of the buffer the Linux kernel reads the shebang line into (`BINPRM_BUF_SIZE`). +const LINUX_MAX_LINE_LEN: usize = 256; + +impl ParseShebangOptions { + #[must_use] + pub const fn macos() -> Self { + Self { split_arguments: true, max_line_len: MACOS_MAX_LINE_LEN, require_newline: true } + } + + #[must_use] + pub const fn linux() -> Self { + Self { split_arguments: false, max_line_len: LINUX_MAX_LINE_LEN, require_newline: false } + } } -#[cfg_attr( - not(target_vendor = "apple"), - expect( - clippy::derivable_impls, - reason = "on macOS split_arguments defaults to true via cfg!, which is not derivable" - ) -)] impl Default for ParseShebangOptions { fn default() -> Self { - Self { split_arguments: cfg!(target_vendor = "apple") } + if cfg!(target_vendor = "apple") { Self::macos() } else { Self::linux() } } } +/// Parses the shebang line of the executable at `path` the way the kernel does. +/// +/// Returns `ENOEXEC` where the kernel would refuse the script instead of +/// running a truncated interpreter path. +/// +/// # Panics +/// +/// Panics if `options.max_line_len` is larger than 512. pub fn parse_shebang( mut peek_executable: impl FnMut(&Path, &mut [u8]) -> nix::Result, path: &Path, options: ParseShebangOptions, ) -> Result, nix::Error> { - // https://lwn.net/Articles/779997/ - // > The array used to hold the shebang line is defined to be 128 bytes in length - // TODO: check linux/macOS' kernel source - const PEEK_SIZE: usize = 128; - - let mut buf = [0u8; PEEK_SIZE]; + let mut buf = [0u8; MACOS_MAX_LINE_LEN]; + let buf = &mut buf[..options.max_line_len]; - let total_read_size = peek_executable(path, &mut buf)?; + let total_read_size = peek_executable(path, buf)?; + let buf = &buf[..total_read_size]; - let Some(buf) = buf[..total_read_size].strip_prefix(b"#!") else { + let Some(line) = buf.strip_prefix(b"#!") else { return Ok(None); }; - let Some(buf) = buf.split(|ch| matches!(*ch, b'\n')).next() else { - // https://github.com/torvalds/linux/blob/5723cc3450bccf7f98f227b9723b5c9f6b3af1c5/fs/binfmt_script.c#L59-L80 + let line = if let Some(newline) = line.find_byte(b'\n') { + &line[..newline] + } else if options.require_newline { return Err(nix::Error::ENOEXEC); + } else if total_read_size < options.max_line_len { + // The whole file fits in the buffer, so nothing was cut off. + line + } else { + // https://github.com/torvalds/linux/blob/v6.12/fs/binfmt_script.c + // Like the kernel, keep the last byte of the buffer as a terminator. + let line = &line[..line.len() - 1]; + // The interpreter path must be followed by a space or tab, or it may be cut off. + if !line.iter().skip_while(|ch| is_whitespace(**ch)).any(|ch| is_whitespace(*ch)) { + return Err(nix::Error::ENOEXEC); + } + line }; - let buf = buf.trim_ascii(); - let Some(interpreter) = buf.split(|ch| is_whitespace(*ch)).next() else { - return Ok(None); - }; - let arguments_buf = buf[interpreter.len()..].trim_ascii_start().as_bstr(); + + let line = line.trim_ascii(); + let interpreter = line.split(|ch| is_whitespace(*ch)).next().unwrap_or_default(); + if interpreter.is_empty() { + return Err(nix::Error::ENOEXEC); + } + let arguments_buf = line[interpreter.len()..].trim_ascii_start().as_bstr(); let arguments: Vec = if options.split_arguments { arguments_buf @@ -228,3 +266,99 @@ pub fn parse_shebang( // ); // } // } + +#[cfg(test)] +mod tests { + use std::path::Path; + + use super::*; + + fn parse(content: &[u8], options: ParseShebangOptions) -> nix::Result> { + parse_shebang( + |_, buf| { + let len = content.len().min(buf.len()); + buf[..len].copy_from_slice(&content[..len]); + Ok(len) + }, + Path::new("/script"), + options, + ) + } + + fn script_with_interpreter_len(len: usize) -> (Vec, Vec) { + let interpreter = [b"/".as_slice(), &vec![b'x'; len - 1]].concat(); + let script = [b"#!".as_slice(), &interpreter, b"\necho hi\n"].concat(); + (script, interpreter) + } + + #[test] + fn keeps_interpreter_paths_longer_than_128_bytes() { + for options in [ParseShebangOptions::macos(), ParseShebangOptions::linux()] { + let (script, interpreter) = script_with_interpreter_len(200); + let shebang = parse(&script, options).unwrap().unwrap(); + assert_eq!(shebang.interpreter, interpreter); + } + } + + #[test] + fn macos_accepts_line_up_to_512_bytes() { + // "#!" + 509 bytes + "\n" is exactly 512 bytes. + let (script, interpreter) = script_with_interpreter_len(509); + let shebang = parse(&script, ParseShebangOptions::macos()).unwrap().unwrap(); + assert_eq!(shebang.interpreter, interpreter); + + let (script, _) = script_with_interpreter_len(510); + assert_eq!(parse(&script, ParseShebangOptions::macos()).unwrap_err(), nix::Error::ENOEXEC); + } + + #[test] + fn macos_requires_newline() { + let result = parse(b"#!/bin/sh", ParseShebangOptions::macos()); + assert_eq!(result.unwrap_err(), nix::Error::ENOEXEC); + } + + #[test] + fn linux_accepts_line_up_to_256_bytes() { + // "#!" + 253 bytes + "\n" is exactly 256 bytes. + let (script, interpreter) = script_with_interpreter_len(253); + let shebang = parse(&script, ParseShebangOptions::linux()).unwrap().unwrap(); + assert_eq!(shebang.interpreter, interpreter); + + let (script, _) = script_with_interpreter_len(254); + assert_eq!(parse(&script, ParseShebangOptions::linux()).unwrap_err(), nix::Error::ENOEXEC); + } + + #[test] + fn linux_accepts_missing_newline_at_end_of_file() { + let shebang = parse(b"#!/bin/sh -e", ParseShebangOptions::linux()).unwrap().unwrap(); + assert_eq!(shebang.interpreter, "/bin/sh"); + assert_eq!(shebang.arguments, vec![BString::from("-e")]); + } + + #[test] + fn linux_refuses_truncated_interpreter() { + let (script, _) = script_with_interpreter_len(300); + assert_eq!(parse(&script, ParseShebangOptions::linux()).unwrap_err(), nix::Error::ENOEXEC); + } + + #[test] + fn linux_truncates_long_arguments() { + let script = [b"#!/bin/sh ".as_slice(), &[b'a'; 300], b"\n"].concat(); + let shebang = parse(&script, ParseShebangOptions::linux()).unwrap().unwrap(); + assert_eq!(shebang.interpreter, "/bin/sh"); + // 256-byte buffer, minus "#!/bin/sh " and the reserved last byte. + assert_eq!(shebang.arguments, vec![BString::from(vec![b'a'; 256 - 10 - 1])]); + } + + #[test] + fn missing_interpreter_is_an_error() { + for options in [ParseShebangOptions::macos(), ParseShebangOptions::linux()] { + assert_eq!(parse(b"#! \n", options).unwrap_err(), nix::Error::ENOEXEC); + } + } + + #[test] + fn not_a_script() { + assert!(parse(b"\x7fELF", ParseShebangOptions::default()).unwrap().is_none()); + } +} From 6d50bde9ab527a122d9b83044cd5d75b37e8ca76 Mon Sep 17 00:00:00 2001 From: Aaron Reisman Date: Fri, 2 Oct 2026 14:34:37 +0700 Subject: [PATCH 2/4] docs: link changelog entry to #788 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 23a483b1c..e61e451d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -- **Fixed** Cached tasks can now run scripts whose shebang names an interpreter path longer than 126 bytes. Before, the path was cut short and the script failed to start or ran the wrong program ([#???](https://github.com/voidzero-dev/vite-task/pull/???)). +- **Fixed** Cached tasks can now run scripts whose shebang names an interpreter path longer than 126 bytes. Before, the path was cut short and the script failed to start or ran the wrong program ([#788](https://github.com/voidzero-dev/vite-task/pull/788)). - **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)). From 469b59b9798b29f5349dbe35007e0b8704e091e2 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Mon, 12 Oct 2026 17:54:05 +0800 Subject: [PATCH 3/4] fix(fspy): end the shebang line where the kernel does On macOS, `#` ends the shebang line like a newline does (`IS_EOL` in XNU's `exec_shell_imgact`). Reading 512 bytes instead of 128 made a `#` past the first 128 bytes part of the interpreter or its arguments, and a line without a newline in its first 512 bytes failed with ENOEXEC even when a `#` ended it. On Linux, when the 256-byte buffer fills up without a newline, the space or tab after the interpreter path can be the buffer's last byte. The kernel looks for it there before dropping that byte, but fspy dropped the byte first and refused the script. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- crates/fspy_shared_unix/src/exec/shebang.rs | 62 ++++++++++++++++----- 2 files changed, 49 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e61e451d9..7a8b613a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -- **Fixed** Cached tasks can now run scripts whose shebang names an interpreter path longer than 126 bytes. Before, the path was cut short and the script failed to start or ran the wrong program ([#788](https://github.com/voidzero-dev/vite-task/pull/788)). +- **Fixed** Cached tasks can now run scripts whose shebang names an interpreter path longer than 126 bytes. Before, the path was cut short and the script failed to start or ran the wrong program. On macOS, text after a `#` in the shebang line is now ignored, as it is when the script runs on its own ([#788](https://github.com/voidzero-dev/vite-task/pull/788)). - **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)). diff --git a/crates/fspy_shared_unix/src/exec/shebang.rs b/crates/fspy_shared_unix/src/exec/shebang.rs index 231f95db5..5c316a43d 100644 --- a/crates/fspy_shared_unix/src/exec/shebang.rs +++ b/crates/fspy_shared_unix/src/exec/shebang.rs @@ -17,12 +17,15 @@ pub struct ParseShebangOptions { pub split_arguments: bool, // TODO: recursive /// How many bytes of the file the kernel reads to find the shebang line. pub max_line_len: usize, - /// Whether the line must end with a newline within `max_line_len` bytes. + /// Whether the line must end within `max_line_len` bytes. /// - /// macOS refuses a script without one. Linux accepts it as long as the - /// interpreter path is followed by a space or tab, so only the - /// arguments can have been cut off. - pub require_newline: bool, + /// macOS refuses a script whose line doesn't end. Linux accepts it as + /// long as the interpreter path is followed by a space or tab, so only + /// the arguments can have been cut off. + pub require_line_end: bool, + /// Whether `#` ends the line like a newline does, as on macOS (`IS_EOL` + /// in XNU's `exec_shell_imgact`). + pub hash_ends_line: bool, } /// Size of the buffer the macOS kernel reads the shebang line into (`IMG_SHSIZE`). @@ -33,12 +36,22 @@ const LINUX_MAX_LINE_LEN: usize = 256; impl ParseShebangOptions { #[must_use] pub const fn macos() -> Self { - Self { split_arguments: true, max_line_len: MACOS_MAX_LINE_LEN, require_newline: true } + Self { + split_arguments: true, + max_line_len: MACOS_MAX_LINE_LEN, + require_line_end: true, + hash_ends_line: true, + } } #[must_use] pub const fn linux() -> Self { - Self { split_arguments: false, max_line_len: LINUX_MAX_LINE_LEN, require_newline: false } + Self { + split_arguments: false, + max_line_len: LINUX_MAX_LINE_LEN, + require_line_end: false, + hash_ends_line: false, + } } } @@ -71,22 +84,23 @@ pub fn parse_shebang( return Ok(None); }; - let line = if let Some(newline) = line.find_byte(b'\n') { - &line[..newline] - } else if options.require_newline { + let line_ends: &[u8] = if options.hash_ends_line { b"\n#" } else { b"\n" }; + let line = if let Some(line_end) = line.find_byteset(line_ends) { + &line[..line_end] + } else if options.require_line_end { return Err(nix::Error::ENOEXEC); } else if total_read_size < options.max_line_len { // The whole file fits in the buffer, so nothing was cut off. line } else { // https://github.com/torvalds/linux/blob/v6.12/fs/binfmt_script.c - // Like the kernel, keep the last byte of the buffer as a terminator. - let line = &line[..line.len() - 1]; - // The interpreter path must be followed by a space or tab, or it may be cut off. + // The interpreter path must be followed by a space or tab, or it may be + // cut off. Like the kernel, look for one in the whole buffer but keep + // its last byte as a terminator. if !line.iter().skip_while(|ch| is_whitespace(**ch)).any(|ch| is_whitespace(*ch)) { return Err(nix::Error::ENOEXEC); } - line + &line[..line.len() - 1] }; let line = line.trim_ascii(); @@ -317,6 +331,16 @@ mod tests { assert_eq!(result.unwrap_err(), nix::Error::ENOEXEC); } + #[test] + fn macos_ends_line_at_hash() { + // The `#` is past the first 128 bytes and no newline follows within 512. + let script = + [b"#!/bin/sh -e ".as_slice(), &[b' '; 200], b"# comment", &[b'x'; 400]].concat(); + let shebang = parse(&script, ParseShebangOptions::macos()).unwrap().unwrap(); + assert_eq!(shebang.interpreter, "/bin/sh"); + assert_eq!(shebang.arguments, vec![BString::from("-e")]); + } + #[test] fn linux_accepts_line_up_to_256_bytes() { // "#!" + 253 bytes + "\n" is exactly 256 bytes. @@ -335,6 +359,16 @@ mod tests { assert_eq!(shebang.arguments, vec![BString::from("-e")]); } + #[test] + fn linux_accepts_space_in_last_byte() { + // "#!" + 253 bytes + " " fills the 256-byte buffer without a newline. + let interpreter = [b"/".as_slice(), &[b'x'; 252]].concat(); + let script = [b"#!".as_slice(), &interpreter, b" ", &[b'a'; 100]].concat(); + let shebang = parse(&script, ParseShebangOptions::linux()).unwrap().unwrap(); + assert_eq!(shebang.interpreter, interpreter); + assert!(shebang.arguments.is_empty()); + } + #[test] fn linux_refuses_truncated_interpreter() { let (script, _) = script_with_interpreter_len(300); From cbf823e772592d2e3e04b21dc04707385e60de01 Mon Sep 17 00:00:00 2001 From: wan9chi Date: Mon, 12 Oct 2026 18:04:18 +0800 Subject: [PATCH 4/4] test(fspy): only check the long interpreter path is read on macOS fspy only swaps the arm64e `/bin/sh` for its own shell when it's run by that path. Through the test's symlink it injects the arm64 preload instead, and dyld refuses to load it where SIP is off, as on the CI runners. The script still runs on Linux; on macOS the test checks that the whole 200-byte interpreter path was read, which the 128-byte read didn't do. Co-Authored-By: Claude Opus 5.5 --- crates/fspy/tests/shebang.rs | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/crates/fspy/tests/shebang.rs b/crates/fspy/tests/shebang.rs index aec46f5bf..13d9513d4 100644 --- a/crates/fspy/tests/shebang.rs +++ b/crates/fspy/tests/shebang.rs @@ -63,11 +63,20 @@ async fn spawn_shebang_with_long_interpreter_path() -> anyhow::Result<()> { let script_path = script_path.into_os_string().into_string().unwrap(); let accesses = track_fn!(script_path, |script_path: String| { - let output = Command::new(&script_path) - .stdin(Stdio::null()) - .output() - .expect("Failed to execute shebang script"); - assert_eq!(output.stdout, b"ok\n", "stderr: {}", String::from_utf8_lossy(&output.stderr)); + let output = Command::new(&script_path).stdin(Stdio::null()).output(); + // On macOS, fspy only swaps the arm64e `/bin/sh` for its own shell when + // it's run by that path. Through the symlink, dyld refuses to load the + // arm64 preload into it where SIP is off (as on CI runners), so only + // check there that the whole interpreter path was read. + if cfg!(target_os = "linux") { + let output = output.expect("Failed to execute shebang script"); + assert_eq!( + output.stdout, + b"ok\n", + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + } }) .await?;