diff --git a/src/CLAUDE.md b/src/CLAUDE.md index 07f756cdb786..12a55791dc06 100644 --- a/src/CLAUDE.md +++ b/src/CLAUDE.md @@ -213,6 +213,14 @@ let joined = resolve_path::join_string_buf::(&mut buf, &[a, b]); let rel = resolve_path::relative(from, to); ``` +The threadlocal-buffer functions (`join`, `join_abs_string`, `normalize_string`, +`relative`, ...) accept input of any length. The `join*_buf`, `normalize*_buf` +and `relative*_buf` functions panic when the result does not fit the buffer; +when the input comes from the user (a package.json field, a command operand, an +archive entry), use the `*_checked` variant (`join_string_buf_checked`, +`join_z_buf_checked`, `join_abs_string_buf_checked`, `normalize_buf_checked`, +`relative_buf_z_checked`, ...) and report its `None` as `ENAMETOOLONG`. + Use the path-buffer pool to avoid 64 KB stack allocations on Windows (`PathBuffer` is `[u8; PATH_MAX_BYTES]`, ~64 KB on Windows): diff --git a/src/install/TarballStream.rs b/src/install/TarballStream.rs index 4a7449481aec..dd3c9152a01e 100644 --- a/src/install/TarballStream.rs +++ b/src/install/TarballStream.rs @@ -806,7 +806,13 @@ impl TarballStream { let rest: &[OSPathChar] = tokenize_rest_after_first(&pathname[..]); let mut norm_buf = OSPathBuffer::uninit(); - if rest.len() >= norm_buf.len() { + // The last unit of `norm_buf` is reserved for the NUL. + let capacity = norm_buf.len() - 1; + let Some(norm_len) = resolve_path::normalize_buf_t::( + rest, + &mut norm_buf[..capacity], + ) + .map(|normalized| normalized.len()) else { bun_core::warn!( "Skipping entry with a path longer than the maximum path length: {}\n", bun_core::fmt::fmt_os_path(rest, Default::default()), @@ -814,10 +820,7 @@ impl TarballStream { self.phase = Phase::WantData; self.out_fd = None; return Ok(()); - } - let normalized = - resolve_path::normalize_buf_t::(rest, &mut norm_buf[..]); - let norm_len = normalized.len(); + }; norm_buf[norm_len] = 0; // SAFETY: norm_buf[norm_len] == 0 written above. let path: OSPathZMut = diff --git a/src/libarchive/lib.rs b/src/libarchive/lib.rs index a649c3853adb..97e958909d2f 100644 --- a/src/libarchive/lib.rs +++ b/src/libarchive/lib.rs @@ -1016,12 +1016,14 @@ pub fn is_symlink_target_safe( written += link_target_bytes.len(); let mut norm_buf = bun_paths::path_buffer_pool::get(); - let resolved = bun_paths::resolve_path::normalize_string_generic_t::( + let Some(resolved) = bun_paths::resolve_path::normalize_string_generic_t::( &join_buf[..written], &mut norm_buf[..], b'/', |c| c == b'/', - ); + ) else { + return false; + }; !(strings::eql(resolved, b"..") || strings::has_prefix_comptime(resolved, b"../")) } @@ -1304,14 +1306,19 @@ impl Archiver { // pathname = sliceTo(remaining[..len :0], 0) let pathname = slice_to_nul(remaining); - if pathname.is_empty() || pathname.len() >= normalized_buf.len() { + if pathname.is_empty() { continue 'loop_; } - let normalized = bun_paths::resolve_path::normalize_buf_t::< - u8, - bun_paths::platform::Auto, - >(pathname, &mut normalized_buf[..]); - let normalized_len = normalized.len(); + // An entry too long to be a path cannot overwrite anything. + let Some(normalized_len) = + bun_paths::resolve_path::normalize_buf_t::( + pathname, + &mut normalized_buf[..], + ) + .map(|normalized| normalized.len()) + else { + continue 'loop_; + }; let pathname: &[u8] = &normalized_buf[..normalized_len]; if pathname.is_empty() || pathname == b"." { continue 'loop_; @@ -1498,7 +1505,15 @@ impl Archiver { // at its original `.len()`; therefore `remaining[remaining.len()] == 0`. let pathname: &[OSPathChar] = remaining; - if pathname.len() >= normalized_buf.len() { + // The last unit of `normalized_buf` is reserved for the NUL. + let capacity = normalized_buf.len() - 1; + let Some(normalized_len) = + bun_paths::resolve_path::normalize_buf_t::< + OSPathChar, + bun_paths::platform::Auto, + >(pathname, &mut normalized_buf[..capacity]) + .map(|normalized| normalized.len()) + else { if options.log { bun_core::warn!( "Skipping entry with a path longer than the maximum path length: {}\n", @@ -1506,13 +1521,7 @@ impl Archiver { ); } continue; - } - - let normalized = bun_paths::resolve_path::normalize_buf_t::< - OSPathChar, - bun_paths::platform::Auto, - >(pathname, &mut normalized_buf[..]); - let normalized_len = normalized.len(); + }; normalized_buf[normalized_len] = 0; // SAFETY: we just wrote a NUL at normalized_buf[normalized_len] let path: &mut [OSPathChar] = &mut normalized_buf[..normalized_len]; diff --git a/src/paths/resolve_path.rs b/src/paths/resolve_path.rs index 12c872f5900d..2b68525ef502 100644 --- a/src/paths/resolve_path.rs +++ b/src/paths/resolve_path.rs @@ -17,14 +17,65 @@ thread_local! { const { UnsafeCell::new([0u8; PARSER_JOIN_INPUT_BUFFER_LEN]) }; static PARSER_BUFFER: UnsafeCell<[u8; PARSER_BUFFER_LEN]> = const { UnsafeCell::new([0u8; PARSER_BUFFER_LEN]) }; + // Results of the wrappers above each fixed buffer that did not fit it; see `spill_out`. + static PARSER_JOIN_INPUT_SPILL: UnsafeCell> = const { UnsafeCell::new(Vec::new()) }; + static PARSER_SPILL: UnsafeCell> = const { UnsafeCell::new(Vec::new()) }; + static JOIN_SPILL: UnsafeCell> = const { UnsafeCell::new(Vec::new()) }; + static RELATIVE_SPILL: UnsafeCell> = const { UnsafeCell::new(Vec::new()) }; } -/// Output capacity of [`join_abs_string`] / [`join_abs_string_z`]. +/// Fixed capacity of [`join_abs_string`]'s output; longer results spill to the heap. const PARSER_JOIN_INPUT_BUFFER_LEN: usize = 4096; -/// Output capacity of [`normalize_string`]. +/// Fixed capacity of [`normalize_string`]'s output; longer results spill to the heap. const PARSER_BUFFER_LEN: usize = 1024; +/// Runs `op` on a heap buffer of `len` bytes that `spill` then keeps until the +/// next result spilled through it, so the result is valid for as long as one in +/// the fixed thread-local buffers. The previous spill is freed only after `op` +/// returns: it may be one of `op`'s inputs. +fn spill_out<'r>( + spill: &'static std::thread::LocalKey>>, + len: usize, + op: impl FnOnce(&'r mut [u8]) -> &'r [u8], +) -> &'static [u8] { + let mut out = vec![0u8; len]; + let out_start = out.as_mut_ptr(); + // SAFETY: `out` holds `len` initialized bytes that nothing else touches + // until `op` returns, and `buf` is not used after that. (`'r` belongs to + // `op`'s result, which may also borrow `op`'s inputs; it is not a borrow of + // `out`, which is moved below.) + let buf: &'r mut [u8] = unsafe { core::slice::from_raw_parts_mut(out_start, len) }; + let result = op(buf); + let result_len = result.len(); + let (out_start, result_start) = (out_start as usize, result.as_ptr() as usize); + let (start, to_store) = + if result_start >= out_start && result_start + result_len <= out_start + len { + (result_start - out_start, out) + } else { + // An input returned as the result (`join_abs_string` with no parts returns the cwd). + (0, result.to_vec()) + }; + spill.with(|cell| { + // SAFETY: thread-local UnsafeCell, so this thread is the sole accessor, + // and `op` has returned, so nothing reads the buffer being replaced. + // The returned slice follows the same "valid until the next call" + // contract as the fixed buffers (see `tl_buf_mut`). + let stored: &'static mut Vec = unsafe { &mut *cell.get() }; + *stored = to_store; + &stored[start..start + result_len] + }) +} + +/// Callers with input of unbounded length use the `*_checked` variant and +/// report its `None` as `ENAMETOOLONG`; the plain variants require a buffer that fits. +#[cold] +#[inline(never)] +#[track_caller] +fn path_buffer_too_small(buf_len: usize) -> ! { + panic!("path does not fit in a {buf_len}-unit path buffer; use the *_checked variant"); +} + /// Project `&'static mut` into a thread-local `UnsafeCell<[u8; N]>` scratch /// buffer. One `unsafe` site for all `PARSER_BUFFER` / `PARSER_JOIN_INPUT_BUFFER` /// / `JOIN_BUF` accessors (nonnull-asref reduction: 6 sites → 1). @@ -382,7 +433,21 @@ pub fn relative_to_common_path_buf() -> *mut PathBuffer { RELATIVE_TO_COMMON_PATH_BUF.with(lazy_path_buf) } -/// Find a relative path from a common path +/// `result` is a suffix of `to`; `None` when a requested copy does not fit `buf`. +#[inline] +fn copy_or_borrow<'a, const ALWAYS_COPY: bool>( + buf: &'a mut [u8], + result: &'a [u8], +) -> Option<&'a [u8]> { + if !ALWAYS_COPY { + return Some(result); + } + let out = buf.get_mut(..result.len())?; + out.copy_from_slice(result); + Some(out) +} + +/// Find a relative path from a common path. `None` when it does not fit `buf`. // Loosely based on Node.js' implementation of path.relative // https://github.com/nodejs/node/blob/9a7cbe25de88d87429a69050a1a1971234558d97/lib/path.js#L1250-L1259 fn relative_to_common_path<'a, const ALWAYS_COPY: bool, P: PlatformT>( @@ -390,7 +455,7 @@ fn relative_to_common_path<'a, const ALWAYS_COPY: bool, P: PlatformT>( normalized_from_: &[u8], normalized_to_: &'a [u8], buf: &'a mut [u8], -) -> &'a [u8] { +) -> Option<&'a [u8]> { let mut normalized_from = normalized_from_; let mut normalized_to = normalized_to_; let win_root_len: Option = if P::P == Platform::Windows { @@ -401,24 +466,14 @@ fn relative_to_common_path<'a, const ALWAYS_COPY: bool, P: PlatformT>( if common_path_.is_empty() { // the only case path.relative can return not a relative string if !strings::eql_case_insensitive_ascii_check_length(from_root, to_root) { - if normalized_to_.len() > to_root.len() + let result = if normalized_to_.len() > to_root.len() && normalized_to_[normalized_to_.len() - 1] == b'\\' { - if ALWAYS_COPY { - let n = normalized_to_.len() - 1; - buf[..n].copy_from_slice(&normalized_to_[..n]); - return &buf[..n]; - } else { - return &normalized_to_[..normalized_to_.len() - 1]; - } + &normalized_to_[..normalized_to_.len() - 1] } else { - if ALWAYS_COPY { - buf[..normalized_to_.len()].copy_from_slice(normalized_to_); - return &buf[..normalized_to_.len()]; - } else { - return normalized_to_; - } - } + normalized_to_ + }; + return copy_or_borrow::(buf, result); } } @@ -455,12 +510,7 @@ fn relative_to_common_path<'a, const ALWAYS_COPY: bool, P: PlatformT>( // We get here if `from` is the root // For example: from='/'; to='/foo' - if ALWAYS_COPY { - buf[..normalized_to.len()].copy_from_slice(normalized_to); - return &buf[..normalized_to.len()]; - } else { - return normalized_to; - } + return copy_or_borrow::(buf, normalized_to); } if normalized_to[common_path.len() - 1] == separator { @@ -475,14 +525,9 @@ fn relative_to_common_path<'a, const ALWAYS_COPY: bool, P: PlatformT>( slice }; - if ALWAYS_COPY { - // We get here if `from` is the exact base path for `to`. - // For example: from='/foo/bar'; to='/foo/bar/baz' - buf[..without_trailing_slash.len()].copy_from_slice(without_trailing_slash); - return &buf[..without_trailing_slash.len()]; - } else { - return without_trailing_slash; - } + // We get here if `from` is the exact base path for `to`. + // For example: from='/foo/bar'; to='/foo/bar/baz' + return copy_or_borrow::(buf, without_trailing_slash); } } } @@ -512,11 +557,12 @@ fn relative_to_common_path<'a, const ALWAYS_COPY: bool, P: PlatformT>( || (normalized_from[i] == separator && i + 1 < normalized_from.len()) { if out_len == 0 { - buf[0..2].copy_from_slice(b".."); + buf.get_mut(0..2)?.copy_from_slice(b".."); out_len = 2; } else { - buf[out_len] = separator; - buf[out_len + 1..out_len + 3].copy_from_slice(b".."); + let dotdot = buf.get_mut(out_len..out_len + 3)?; + dotdot[0] = separator; + dotdot[1..].copy_from_slice(b".."); out_len += 3; } } @@ -539,14 +585,14 @@ fn relative_to_common_path<'a, const ALWAYS_COPY: bool, P: PlatformT>( let insert_leading_slash = !P::P.is_separator(tail[0]) && out_len > 0 && !P::P.is_separator(buf[out_len - 1]); + // Lastly, append the rest of the destination (`to`) path that comes after + // the common path parts. + let rest = buf.get_mut(out_len..out_len + insert_leading_slash as usize + tail.len())?; if insert_leading_slash { - buf[out_len] = separator; + rest[0] = separator; out_len += 1; } - - // Lastly, append the rest of the destination (`to`) path that comes after - // the common path parts. - buf[out_len..out_len + tail.len()].copy_from_slice(tail); + rest[insert_leading_slash as usize..].copy_from_slice(tail); out_len += tail.len(); } @@ -554,21 +600,29 @@ fn relative_to_common_path<'a, const ALWAYS_COPY: bool, P: PlatformT>( out_len -= 1; } - &buf[..out_len] + Some(&buf[..out_len]) } -pub fn relative_normalized_buf<'a, P: PlatformT, const ALWAYS_COPY: bool>( +/// Holds any result of `relative_to_common_path`: one `/..` per separator of +/// `from` plus one, then a separator and the tail of `to`. +#[inline] +fn relative_result_capacity(normalized_from_len: usize, normalized_to_len: usize) -> usize { + (normalized_from_len + 1) * 3 + 1 + normalized_to_len +} + +/// `None` when the result does not fit `buf`. Without `ALWAYS_COPY` it may borrow `to`. +pub fn relative_normalized_buf_checked<'a, P: PlatformT, const ALWAYS_COPY: bool>( buf: &'a mut [u8], from: &'a [u8], to: &'a [u8], -) -> &'a [u8] { +) -> Option<&'a [u8]> { let equal = if P::P == Platform::Windows { strings::eql_case_insensitive_ascii(from, to, true) } else { from.len() == to.len() && strings::eql_long(from, to, false) }; if equal { - return b""; + return Some(b""); } let two: [&[u8]; 2] = [from, to]; @@ -577,7 +631,19 @@ pub fn relative_normalized_buf<'a, P: PlatformT, const ALWAYS_COPY: bool>( relative_to_common_path::(common_path, from, to, buf) } -// result borrows either the thread-local common-path buf ('static) +/// [`relative_normalized_buf_checked`]; panics when the result does not fit `buf`. +#[track_caller] +pub fn relative_normalized_buf<'a, P: PlatformT, const ALWAYS_COPY: bool>( + buf: &'a mut [u8], + from: &'a [u8], + to: &'a [u8], +) -> &'a [u8] { + let buf_len = buf.len(); + relative_normalized_buf_checked::(buf, from, to) + .unwrap_or_else(|| path_buffer_too_small(buf_len)) +} + +// result borrows the thread-local common-path buf or its spill ('static) // or `to` (when !ALWAYS_COPY and result==to). Return lifetime is `'a` (=to's), // since 'static: 'a. "Valid until next call" still applies for // the buf-backed case. @@ -586,10 +652,23 @@ pub fn relative_normalized<'a, P: PlatformT, const ALWAYS_COPY: bool>( to: &'a [u8], ) -> &'a [u8] { // SAFETY: thread-local scratch; single live borrow per thread. - relative_normalized_buf::( - RELATIVE_TO_COMMON_PATH_BUF.with(lazy_path_buf), - from, - to, + let common_buf = RELATIVE_TO_COMMON_PATH_BUF.with(lazy_path_buf); + match relative_normalized_buf_checked::(&mut common_buf[..], from, to) { + Some(rel) => rel, + None => relative_normalized_spilled::

(from, to), + } +} + +#[cold] +#[inline(never)] +fn relative_normalized_spilled(from: &[u8], to: &[u8]) -> &'static [u8] { + spill_out( + &RELATIVE_SPILL, + relative_result_capacity(from.len(), to.len()), + |buf| { + relative_normalized_buf_checked::(buf, from, to) + .unwrap_or_else(|| unreachable!("relative_result_capacity bounds the result")) + }, ) } @@ -644,104 +723,131 @@ pub fn relative(from: &[u8], to: &[u8]) -> &'static [u8] { relative_platform::(from, to) } -pub fn relative_buf_z<'a>(buf: &'a mut [u8], from: &[u8], to: &[u8]) -> &'a ZStr { - let rel = relative_platform_buf::(buf, from, to); - let len = rel.len(); - // reshaped for borrowck — drop `rel` borrow before mutating buf +/// NUL-terminated [`relative_platform_buf_checked`] for the host platform. +pub fn relative_buf_z_checked<'a>(buf: &'a mut [u8], from: &[u8], to: &[u8]) -> Option<&'a ZStr> { + let capacity = buf.len().checked_sub(1)?; + let len = + relative_platform_buf_checked::(&mut buf[..capacity], from, to)? + .len(); buf[len] = 0; - // SAFETY: buf[len] == 0 written above - ZStr::from_buf(&buf[..], len) + Some(ZStr::from_buf(&buf[..], len)) } -pub fn relative_platform_buf<'a, P: PlatformT, const ALWAYS_COPY: bool>( +/// [`relative_buf_z_checked`]; panics when something does not fit. +#[track_caller] +pub fn relative_buf_z<'a>(buf: &'a mut [u8], from: &[u8], to: &[u8]) -> &'a ZStr { + let buf_len = buf.len(); + relative_buf_z_checked(buf, from, to).unwrap_or_else(|| path_buffer_too_small(buf_len)) +} + +/// A relative `path` is resolved against the top-level directory; `scratch` +/// holds its normalized form so that the join does not read the buffer it writes. +fn normalize_relative_input<'a, P: PlatformT>( + out: &'a mut [u8], + scratch: &mut [u8], + path: &[u8], +) -> Option<&'a [u8]> { + if !P::P.is_absolute(path) { + let norm_len = normalize_string_buf_checked::(path, scratch)?.len(); + return join_abs_string_buf_checked::

( + Fs::FileSystem::instance().top_level_dir(), + out, + &[&scratch[..norm_len]], + ); + } + if P::P == Platform::Loose && cfg!(windows) { + // we want to invoke the windows resolution behavior but end up with a + // string with forward slashes. + let normalized = + normalize_string_buf_checked::(path, out.get_mut(1..)?)?; + platform_to_posix_in_place::(normalized); + return Some(&*normalized); + } + // reshaped for borrowck — capture len, drop inner &mut, re-slice + let path_len = normalize_string_buf_checked::(path, out.get_mut(1..)?)?.len(); + if P::P == Platform::Windows { + return Some(&out[1..1 + path_len]); + } + out[0] = P::P.separator(); + Some(&out[0..path_len + 1]) +} + +/// `None` when `from`, `to` or the result does not fit a path buffer (`buf` is +/// also the scratch space). Without `ALWAYS_COPY` the result may borrow a +/// thread-local buffer that the next `relative*` call overwrites. +pub fn relative_platform_buf_checked<'a, P: PlatformT, const ALWAYS_COPY: bool>( buf: &'a mut [u8], from: &[u8], to: &[u8], -) -> &'a [u8] { +) -> Option<&'a [u8]> { // RELATIVE_FROM_BUF and RELATIVE_TO_BUF are independent allocations so the // two `&mut` borrows below are disjoint. let relative_from_buf = RELATIVE_FROM_BUF.with(lazy_path_buf); let relative_to_buf = RELATIVE_TO_BUF.with(lazy_path_buf); + let normalized_from = normalize_relative_input::

(&mut relative_from_buf[..], buf, from)?; + let normalized_to = normalize_relative_input::

(&mut relative_to_buf[..], buf, to)?; + relative_normalized_buf_checked::(buf, normalized_from, normalized_to) +} - let normalized_from: &[u8] = if P::P.is_absolute(from) { - 'brk: { - if P::P == Platform::Loose && cfg!(windows) { - // we want to invoke the windows resolution behavior but end up with a - // string with forward slashes. - let normalized = normalize_string_buf::( - from, - &mut relative_from_buf[1..], - ); - platform_to_posix_in_place::(normalized); - break 'brk &*normalized; - } - // reshaped for borrowck — capture len, drop inner &mut, re-slice - let path_len = - normalize_string_buf::(from, &mut relative_from_buf[1..]).len(); - if P::P == Platform::Windows { - break 'brk &relative_from_buf[1..1 + path_len]; - } - relative_from_buf[0] = P::P.separator(); - break 'brk &relative_from_buf[0..path_len + 1]; - } - } else { - // Avoid aliasing relative_from_buf as both input (normalize result) - // and output (join target): normalize into relative_to_buf scratch, - // then join into relative_from_buf. Safe because normalized_to is computed - // afterwards (overwrites relative_to_buf anyway). - let norm_len = normalize_string_buf::(from, &mut relative_to_buf[..]).len(); - join_abs_string_buf::

( - Fs::FileSystem::instance().top_level_dir(), - relative_from_buf, - &[&relative_to_buf[..norm_len]], - ) - }; - - let normalized_to: &[u8] = if P::P.is_absolute(to) { - 'brk: { - if P::P == Platform::Loose && cfg!(windows) { - let normalized = normalize_string_buf::( - to, - &mut relative_to_buf[1..], - ); - platform_to_posix_in_place::(normalized); - break 'brk &*normalized; - } - // reshaped for borrowck — capture len, drop inner &mut, re-slice - let path_len = - normalize_string_buf::(to, &mut relative_to_buf[1..]).len(); - if P::P == Platform::Windows { - break 'brk &relative_to_buf[1..1 + path_len]; - } - relative_to_buf[0] = P::P.separator(); - break 'brk &relative_to_buf[0..path_len + 1]; - } - } else { - // Avoid aliasing relative_to_buf as both input (normalize result) - // and output (join target): normalize into `buf` scratch (caller - // output buffer, untouched until the final relative_normalized_buf call - // and disjoint from both threadlocals), then join into relative_to_buf. - let norm_len = normalize_string_buf::(to, buf).len(); - join_abs_string_buf::

( - Fs::FileSystem::instance().top_level_dir(), - relative_to_buf, - &[&buf[..norm_len]], - ) - }; - - relative_normalized_buf::(buf, normalized_from, normalized_to) +/// [`relative_platform_buf_checked`]; panics when something does not fit. +#[track_caller] +pub fn relative_platform_buf<'a, P: PlatformT, const ALWAYS_COPY: bool>( + buf: &'a mut [u8], + from: &[u8], + to: &[u8], +) -> &'a [u8] { + let buf_len = buf.len(); + relative_platform_buf_checked::(buf, from, to) + .unwrap_or_else(|| path_buffer_too_small(buf_len)) } +/// Takes input of any length; the result is valid until the next `relative*` call on this thread. pub fn relative_platform( from: &[u8], to: &[u8], ) -> &'static [u8] { // SAFETY: thread-local scratch; single live borrow per thread. - relative_platform_buf::( - RELATIVE_TO_COMMON_PATH_BUF.with(lazy_path_buf), - from, - to, - ) + let common_buf = RELATIVE_TO_COMMON_PATH_BUF.with(lazy_path_buf); + let relative_from_buf = RELATIVE_FROM_BUF.with(lazy_path_buf); + let relative_to_buf = RELATIVE_TO_BUF.with(lazy_path_buf); + let normalized = ( + normalize_relative_input::

(&mut relative_from_buf[..], &mut common_buf[..], from), + normalize_relative_input::

(&mut relative_to_buf[..], &mut common_buf[..], to), + ); + let (Some(normalized_from), Some(normalized_to)) = normalized else { + return relative_platform_spilled::

(from, to); + }; + match relative_normalized_buf_checked::( + &mut common_buf[..], + normalized_from, + normalized_to, + ) { + Some(rel) => rel, + None => relative_normalized_spilled::

(normalized_from, normalized_to), + } +} + +/// [`relative_platform`] in heap buffers sized for `from` and `to`. +#[cold] +#[inline(never)] +fn relative_platform_spilled(from: &[u8], to: &[u8]) -> &'static [u8] { + // Normalizing adds at most one byte (`normalize_string_generic_tz`); an + // absolute input also gets its leading separator slot, a relative one is joined. + let input_capacity = |path: &[u8]| { + if P::P.is_absolute(path) { + path.len() + 2 + } else { + join_abs_needed(Fs::FileSystem::instance().top_level_dir().len(), &[path]) + 1 + } + }; + let mut from_buf = vec![0u8; input_capacity(from)]; + let mut to_buf = vec![0u8; input_capacity(to)]; + let mut scratch = vec![0u8; from.len().max(to.len()) + 1]; + let normalized_from = normalize_relative_input::

(&mut from_buf, &mut scratch, from) + .unwrap_or_else(|| unreachable!("input_capacity bounds the normalized input")); + let normalized_to = normalize_relative_input::

(&mut to_buf, &mut scratch, to) + .unwrap_or_else(|| unreachable!("input_capacity bounds the normalized input")); + relative_normalized_spilled::

(normalized_from, normalized_to) } pub fn relative_alloc(from: &[u8], to: &[u8]) -> Result, bun_alloc::AllocError> { @@ -886,6 +992,14 @@ fn windows_filesystem_root_t(path: &[T]) -> &[T] { &path[0..0] } +/// Whether `units` more units fit at `buf[at..]`; `at` only ever advances past admitted writes. +#[inline(always)] +fn has_room(buf: &[T], at: usize, units: usize) -> bool { + debug_assert!(at <= buf.len()); + units <= buf.len() - at +} + +/// [`normalize_string_generic_tz`] without NUL termination or an NT prefix. pub fn normalize_string_generic_t< 'a, T: PathChar, @@ -896,7 +1010,7 @@ pub fn normalize_string_generic_t< buf: &'a mut [T], separator: T, is_separator_t: impl Fn(T) -> bool + Copy, -) -> &'a mut [T] { +) -> Option<&'a mut [T]> { normalize_string_generic_tz::( path_, buf, @@ -905,10 +1019,13 @@ pub fn normalize_string_generic_t< ) } -// Rust cannot vary the return type on a const-generic bool without -// specialization, so -// we always return `&mut [T]` and write the NUL when `ZERO_TERMINATE`. Callers -// that need `&ZStr`/`&WStr` re-wrap with `from_raw`. +/// `None` as soon as a write does not fit `buf`, which then holds a partial +/// result. Works in place, so a `..` that later shortens the path still needs +/// the room (`normalize_string_buf_t` handles that). Output is at most input + +/// 1 (Windows: `C:` -> `C:.`, bare UNC volume + `\`, `C:\..` keeps its `..`), +/// + 4 or 6 for `ADD_NT_PREFIX`, + 1 for the NUL of `ZERO_TERMINATE`, which +/// callers re-wrap as `ZStr`/`WStr` themselves (the return type cannot vary +/// with a const generic). pub fn normalize_string_generic_tz< 'a, T: PathChar, @@ -921,7 +1038,7 @@ pub fn normalize_string_generic_tz< buf: &'a mut [T], separator: T, is_separator: impl Fn(T) -> bool + Copy, -) -> &'a mut [T] { +) -> Option<&'a mut [T]> { let is_windows = separator == T::from_u8(SEP_WINDOWS); // sep_str: single-char slice [separator], built per-call. @@ -946,11 +1063,17 @@ pub fn normalize_string_generic_tz< if is_windows { if vol_len > 0 { if ADD_NT_PREFIX { + if !has_room(buf, buf_i, 4) { + return None; + } buf[buf_i..buf_i + 4].copy_from_slice(T::lit(b"\\??\\")); buf_i += 4; } if path_[1] != T::from_u8(b':') { - // UNC paths + // UNC paths: the volume, its trailing separator, and the two units `UNC\` adds. + if !has_room(buf, buf_i, vol_len + 1 + if ADD_NT_PREFIX { 2 } else { 0 }) { + return None; + } if ADD_NT_PREFIX { // "UNC" ++ sep_str buf[buf_i..buf_i + 3].copy_from_slice(T::lit(b"UNC")); @@ -978,12 +1101,18 @@ pub fn normalize_string_generic_tz< // it is just a volume name if path_begin >= path_.len() { if ZERO_TERMINATE { + if !has_room(buf, buf_i, 1) { + return None; + } buf[buf_i] = T::from_u8(0); } - return &mut buf[0..buf_i]; + return Some(&mut buf[0..buf_i]); } } else { // drive letter + if !has_room(buf, buf_i, 2) { + return None; + } buf[buf_i] = T::to_ascii_upper(path_[0]); buf[buf_i + 1] = T::from_u8(b':'); buf_i += 2; @@ -991,6 +1120,9 @@ pub fn normalize_string_generic_tz< path_begin = 2; } } else if !path_.is_empty() && is_separator(path_[0]) { + if !has_room(buf, buf_i, 1) { + return None; + } buf[buf_i] = separator; buf_i += 1; dotdot = buf_i; @@ -1011,6 +1143,9 @@ pub fn normalize_string_generic_tz< // consume leading slashes on windows if r < n && is_separator(path[r]) { r += 1; + if !has_room(buf, buf_i, 1) { + return None; + } buf[buf_i] = separator; buf_i += 1; @@ -1046,11 +1181,17 @@ pub fn normalize_string_generic_tz< } else if ALLOW_ABOVE_ROOT { if buf_i > buf_start { // sep_str ++ ".." + if !has_room(buf, buf_i, 3) { + return None; + } buf[buf_i] = separator; buf[buf_i + 1] = T::from_u8(b'.'); buf[buf_i + 2] = T::from_u8(b'.'); buf_i += 3; } else { + if !has_room(buf, buf_i, 2) { + return None; + } buf[buf_i] = T::from_u8(b'.'); buf[buf_i + 1] = T::from_u8(b'.'); buf_i += 2; @@ -1062,17 +1203,20 @@ pub fn normalize_string_generic_tz< } // real path element. - // add slash if needed - if buf_i != buf_start && buf_i > 0 && !is_separator(buf[buf_i - 1]) { - buf[buf_i] = separator; - buf_i += 1; - } - let from = r; while r < n && !is_separator(path[r]) { r += 1; } let count = r - from; + // add slash if needed + let needs_separator = buf_i != buf_start && buf_i > 0 && !is_separator(buf[buf_i - 1]); + if !has_room(buf, buf_i, count + needs_separator as usize) { + return None; + } + if needs_separator { + buf[buf_i] = separator; + buf_i += 1; + } buf[buf_i..buf_i + count].copy_from_slice(&path[from..from + count]); buf_i += count; } @@ -1080,6 +1224,9 @@ pub fn normalize_string_generic_tz< if PRESERVE_TRAILING_SLASH { // Was there a trailing slash? Let's keep it. if buf_i > 0 && path_[path_.len() - 1] == separator && buf[buf_i - 1] != separator { + if !has_room(buf, buf_i, 1) { + return None; + } buf[buf_i] = separator; buf_i += 1; } @@ -1088,6 +1235,9 @@ pub fn normalize_string_generic_tz< if is_windows && buf_i == 2 && buf[1] == T::from_u8(b':') { // If the original path is just a relative path with a drive letter, // add . + if !has_room(buf, buf_i, 1) { + return None; + } buf[buf_i] = if !path.is_empty() && path[0] == T::from_u8(b'\\') { T::from_u8(b'\\') } else { @@ -1097,10 +1247,13 @@ pub fn normalize_string_generic_tz< } if ZERO_TERMINATE { + if !has_room(buf, buf_i, 1) { + return None; + } buf[buf_i] = T::from_u8(0); } - &mut buf[0..buf_i] + Some(&mut buf[0..buf_i]) } #[derive(Copy, Clone, PartialEq, Eq, Debug, core::marker::ConstParamTy)] @@ -1269,12 +1422,19 @@ impl Platform { } } -/// `str` must normalize into [`PARSER_BUFFER_LEN`] bytes; for input of -/// arbitrary length use [`normalize_string_spill`]. -pub fn normalize_string(str: &[u8]) -> &mut [u8] { - // returns slice into thread-local PARSER_BUFFER; valid until the - // next call on this thread. - PARSER_BUFFER.with(|b| normalize_string_buf::(str, tl_buf_mut(b))) +/// Takes input of any length; the result is valid until the next call on this thread. +pub fn normalize_string(str: &[u8]) -> &'static [u8] { + // Normalizing grows a path by at most one byte (see `normalize_string_generic_tz`). + let capacity = str.len() + 1; + if capacity <= PARSER_BUFFER_LEN { + return PARSER_BUFFER + .with(|b| &*normalize_string_buf::(str, tl_buf_mut(b))); + } + spill_out(&PARSER_SPILL, capacity, |buf| { + normalize_string_buf_checked::(str, buf) + .map(|normalized| &*normalized) + .unwrap_or_else(|| unreachable!("capacity bounds the normalized path")) + }) } /// [`normalize_string`] (thread-local buffer) when the result fits, otherwise @@ -1295,22 +1455,49 @@ pub fn normalize_string_spill<'a, const ALLOW_ABOVE_ROOT: bool, P: PlatformT>( normalize_string_buf::(str, &mut spill[..]) } -pub fn normalize_buf<'a, P: PlatformT>(str: &[u8], buf: &'a mut [u8]) -> &'a mut [u8] { +/// `None` when the result does not fit `buf`. +pub fn normalize_buf_checked<'a, P: PlatformT>( + str: &[u8], + buf: &'a mut [u8], +) -> Option<&'a mut [u8]> { normalize_buf_t::(str, buf) } -pub fn normalize_buf_z<'a, P: PlatformT>(str: &[u8], buf: &'a mut [u8]) -> &'a mut ZStr { - let norm = normalize_buf_t::(str, buf); - let len = norm.len(); +/// [`normalize_buf_checked`]; panics when the result does not fit `buf`. +#[track_caller] +pub fn normalize_buf<'a, P: PlatformT>(str: &[u8], buf: &'a mut [u8]) -> &'a mut [u8] { + let buf_len = buf.len(); + normalize_buf_checked::

(str, buf).unwrap_or_else(|| path_buffer_too_small(buf_len)) +} + +/// NUL-terminated [`normalize_buf_checked`]. +pub fn normalize_buf_z_checked<'a, P: PlatformT>( + str: &[u8], + buf: &'a mut [u8], +) -> Option<&'a mut ZStr> { + let capacity = buf.len().checked_sub(1)?; + let len = normalize_buf_t::(str, &mut buf[..capacity])?.len(); buf[len] = 0; // SAFETY: buf[len] == 0 written above - unsafe { ZStr::from_raw_mut(buf.as_mut_ptr(), len) } + Some(unsafe { ZStr::from_raw_mut(buf.as_mut_ptr(), len) }) +} + +/// [`normalize_buf_z_checked`]; panics when the result does not fit `buf`. +#[track_caller] +pub fn normalize_buf_z<'a, P: PlatformT>(str: &[u8], buf: &'a mut [u8]) -> &'a mut ZStr { + let buf_len = buf.len(); + normalize_buf_z_checked::

(str, buf).unwrap_or_else(|| path_buffer_too_small(buf_len)) } -pub fn normalize_buf_t<'a, T: PathChar, P: PlatformT>(str: &[T], buf: &'a mut [T]) -> &'a mut [T] { +/// `None` when the result does not fit `buf`. +pub fn normalize_buf_t<'a, T: PathChar, P: PlatformT>( + str: &[T], + buf: &'a mut [T], +) -> Option<&'a mut [T]> { if str.is_empty() { - buf[0] = T::from_u8(b'.'); - return &mut buf[0..1]; + let dot = buf.get_mut(0..1)?; + dot[0] = T::from_u8(b'.'); + return Some(dot); } let is_absolute = P::P.is_absolute_t::(str); @@ -1333,6 +1520,21 @@ pub fn normalize_buf_t<'a, T: PathChar, P: PlatformT>(str: &[T], buf: &'a mut [T normalize_string_buf_t::(str, buf) } +/// `None` when the result does not fit `buf`. +pub fn normalize_string_buf_checked< + 'a, + const ALLOW_ABOVE_ROOT: bool, + P: PlatformT, + const PRESERVE_TRAILING_SLASH: bool, +>( + str: &[u8], + buf: &'a mut [u8], +) -> Option<&'a mut [u8]> { + normalize_string_buf_t::(str, buf) +} + +/// [`normalize_string_buf_checked`]; panics when the result does not fit `buf`. +#[track_caller] pub fn normalize_string_buf< 'a, const ALLOW_ABOVE_ROOT: bool, @@ -1342,9 +1544,13 @@ pub fn normalize_string_buf< str: &[u8], buf: &'a mut [u8], ) -> &'a mut [u8] { - normalize_string_buf_t::(str, buf) + let buf_len = buf.len(); + normalize_string_buf_checked::(str, buf) + .unwrap_or_else(|| path_buffer_too_small(buf_len)) } +/// `None` only when the *result* does not fit `buf`: a path that `..` segments +/// shorten below its intermediate form is normalized in a scratch buffer first. fn normalize_string_buf_t< 'a, T: PathChar, @@ -1354,7 +1560,51 @@ fn normalize_string_buf_t< >( str: &[T], buf: &'a mut [T], -) -> &'a mut [T] { +) -> Option<&'a mut [T]> { + if let Some(len) = + normalize_string_buf_in_place_t::(str, buf) + .map(|normalized| normalized.len()) + { + return Some(&mut buf[..len]); + } + normalize_string_buf_via_scratch_t::(str, buf) +} + +#[cold] +#[inline(never)] +fn normalize_string_buf_via_scratch_t< + 'a, + T: PathChar, + const ALLOW_ABOVE_ROOT: bool, + P: PlatformT, + const PRESERVE_TRAILING_SLASH: bool, +>( + str: &[T], + buf: &'a mut [T], +) -> Option<&'a mut [T]> { + // Normalizing grows a path by at most one unit (see `normalize_string_generic_tz`). + let mut scratch = vec![T::from_u8(0); str.len() + 1]; + let normalized = + normalize_string_buf_in_place_t::( + str, + &mut scratch, + ) + .unwrap_or_else(|| unreachable!("the scratch buffer is sized for the input")); + let out = buf.get_mut(..normalized.len())?; + out.copy_from_slice(normalized); + Some(out) +} + +fn normalize_string_buf_in_place_t< + 'a, + T: PathChar, + const ALLOW_ABOVE_ROOT: bool, + P: PlatformT, + const PRESERVE_TRAILING_SLASH: bool, +>( + str: &[T], + buf: &'a mut [T], +) -> Option<&'a mut [T]> { match P::P { Platform::Nt => unreachable!("not implemented"), Platform::Windows => { @@ -1378,10 +1628,19 @@ pub fn join_abs<'a, P: PlatformT>(cwd: &'a [u8], part: &[u8]) -> &'a [u8] { /// This is the equivalent of path.resolve /// /// Returned path is stored in a temporary buffer. It must be copied if it needs to be stored. +/// `cwd` and each part may be of any length. // result borrows the thread-local buffer ('static) OR returns `cwd` // directly when `parts.is_empty()`. Return tied to `cwd`'s lifetime ('static: 'a). pub fn join_abs_string<'a, P: PlatformT>(cwd: &'a [u8], parts: &[&[u8]]) -> &'a [u8] { - PARSER_JOIN_INPUT_BUFFER.with(|b| join_abs_string_buf::

(cwd, tl_buf_mut(b), parts)) + let capacity = join_abs_capacity::

(cwd.len(), parts); + if capacity <= PARSER_JOIN_INPUT_BUFFER_LEN { + return PARSER_JOIN_INPUT_BUFFER + .with(|b| join_abs_string_buf::

(cwd, tl_buf_mut(b), parts)); + } + spill_out(&PARSER_JOIN_INPUT_SPILL, capacity, |buf| { + join_abs_string_buf_checked::

(cwd, buf, parts) + .unwrap_or_else(|| unreachable!("join_abs_capacity bounds the joined path")) + }) } /// [`join_abs_string`] (thread-local buffer) when the result fits, otherwise @@ -1407,10 +1666,23 @@ pub fn join_abs_string_spill<'a, P: PlatformT>( /// This is the equivalent of path.resolve /// /// Returned path is stored in a temporary buffer. It must be copied if it needs to be stored. +/// `cwd` and each part may be of any length. `parts` must not be empty: the +/// result would be `cwd` itself, which has no NUL. pub fn join_abs_string_z<'a, P: PlatformT>(cwd: &'a [u8], parts: &[&[u8]]) -> &'a ZStr { - PARSER_JOIN_INPUT_BUFFER.with(|b| join_abs_string_buf_z::

(cwd, tl_buf_mut(b), parts)) + let capacity = join_abs_capacity::

(cwd.len(), parts); + if capacity <= PARSER_JOIN_INPUT_BUFFER_LEN { + return PARSER_JOIN_INPUT_BUFFER + .with(|b| join_abs_string_buf_z::

(cwd, tl_buf_mut(b), parts)); + } + let with_nul = spill_out(&PARSER_JOIN_INPUT_SPILL, capacity, |buf| { + join_abs_string_buf_z_checked::

(cwd, buf, parts) + .unwrap_or_else(|| unreachable!("join_abs_capacity bounds the joined path")) + .as_bytes_with_nul() + }); + ZStr::from_slice_with_nul(with_nul) } +/// Fixed output capacity of [`join`] / [`join_z`]; longer results spill to the heap. const JOIN_BUF_LEN: usize = 4096; thread_local! { @@ -1418,17 +1690,37 @@ thread_local! { const { UnsafeCell::new([0u8; JOIN_BUF_LEN]) }; } +/// Takes input of any length; the result is valid until the next `join`/`join_z` on this thread. pub fn join(parts: &[&[u8]]) -> &'static [u8] { - JOIN_BUF.with(|b| join_string_buf::

(tl_buf_mut(b), parts)) + let capacity = join_needed(parts); + if capacity <= JOIN_BUF_LEN { + return JOIN_BUF.with(|b| join_string_buf::

(tl_buf_mut(b), parts)); + } + spill_out(&JOIN_SPILL, capacity, |buf| { + join_string_buf_checked::

(buf, parts) + .unwrap_or_else(|| unreachable!("join_needed bounds the joined path")) + }) } +/// [`join`], NUL-terminated. pub fn join_z(parts: &[&[u8]]) -> &'static ZStr { - JOIN_BUF.with(|b| join_z_buf::

(tl_buf_mut(b), parts)) + let capacity = join_needed(parts); + if capacity <= JOIN_BUF_LEN { + return JOIN_BUF.with(|b| join_z_buf::

(tl_buf_mut(b), parts)); + } + let with_nul = spill_out(&JOIN_SPILL, capacity, |buf| { + join_z_buf_checked::

(buf, parts) + .unwrap_or_else(|| unreachable!("join_needed bounds the joined path")) + .as_bytes_with_nul() + }); + ZStr::from_slice_with_nul(with_nul) } +/// Holds any [`join_z_buf`] result in place: one separator per part covers the +/// leading slot of `normalize_string_node_t`, + 2 covers the NUL and `.`. #[inline] fn join_needed(parts: &[&[u8]]) -> usize { - parts.iter().map(|p| p.len() + 1).sum::() + 1 + parts.iter().map(|p| p.len() + 1).sum::() + 2 } /// [`join_z_buf`] into `buf` when the result fits, otherwise into `spill` @@ -1476,31 +1768,69 @@ pub fn join_z_spill<'a, P: PlatformT>(spill: &'a mut Vec, parts: &[&[u8]]) - join_z_buf::

(&mut spill[..], parts) } +/// Where a `join*` result lies in its buffer (see `normalize_string_node_t`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Placed { + start: usize, + len: usize, +} + +impl Placed { + fn end(self) -> usize { + self.start + self.len + } +} + +/// NUL-terminated [`join_string_buf_checked`]. +pub fn join_z_buf_checked<'a, P: PlatformT>( + buf: &'a mut [u8], + parts: &[&[u8]], +) -> Option<&'a ZStr> { + let capacity = buf.len().checked_sub(1)?; + let placed = join_string_buf_t::(&mut buf[..capacity], parts)?; + buf[placed.end()] = 0; + Some(ZStr::from_slice_with_nul(&buf[placed.start..=placed.end()])) +} + +/// [`join_z_buf_checked`]; panics when the result does not fit `buf`. +#[track_caller] pub fn join_z_buf<'a, P: PlatformT>(buf: &'a mut [u8], parts: &[&[u8]]) -> &'a ZStr { - // reshaped for borrowck — capture buf base ptr before sub-borrow - let buf_base = buf.as_mut_ptr(); let buf_len = buf.len(); - let (start_offset, len) = { - let joined = join_string_buf::

(&mut buf[..buf_len - 1], parts); - ( - (joined.as_ptr() as usize) - (buf_base as usize), - joined.len(), - ) - }; - debug_assert!(start_offset + len < buf_len); - buf[start_offset + len] = 0; - // SAFETY: NUL written at buf[start_offset + len]; slice is within buf - unsafe { ZStr::from_raw(buf_base.add(start_offset), len) } + join_z_buf_checked::

(buf, parts).unwrap_or_else(|| path_buffer_too_small(buf_len)) +} + +/// `path.join` into `buf`; `None` when the result does not fit it. +pub fn join_string_buf_checked<'a, P: PlatformT>( + buf: &'a mut [u8], + parts: &[&[u8]], +) -> Option<&'a [u8]> { + let placed = join_string_buf_t::(buf, parts)?; + Some(&buf[placed.start..placed.end()]) } +/// [`join_string_buf_checked`]; panics when the result does not fit `buf`. +#[track_caller] pub fn join_string_buf<'a, P: PlatformT>(buf: &'a mut [u8], parts: &[&[u8]]) -> &'a [u8] { - join_string_buf_t::(buf, parts) + let buf_len = buf.len(); + join_string_buf_checked::

(buf, parts).unwrap_or_else(|| path_buffer_too_small(buf_len)) } /// `joinStringBufW` overload for u16 parts (no transcode): the -/// `T == u16 && Elem == u16` case. +/// `T == u16 && Elem == u16` case. `None` when the result does not fit `buf`. +pub fn join_string_buf_w_same_checked<'a, P: PlatformT>( + buf: &'a mut [u16], + parts: &[&[u16]], +) -> Option<&'a [u16]> { + let placed = join_string_buf_t_same::(buf, parts)?; + Some(&buf[placed.start..placed.end()]) +} + +/// [`join_string_buf_w_same_checked`]; panics when the result does not fit `buf`. +#[track_caller] pub fn join_string_buf_w_same<'a, P: PlatformT>(buf: &'a mut [u16], parts: &[&[u16]]) -> &'a [u16] { - join_string_buf_t_same::(buf, parts) + let buf_len = buf.len(); + join_string_buf_w_same_checked::

(buf, parts) + .unwrap_or_else(|| path_buffer_too_small(buf_len)) } const JOIN_TEMP_LEN: usize = 4096; @@ -1522,10 +1852,10 @@ fn join_temp_buf<'a, T>( /// Same-width `joinStringBufT`: parts already match `T`, so no UTF-8→16 transcode. /// split out of `join_string_buf_t` because Rust can't monomorphize on the /// parts' element types — callers pick the overload. -fn join_string_buf_t_same<'a, T: PathChar, P: PlatformT>( - buf: &'a mut [T], +fn join_string_buf_t_same( + buf: &mut [T], parts: &[&[T]], -) -> &'a [T] { +) -> Option { let mut written: usize = 0; let mut stack_temp_buf = [const { MaybeUninit::::uninit() }; JOIN_TEMP_LEN]; let mut heap_temp_buf: Vec = Vec::new(); @@ -1555,33 +1885,51 @@ fn join_string_buf_t_same<'a, T: PathChar, P: PlatformT>( } if written == 0 { - buf[0] = T::from_u8(b'.'); - return &buf[0..1]; + return join_dot(buf); } // SAFETY: the loop above wrote every unit of `temp_buf[..written]`. let joined = unsafe { temp_buf[..written].assume_init_ref() }; - normalize_string_node_t::(joined, buf) + join_normalize_into::(joined, buf) } +/// The result of joining nothing but empty parts. +fn join_dot(buf: &mut [T]) -> Option { + *buf.get_mut(0)? = T::from_u8(b'.'); + Some(Placed { start: 0, len: 1 }) +} + +/// `normalize_string_node_t` with the result-fits contract of `normalize_string_buf_t`. +fn join_normalize_into(joined: &[T], buf: &mut [T]) -> Option { + normalize_string_node_t::(joined, buf) + .or_else(|| join_normalize_via_scratch::(joined, buf)) +} + +#[cold] +#[inline(never)] +fn join_normalize_via_scratch( + joined: &[T], + buf: &mut [T], +) -> Option { + // Room for the leading slot or the one unit Windows normalization can add. + let mut scratch = vec![T::from_u8(0); joined.len() + 2]; + let in_scratch = normalize_string_node_t::(joined, &mut scratch) + .unwrap_or_else(|| unreachable!("the scratch buffer is sized for the input")); + let out = buf.get_mut(..in_scratch.len)?; + out.copy_from_slice(&scratch[in_scratch.start..in_scratch.end()]); + Some(Placed { + start: 0, + len: out.len(), + }) +} + +/// [`join_z_buf`] under its older name. +#[track_caller] pub fn join_string_buf_z<'a, P: PlatformT>(buf: &'a mut [u8], parts: &[&[u8]]) -> &'a ZStr { - // reshaped for borrowck — capture buf base ptr before sub-borrow - let buf_base = buf.as_mut_ptr(); - let buf_len = buf.len(); - let (start_offset, len) = { - let joined = join_string_buf_t::(&mut buf[..buf_len - 1], parts); - ( - (joined.as_ptr() as usize) - (buf_base as usize), - joined.len(), - ) - }; - debug_assert!(start_offset + len < buf_len); - buf[start_offset + len] = 0; - // SAFETY: NUL written at buf[start_offset + len]; slice is within buf - unsafe { ZStr::from_raw(buf_base.add(start_offset), len) } + join_z_buf::

(buf, parts) } -fn join_string_buf_t<'a, T: PathChar, P: PlatformT>(buf: &'a mut [T], parts: &[&[u8]]) -> &'a [T] { +fn join_string_buf_t(buf: &mut [T], parts: &[&[u8]]) -> Option { // Takes `&[&[u8]]` — every in-tree caller passes u8 // parts — and transcodes to u16 below when `T == u16`. let mut written: usize = 0; @@ -1617,13 +1965,12 @@ fn join_string_buf_t<'a, T: PathChar, P: PlatformT>(buf: &'a mut [T], parts: &[& } if written == 0 { - buf[0] = T::from_u8(b'.'); - return &buf[0..1]; + return join_dot(buf); } // SAFETY: the loop above wrote every unit of `temp_buf[..written]`. let joined = unsafe { temp_buf[..written].assume_init_ref() }; - normalize_string_node_t::(joined, buf) + join_normalize_into::(joined, buf) } /// Buffer length that holds `_join_abs_string_buf`'s concatenation of `cwd` and @@ -1634,6 +1981,13 @@ fn join_abs_needed(cwd_len: usize, parts: &[&[u8]]) -> usize { parts.iter().map(|p| p.len() + 1).sum::() + cwd_len + 2 } +/// Holds any [`join_abs_string_buf_z`] result: the first part's unused separator +/// slot in [`join_abs_needed`] covers the NUL; `Platform::Nt` adds `\\?\`. +#[inline] +fn join_abs_capacity(cwd_len: usize, parts: &[&[u8]]) -> usize { + join_abs_needed(cwd_len, parts) + if P::P == Platform::Nt { 4 } else { 0 } +} + /// Scratch buffer for `_join_abs_string_buf`'s unnormalized concatenation. /// Draws from the /// thread-local `path_buffer_pool` for the common case and only heap-allocates @@ -1664,74 +2018,71 @@ impl JoinScratch { } } +/// [`join_abs_string_buf_checked`]; panics when the result does not fit `buf`. +#[track_caller] pub fn join_abs_string_buf<'a, P: PlatformT>( cwd: &'a [u8], buf: &'a mut [u8], parts: &[&[u8]], ) -> &'a [u8] { - _join_abs_string_buf::(cwd, buf, parts) + let buf_len = buf.len(); + join_abs_string_buf_checked::

(cwd, buf, parts) + .unwrap_or_else(|| path_buffer_too_small(buf_len)) } -/// Like `join_abs_string_buf`, but returns null when the *normalized* result is -/// too large for `buf`. Use this when `parts` may contain user-controlled -/// input of arbitrary length. `..` segments are handled correctly: a path -/// whose unnormalized length exceeds `buf.len` but normalizes down will still -/// succeed. +/// `path.resolve(cwd, ...parts)` into `buf`; `None` only when the *normalized* +/// result does not fit it, so `parts` may be user input of any length. pub fn join_abs_string_buf_checked<'a, P: PlatformT>( cwd: &'a [u8], buf: &'a mut [u8], parts: &[&[u8]], ) -> Option<&'a [u8]> { - debug_assert!(!matches!(P::P, Platform::Nt)); - // Fast path: size check only — don't allocate a JoinScratch here since the - // inner join_abs_string_buf already has its own (avoids doubling stack usage). - let total = join_abs_needed(cwd.len(), parts); - if total < buf.len() { - return Some(join_abs_string_buf::

(cwd, buf, parts)); - } - - // Slow path: allocate a large scratch for the result. The inner - // join_abs_string_buf will heap-allocate its own temp buffer for the concat - // since `total > MAX_PATH_BYTES * 2 > sfa inline size` is likely here. - let mut scratch = vec![0u8; total]; - let joined = join_abs_string_buf::

(cwd, &mut scratch, parts); - if joined.len() > buf.len() { - return None; - } - let len = joined.len(); - buf[..len].copy_from_slice(joined); - Some(&buf[..len]) + _join_abs_string_buf::(cwd, buf, parts) +} + +/// NUL-terminated [`join_abs_string_buf_checked`]; `parts` must not be empty. +pub fn join_abs_string_buf_z_checked<'a, P: PlatformT>( + cwd: &'a [u8], + buf: &'a mut [u8], + parts: &[&[u8]], +) -> Option<&'a ZStr> { + let len = _join_abs_string_buf::(cwd, buf, parts)?.len(); + Some(ZStr::from_buf(buf, len)) } +/// [`join_abs_string_buf_z_checked`]; panics when the result does not fit `buf`. +#[track_caller] pub fn join_abs_string_buf_z<'a, P: PlatformT>( cwd: &'a [u8], buf: &'a mut [u8], parts: &[&[u8]], ) -> &'a ZStr { - let r = _join_abs_string_buf::(cwd, buf, parts); - // SAFETY: IS_SENTINEL=true wrote NUL at r.len() - unsafe { ZStr::from_raw(r.as_ptr(), r.len()) } + let buf_len = buf.len(); + join_abs_string_buf_z_checked::

(cwd, buf, parts) + .unwrap_or_else(|| path_buffer_too_small(buf_len)) } -// We always return `&[u8]`; when `IS_SENTINEL` a NUL is written -// at `result.len()` and callers (e.g. `join_abs_string_buf_z`) re-wrap as `ZStr`. +// With `IS_SENTINEL` the result starts at `buf[0]` and a NUL follows it, for +// `join_abs_string_buf_z_checked` to re-wrap; `None` when either does not fit. fn _join_abs_string_buf<'a, const IS_SENTINEL: bool, P: PlatformT>( _cwd: &'a [u8], buf: &'a mut [u8], _parts: &[&[u8]], -) -> &'a [u8] { +) -> Option<&'a [u8]> { if P::P == Platform::Windows || (cfg!(windows) && P::P == Platform::Loose) { return join_abs_string_buf_windows::(_cwd, buf, _parts); } if P::P == Platform::Nt { - let end_path = join_abs_string_buf_windows::(_cwd, &mut buf[4..], _parts); - let end_len = end_path.len(); - buf[0..4].copy_from_slice(b"\\\\?\\"); - if IS_SENTINEL { - buf[end_len + 4] = 0; - } - return &buf[0..end_len + 4]; + const NT_PREFIX: &[u8] = b"\\\\?\\"; + let end_len = join_abs_string_buf_windows::( + _cwd, + buf.get_mut(NT_PREFIX.len()..)?, + _parts, + )? + .len(); + buf[..NT_PREFIX.len()].copy_from_slice(NT_PREFIX); + return buf.get(..NT_PREFIX.len() + end_len); } let mut parts: &[&[u8]] = _parts; @@ -1739,7 +2090,7 @@ fn _join_abs_string_buf<'a, const IS_SENTINEL: bool, P: PlatformT>( if IS_SENTINEL { unreachable!(); } - return _cwd; + return Some(_cwd); } if matches!(P::P, Platform::Loose | Platform::Posix) @@ -1750,11 +2101,12 @@ fn _join_abs_string_buf<'a, const IS_SENTINEL: bool, P: PlatformT>( // A bare `b"/"` literal is NOT NUL-terminated and not in `buf`, breaking callers // that assume buf-backing (`ZStr::from_raw`, trailing-slash check). // Write into `buf` so the result is always buf-backed and sentinel-safe. - buf[0] = b'/'; + let root = buf.get_mut(..1 + IS_SENTINEL as usize)?; + root[0] = b'/'; if IS_SENTINEL { - buf[1] = 0; + root[1] = 0; } - return &buf[0..1]; + return Some(&root[..1]); } let mut cwd = if cfg!(windows) && _cwd.len() >= 3 && _cwd[1] == b':' { @@ -1820,32 +2172,38 @@ fn _join_abs_string_buf<'a, const IS_SENTINEL: bool, P: PlatformT>( }; // Copy leading separator into buf (order-independent with normalize, // which writes into buf[leading_len..]). - buf[..leading_len].copy_from_slice(&leading_buf[..leading_len]); + let capacity = buf.len().checked_sub(IS_SENTINEL as usize)?; + let out_buf = &mut buf[..capacity]; + out_buf + .get_mut(..leading_len)? + .copy_from_slice(&leading_buf[..leading_len]); - let result = normalize_string_buf::( + let result_len = normalize_string_buf_checked::( &temp_buf[leading_len..out], - &mut buf[leading_len..], - ); - let result_len = result.len(); + &mut out_buf[leading_len..], + )? + .len(); + let len = leading_len + result_len; if IS_SENTINEL { - buf[result_len + leading_len] = 0; + buf[len] = 0; } - &buf[0..result_len + leading_len] + Some(&buf[..len]) } +/// See `_join_abs_string_buf` for the return contract. fn join_abs_string_buf_windows<'a, const IS_SENTINEL: bool>( cwd: &'a [u8], buf: &'a mut [u8], parts: &[&[u8]], -) -> &'a [u8] { +) -> Option<&'a [u8]> { debug_assert!(crate::is_absolute_windows(cwd)); if parts.is_empty() { if IS_SENTINEL { unreachable!(); } - return cwd; + return Some(cwd); } // path.resolve is a bit different on Windows, as there are multiple possible filesystem roots. @@ -1933,13 +2291,17 @@ fn join_abs_string_buf_windows<'a, const IS_SENTINEL: bool>( out += part_without_vol.len(); } - let result = normalize_string_buf::(&temp_buf[0..out], buf); - let result_len = result.len(); + let capacity = buf.len().checked_sub(IS_SENTINEL as usize)?; + let result_len = normalize_string_buf_checked::( + &temp_buf[0..out], + &mut buf[..capacity], + )? + .len(); if IS_SENTINEL { buf[result_len] = 0; } - &buf[0..result_len] + Some(&buf[..result_len]) } // Separator predicates live in T0 `bun_core::path_sep`; re-export the full set @@ -1988,7 +2350,7 @@ fn normalize_string_loose_buf_t< >( str: &[T], buf: &'a mut [T], -) -> &'a mut [T] { +) -> Option<&'a mut [T]> { normalize_string_generic_t::( str, buf, @@ -2005,7 +2367,7 @@ fn normalize_string_windows_t< >( str: &[T], buf: &'a mut [T], -) -> &'a mut [T] { +) -> Option<&'a mut [T]> { normalize_string_generic_t::( str, buf, @@ -2014,13 +2376,11 @@ fn normalize_string_windows_t< ) } -fn normalize_string_node_t<'a, T: PathChar, P: PlatformT>( - str: &[T], - buf: &'a mut [T], -) -> &'a mut [T] { +/// `path.normalize` for `join*`. Off Windows a relative result starts at +/// `buf[1]`, behind the slot for an absolute result's leading separator. +fn normalize_string_node_t(str: &[T], buf: &mut [T]) -> Option { if str.is_empty() { - buf[0] = T::from_u8(b'.'); - return &mut buf[0..1]; + return join_dot(buf); } let is_absolute = P::P.is_absolute_t::(str); @@ -2034,58 +2394,50 @@ fn normalize_string_node_t<'a, T: PathChar, P: PlatformT>( let separator_t = T::from_u8(P::P.separator()); let is_sep_fn = |c: T| P::P.is_separator_t::(c); + let out = buf.get_mut(buf_off..)?; let out_len = if !is_absolute { - normalize_string_generic_t::( - str, - &mut buf[buf_off..], - separator_t, - is_sep_fn, - ) - .len() + normalize_string_generic_t::(str, out, separator_t, is_sep_fn)?.len() } else { - normalize_string_generic_t::( - str, - &mut buf[buf_off..], - separator_t, - is_sep_fn, - ) - .len() + normalize_string_generic_t::(str, out, separator_t, is_sep_fn)?.len() }; let mut out_len = out_len; if out_len == 0 { if is_absolute { - buf[0] = separator_t; - return &mut buf[0..1]; + *buf.get_mut(0)? = separator_t; + return Some(Placed { start: 0, len: 1 }); } if trailing_separator { let sep = P::P.trailing_separator(); - buf[0] = T::from_u8(sep[0]); - buf[1] = T::from_u8(sep[1]); - return &mut buf[0..2]; + let dot_slash = buf.get_mut(0..2)?; + dot_slash[0] = T::from_u8(sep[0]); + dot_slash[1] = T::from_u8(sep[1]); + return Some(Placed { start: 0, len: 2 }); } - buf[0] = T::from_u8(b'.'); - return &mut buf[0..1]; + return join_dot(buf); } if trailing_separator { if !P::P.is_separator_t::(buf[buf_off + out_len - 1]) { - buf[buf_off + out_len] = separator_t; + *buf.get_mut(buf_off + out_len)? = separator_t; out_len += 1; } } - if is_absolute { - if P::P == Platform::Windows { - return &mut buf[buf_off..buf_off + out_len]; - } + if is_absolute && P::P != Platform::Windows { buf[0] = separator_t; - return &mut buf[0..out_len + 1]; + return Some(Placed { + start: 0, + len: out_len + 1, + }); } - &mut buf[buf_off..buf_off + out_len] + Some(Placed { + start: buf_off, + len: out_len, + }) } /// **NOT** plain basename (see @@ -2529,6 +2881,46 @@ mod tests { .unwrap_or(text_len) } + /// Returns `haystack_len` when `needle` does not occur, like the kernel. + #[unsafe(no_mangle)] + unsafe extern "C" fn highway_last_index_of_char( + haystack: *const u8, + haystack_len: usize, + needle: u8, + ) -> usize { + // SAFETY: test stub; callers pass a valid (ptr, len) pair. + let haystack = unsafe { core::slice::from_raw_parts(haystack, haystack_len) }; + haystack + .iter() + .rposition(|&b| b == needle) + .unwrap_or(haystack_len) + } + + /// Returns `usize::MAX` when `needle` does not occur, like the kernel. + #[unsafe(no_mangle)] + unsafe extern "C" fn highway_memrmem16( + haystack: *const u16, + haystack_len: usize, + needle: *const u16, + needle_len: usize, + ) -> usize { + // SAFETY: test stub; callers pass valid (ptr, len) pairs. + let (haystack, needle) = unsafe { + ( + core::slice::from_raw_parts(haystack, haystack_len), + core::slice::from_raw_parts(needle, needle_len), + ) + }; + haystack_len + .checked_sub(needle_len) + .and_then(|last| { + (0..=last) + .rev() + .find(|&i| haystack[i..i + needle_len] == *needle) + }) + .unwrap_or(usize::MAX) + } + #[test] fn normalize_string_spill_leaves_spill_untouched_when_the_input_fits() { let mut spill = Vec::new(); @@ -2705,4 +3097,535 @@ mod tests { &expected[..] ); } + + /// `f` into a buffer one byte shorter than `expected` fails, and into one + /// exactly as long succeeds with `expected`, no matter where inside the + /// buffer the result is placed. + #[track_caller] + fn assert_fits_exactly(expected: &[u8], f: impl Fn(&mut [u8]) -> Option>) { + let mut short = vec![0xAAu8; expected.len() - 1]; + assert_eq!(f(&mut short), None, "must not fit {} bytes", short.len()); + let mut exact = vec![0xAAu8; expected.len()]; + assert_eq!(f(&mut exact).as_deref(), Some(expected)); + assert_eq!(f(&mut []), None); + } + + fn normalize_posix<'a>(path: &[u8], buf: &'a mut [u8]) -> Option<&'a mut [u8]> { + normalize_string_generic_t::(path, buf, b'/', |c| c == b'/') + } + + fn normalize_windows<'a, const ALLOW_ABOVE_ROOT: bool>( + path: &[u8], + buf: &'a mut [u8], + ) -> Option<&'a mut [u8]> { + normalize_string_generic_t::(path, buf, b'\\', is_sep_any) + } + + #[test] + fn normalizer_reports_instead_of_writing_past_the_buffer() { + let long = vec![b'a'; 100]; + assert_fits_exactly(&long, |buf| normalize_posix(&long, buf).map(|s| s.to_vec())); + assert_fits_exactly(b"a/b/c", |buf| { + normalize_posix(b"a//./b/x/../c", buf).map(|s| s.to_vec()) + }); + assert_fits_exactly(b"../..", |buf| { + normalize_posix(b"a/../../..", buf).map(|s| s.to_vec()) + }); + + // The primitive works in place, so it needs room for the intermediate + // form (`dir`) of a path that collapses; the `*_checked` functions + // built on it only need room for the result. + assert_eq!(normalize_posix(b"dir/../x", &mut [0u8; 1]), None); + assert_eq!( + normalize_posix(b"dir/../x", &mut [0u8; 3]).as_deref(), + Some(&b"x"[..]) + ); + let mut collapsing = b"dir/../".repeat(1000); + collapsing.extend_from_slice(b"x"); + let mut one = [0u8; 1]; + assert_eq!( + normalize_buf_checked::(&collapsing, &mut one).as_deref(), + Some(&b"x"[..]) + ); + assert_eq!( + normalize_string_buf_checked::(&collapsing, &mut one) + .as_deref(), + Some(&b"x"[..]) + ); + assert_eq!( + normalize_buf_checked::(b"dir/../xy", &mut one), + None + ); + } + + #[test] + fn normalizer_checks_the_units_windows_adds_to_its_input() { + let windows = |path: &'static [u8], expected: &[u8]| { + assert_fits_exactly(expected, |buf| { + normalize_windows::(path, buf).map(|s| s.to_vec()) + }); + }; + windows(b"c:", b"C:."); + windows(b"c:\\x\\", b"C:\\x\\"); + windows(b"\\\\server\\share", b"\\\\server\\share\\"); + windows(b"\\\\server/share\\x", b"\\\\server\\share\\x"); + windows(b"\\x\\..\\y", b"\\y"); + assert_fits_exactly(b"C:\\\\..", |buf| { + normalize_windows::(b"C:\\..", buf).map(|s| s.to_vec()) + }); + } + + #[test] + fn normalizer_checks_the_nt_prefix_and_the_nul() { + fn nt(path: &str, buf: &mut [u16]) -> Option> { + let path: Vec = path.encode_utf16().collect(); + let len = normalize_string_generic_tz::( + &path, + buf, + b'\\' as u16, + is_sep_any_t::, + )? + .len(); + assert_eq!(buf[len], 0); + Some(buf[..=len].to_vec()) + } + let check = |expected_with_nul: &str, f: &dyn Fn(&mut [u16]) -> Option>| { + let expected: Vec = expected_with_nul.encode_utf16().collect(); + let mut short = vec![0xAAAAu16; expected.len() - 1]; + assert_eq!(f(&mut short), None, "{expected_with_nul:?} must not fit"); + let mut exact = vec![0xAAAAu16; expected.len()]; + assert_eq!(f(&mut exact), Some(expected)); + assert_eq!(f(&mut []), None); + }; + check("\\??\\C:\\x\0", &|buf| nt::("C:/./x", buf)); + check("\\??\\C:\\\0", &|buf| nt::("C:\\", buf)); + check("\\??\\UNC\\s\\sh\\\0", &|buf| nt::("\\\\s\\sh", buf)); + check("\\??\\UNC\\s\\sh\\x\0", &|buf| nt::("//s/sh/x", buf)); + check("\\\\s\\sh\\\0", &|buf| nt::("\\\\s\\sh", buf)); + check("C:\\x\\y\0", &|buf| nt::("C:/x//./y", buf)); + check("x\0", &|buf| nt::("x", buf)); + // The primitive works in place: the intermediate form `C:\x\y` needs + // six units even though the result is shorter. + assert_eq!(nt::("C:\\x\\y\\..", &mut [0u16; 5]), None); + let expected: Vec = "C:\\x\0".encode_utf16().collect(); + assert_eq!(nt::("C:\\x\\y\\..", &mut [0u16; 6]), Some(expected)); + } + + #[test] + fn checked_buffer_variants_fit_exactly() { + assert_fits_exactly(b"a/b", |buf| { + normalize_buf_checked::(b"a/./b", buf).map(|s| s.to_vec()) + }); + assert_fits_exactly(b".", |buf| { + normalize_buf_checked::(b"", buf).map(|s| s.to_vec()) + }); + assert_fits_exactly(b"a/b/\0", |buf| { + normalize_buf_z_checked::(b"a//b/", buf) + .map(|s| s.as_bytes_with_nul().to_vec()) + }); + assert_fits_exactly(b"/work/b", |buf| { + join_abs_string_buf_checked::(b"/work", buf, &[b"a/../b"]) + .map(|s| s.to_vec()) + }); + assert_fits_exactly(b"/work/b\0", |buf| { + join_abs_string_buf_z_checked::(b"/work", buf, &[b"a", b"../b"]) + .map(|s| s.as_bytes_with_nul().to_vec()) + }); + assert_fits_exactly(b"/\0", |buf| { + join_abs_string_buf_z_checked::(b"/work", buf, &[b"/"]) + .map(|s| s.as_bytes_with_nul().to_vec()) + }); + assert_fits_exactly(b"C:\\work\\b\0", |buf| { + join_abs_string_buf_z_checked::(b"C:\\work", buf, &[b"a/../b"]) + .map(|s| s.as_bytes_with_nul().to_vec()) + }); + assert_fits_exactly(b"\\\\?\\C:\\work\\b\0", |buf| { + join_abs_string_buf_z_checked::(b"C:\\work", buf, &[b"b"]) + .map(|s| s.as_bytes_with_nul().to_vec()) + }); + assert_fits_exactly(b"C:\\b", |buf| { + join_string_buf_checked::(buf, &[b"C:\\", b"a\\..\\b"]) + .map(|s| s.to_vec()) + }); + assert_fits_exactly(b"foo\\baz", |buf| { + let mut wide = vec![0u16; buf.len()]; + let foo: Vec = "foo".encode_utf16().collect(); + let rest: Vec = "bar\\..\\baz".encode_utf16().collect(); + let out = + join_string_buf_w_same_checked::(&mut wide, &[&foo, &rest])?; + Some(out.iter().map(|&c| c as u8).collect()) + }); + + // A collapsing part that is longer than the buffer still joins. + let mut part = b"sub/../".repeat(100); + part.extend_from_slice(b"x"); + let mut buf = [0u8; 8]; + assert_eq!( + join_abs_string_buf_checked::(b"/work", &mut buf, &[&part]), + Some(&b"/work/x"[..]) + ); + assert_eq!( + join_abs_string_buf_checked::(b"/work", &mut buf, &[b"toolong1"]), + None + ); + } + + #[test] + fn join_checked_variants_only_need_room_for_the_result() { + // Off Windows `normalize_string_node_t` places a relative result at + // `buf[1..]`; a buffer with room for the result alone still works. + let mut buf = [0u8; 8]; + let placed = |start: usize, len: usize| Some(Placed { start, len }); + assert_eq!( + join_string_buf_t::(&mut buf, &[b"a", b"b"]), + placed(1, 3) + ); + assert_eq!( + join_string_buf_t::(&mut buf, &[b"/a", b"b"]), + placed(0, 4) + ); + assert_eq!( + join_string_buf_t::(&mut buf, &[b"a", b"b"]), + placed(0, 3) + ); + assert_eq!( + join_string_buf_t::(&mut buf[..3], &[b"a", b"b"]), + placed(0, 3) + ); + assert_fits_exactly(b"a/b", |buf| { + join_string_buf_checked::(buf, &[b"a", b"b"]).map(|s| s.to_vec()) + }); + assert_fits_exactly(b"a/b\0", |buf| { + join_z_buf_checked::(buf, &[b"a", b"b"]) + .map(|z| z.as_bytes_with_nul().to_vec()) + }); + assert_fits_exactly(b"/", |buf| { + join_string_buf_checked::(buf, &[b"/"]).map(|s| s.to_vec()) + }); + + // The same goes for a result that only fits once `..` has collapsed + // the path. + let mut two = [0u8; 2]; + assert_eq!( + join_string_buf_checked::(&mut two[..1], &[b"dir", b"../x"]), + Some(&b"x"[..]) + ); + assert_eq!( + join_z_buf_checked::(&mut two, &[b"dir", b"../x"]) + .map(|z| z.as_bytes_with_nul().to_vec()) + .as_deref(), + Some(&b"x\0"[..]) + ); + assert_eq!( + join_string_buf_checked::(&mut two, &[b"dir", b"../xyz"]), + None + ); + } + + #[test] + fn join_needed_holds_for_the_results_that_grow() { + fn check(parts: &[&[u8]], expected: &[u8]) { + let mut buf = vec![0u8; join_needed(parts)]; + assert_eq!( + join_z_buf_checked::

(&mut buf, parts) + .map(|z| z.as_bytes().to_vec()) + .as_deref(), + Some(expected) + ); + } + check::(&[b"C:"], b"C:."); + check::(&[b"\\\\s\\sh"], b"\\\\s\\sh\\"); + check::(&[b"..", b".."], b"..\\.."); + check::(&[b"a", b"b/"], b"a/b/"); + check::(&[b"a/"], b"a/"); + check::(&[b"./"], b"./"); + check::(&[b"/"], b"/"); + check::(&[b"", b""], b"."); + check::(&[], b"."); + } + + #[test] + fn join_abs_needed_holds_for_the_results_that_grow() { + fn check(cwd: &[u8], parts: &[&[u8]], expected: &[u8]) { + let mut buf = vec![0u8; join_abs_capacity::

(cwd.len(), parts)]; + assert_eq!( + join_abs_string_buf_z_checked::

(cwd, &mut buf, parts) + .map(|z| z.as_bytes().to_vec()) + .as_deref(), + Some(expected) + ); + } + check::(b"/x", &[b"y"], b"/x/y"); + check::(b"/", &[b"y"], b"/y"); + check::(b"/", &[b"a", b"/b", b"c"], b"/b/c"); + check::(b"/x", &[b"", b"y/"], b"/x/y/"); + check::(b"/w", &[b"a\\b"], b"/w/a/b"); + // The root comes from one part and the directory from another. + check::( + b"C:\\", + &[b"/one", b"D:two", b"three"], + b"D:\\one\\two\\three", + ); + check::(b"\\\\s\\sh", &[b"x"], b"\\\\s\\sh\\x"); + check::(b"C:\\w", &[b"x"], b"\\\\?\\C:\\w\\x"); + } + + fn relative_into(buf: &mut [u8], from: &[u8], to: &[u8]) -> Option> { + relative_normalized_buf_checked::(buf, from, to).map(|r| r.to_vec()) + } + + #[test] + fn relative_checked_variants_fit_exactly() { + assert_fits_exactly(b"../../c/d", |buf| relative_into(buf, b"/x/a/b", b"/x/c/d")); + assert_fits_exactly(b"../..", |buf| relative_into(buf, b"/x/a/b", b"/x")); + assert_fits_exactly(b"b/c", |buf| relative_into(buf, b"/x/a", b"/x/a/b/c")); + assert_fits_exactly(b"a/b", |buf| relative_into(buf, b"/", b"/a/b")); + assert_eq!(relative_into(&mut [], b"/same", b"/same"), Some(Vec::new())); + + assert_fits_exactly(b"../d", |buf| { + relative_platform_buf_checked::(buf, b"/x/a/", b"/x//d") + .map(|r| r.to_vec()) + }); + if cfg!(unix) { + assert_fits_exactly(b"../b/c\0", |buf| { + relative_buf_z_checked(buf, b"/x/a", b"/x/b/./c") + .map(|z| z.as_bytes_with_nul().to_vec()) + }); + } + } + + // Windows paths are compared case-insensitively through libc, which Miri + // has no shim for. + #[test] + #[cfg_attr(miri, ignore)] + fn relative_checked_variants_fit_exactly_on_windows() { + fn relative_windows( + buf: &mut [u8], + from: &[u8], + to: &[u8], + ) -> Option> { + relative_normalized_buf_checked::(buf, from, to) + .map(|r| r.to_vec()) + } + assert_fits_exactly(b"..\\..\\y", |buf| { + relative_windows::(buf, b"C:\\x\\a\\b", b"C:\\x\\y") + }); + assert_fits_exactly(b"D:\\y", |buf| { + relative_windows::(buf, b"C:\\x", b"D:\\y\\") + }); + + // From a root the result is a suffix of `to`: copied when asked to, + // otherwise borrowed from `to` without touching the buffer. + assert_fits_exactly(b"x\\y", |buf| { + relative_windows::(buf, b"C:\\", b"C:\\x\\y") + }); + assert_eq!( + relative_windows::(&mut [], b"C:\\", b"C:\\x\\y").as_deref(), + Some(&b"x\\y"[..]) + ); + } + + #[test] + fn relative_result_capacity_bounds_every_input_shape() { + let inputs: &[&[u8]] = &[ + b"/", + b"/a", + b"/a/", + b"/a/b/c", + b"/ab/cd", + b"/x/y/z/w", + b"a", + b"a/b", + b"///", + b"/////", + b"//a//b//", + b"/a/../..", + b"..", + b"../..", + b"", + b"/\xff/\xfe", + ]; + let mut big = vec![0u8; 4096]; + for from in inputs { + for to in inputs { + let expected = relative_into(&mut big, from, to).unwrap(); + assert!( + expected.len() <= relative_result_capacity(from.len(), to.len()), + "relative({from:?}, {to:?}) = {expected:?}" + ); + let mut exact = vec![0u8; relative_result_capacity(from.len(), to.len())]; + assert_eq!( + relative_into(&mut exact, from, to).as_deref(), + Some(&expected[..]) + ); + } + } + } + + /// The results the spilling wrappers return must match a direct + /// computation into a buffer that is large enough. + fn reference_join_abs(cwd: &[u8], parts: &[&[u8]]) -> Vec { + let mut buf = vec![0u8; join_abs_needed(cwd.len(), parts)]; + join_abs_string_buf::(cwd, &mut buf, parts).to_vec() + } + + #[test] + fn normalize_string_spills_results_longer_than_its_fixed_buffer() { + let mut input = b"./".to_vec(); + input.resize(PARSER_BUFFER_LEN * 2, b'b'); + input.extend_from_slice(b"/./x"); + let mut expected = vec![b'b'; PARSER_BUFFER_LEN * 2 - 2]; + expected.extend_from_slice(b"/x"); + assert_eq!( + normalize_string::(&input), + &expected[..] + ); + + // Exactly one byte too long for the fixed buffer, and a result that + // grows by one byte on Windows. + let mut unc = b"\\\\".to_vec(); + unc.resize(PARSER_BUFFER_LEN, b's'); + let mut expected = unc.clone(); + expected.push(b'\\'); + assert_eq!( + normalize_string::(&unc), + &expected[..] + ); + + // Back to the fixed buffer afterwards. + assert_eq!(normalize_string::(b"a/./b"), b"a/b"); + } + + #[test] + fn join_spills_results_longer_than_its_fixed_buffer() { + let long = vec![b'a'; JOIN_BUF_LEN]; + let mut expected = long.clone(); + expected.extend_from_slice(b"/y"); + assert_eq!(join::(&[&long, b"x/../y"]), &expected[..]); + expected.push(0); + assert_eq!( + join_z::(&[&long, b"x/../y"]).as_bytes_with_nul(), + &expected[..] + ); + + // An absolute result is placed at `buf[0]`, a relative one at `buf[1]`. + let mut abs = b"/".to_vec(); + abs.extend_from_slice(&long); + assert_eq!(join::(&[&abs]), &abs[..]); + assert_eq!(join::(&[&long]), &long[..]); + assert_eq!(join::(&[b"a", b"b"]), b"a/b"); + } + + #[test] + fn join_accepts_its_previous_spilled_result_as_input() { + let long = vec![b'a'; JOIN_BUF_LEN]; + let previous = join::(&[&long]); + let mut expected = long.clone(); + expected.extend_from_slice(b"/b"); + assert_eq!(join::(&[previous, b"b"]), &expected[..]); + } + + #[test] + fn join_abs_string_spills_results_longer_than_its_fixed_buffer() { + let name = vec![b'n'; PARSER_JOIN_INPUT_BUFFER_LEN]; + let parts: &[&[u8]] = &[&name, b"..", &name, b"x"]; + let expected = reference_join_abs(b"/work", parts); + assert!(expected.len() > PARSER_JOIN_INPUT_BUFFER_LEN); + assert_eq!( + join_abs_string::(b"/work", parts), + &expected[..] + ); + let z = join_abs_string_z::(b"/work", parts); + assert_eq!(z.as_bytes(), &expected[..]); + assert_eq!(z.as_bytes_with_nul()[expected.len()], 0); + + let mut cwd = b"/".to_vec(); + cwd.resize(PARSER_JOIN_INPUT_BUFFER_LEN * 2, b'c'); + assert_eq!( + join_abs_string::(&cwd, &[b"./x", b".."]), + &cwd[..] + ); + // With no parts the cwd itself is the result; it is not in any buffer. + assert_eq!(join_abs_string::(&cwd, &[]), &cwd[..]); + assert_eq!( + join_abs_string::(b"/work", &[b"y"]), + b"/work/y" + ); + } + + // Path-buffer-sized inputs take most of a minute under Miri; `spill_out`, + // the only unsafe code on this path, is covered by the join tests above. + #[test] + #[cfg_attr(miri, ignore)] + fn relative_spills_inputs_and_results_longer_than_a_path_buffer() { + Fs::FileSystem::init(b"/work"); + let mut big = vec![0u8; MAX_PATH_BYTES * 8]; + + let mut from = b"/".to_vec(); + from.resize(MAX_PATH_BYTES, b'f'); + let mut to = b"/".to_vec(); + to.resize(MAX_PATH_BYTES * 2, b't'); + let expected = relative_into(&mut big, &from, &to).unwrap(); + assert_eq!( + relative_platform::(&from, &to), + &expected[..] + ); + let expected = relative_into(&mut big, &to, &from).unwrap(); + assert_eq!(expected.len(), from.len() + 2); + assert_eq!( + relative_platform::(&to, &from), + &expected[..] + ); + // A second spill replaces the first. + assert_eq!( + relative_platform::(&to, &from), + &expected[..] + ); + assert_eq!( + relative_platform_buf_checked::(&mut big, &from, &to), + None + ); + + // Relative inputs resolve against the top-level dir before spilling. + let mut rel = Vec::new(); + while rel.len() < MAX_PATH_BYTES { + rel.extend_from_slice(b"deep/"); + } + assert_eq!( + relative_platform::(b"/work", &rel), + &rel[..rel.len() - 1] + ); + let mut resolved = b"/work/".to_vec(); + resolved.extend_from_slice(&rel); + let expected = relative_into(&mut big, &resolved, b"/work/x").unwrap(); + assert_eq!( + relative_platform::(&rel, b"/work/x"), + &expected[..] + ); + + // Inputs that fit a path buffer but whose result does not. + let deep = b"/a".repeat(MAX_PATH_BYTES / 2 - 8); + let expected = relative_into(&mut big, &deep, b"/b").unwrap(); + assert!(expected.len() > MAX_PATH_BYTES); + assert_eq!( + relative_platform::(&deep, b"/b"), + &expected[..] + ); + assert_eq!( + relative_normalized::(&deep, b"/b"), + &expected[..] + ); + if cfg!(unix) { + assert_eq!( + relative_alloc(&deep, b"/b").unwrap().as_ref(), + &expected[..] + ); + } + + // Back to the thread-local buffers afterwards. + assert_eq!( + relative_platform::(b"/x/a", b"/x/b"), + b"../b" + ); + } } diff --git a/src/runtime/api/Archive.rs b/src/runtime/api/Archive.rs index ef6b68867ee4..b3ea165f8210 100644 --- a/src/runtime/api/Archive.rs +++ b/src/runtime/api/Archive.rs @@ -1339,12 +1339,12 @@ fn extract_to_disk_filtered( let raw_pathname = raw_pathname_z.as_bytes(); let mut normalized_buf = bun_paths::PathBuffer::uninit(); - if raw_pathname.len() >= normalized_buf.len() { - continue; - } - let pathname_z: &bun_core::ZStr = bun_paths::resolve_path::normalize_buf_z::< + let Some(pathname_z) = bun_paths::resolve_path::normalize_buf_z_checked::< bun_paths::platform::Posix, - >(raw_pathname, &mut normalized_buf[..]); + >(raw_pathname, &mut normalized_buf[..]) else { + continue; + }; + let pathname_z: &bun_core::ZStr = pathname_z; let pathname = pathname_z.as_bytes(); // Validate path safety (reject absolute paths, path traversal) diff --git a/src/runtime/shell/builtin/cp.rs b/src/runtime/shell/builtin/cp.rs index 2e9edb302bfb..0ad6ad1eb193 100644 --- a/src/runtime/shell/builtin/cp.rs +++ b/src/runtime/shell/builtin/cp.rs @@ -579,6 +579,16 @@ impl ShellCpTask { .is_some_and(|&c| resolve_path::Platform::AUTO.is_separator(c)) } + /// `ENAMETOOLONG` naming `parts` (the target path that did not fit a path buffer). + #[cold] + fn target_too_long(parts: &[&[u8]]) -> ShellErr { + let path = parts.join(&resolve_path::Platform::AUTO.separator()); + ShellErr::new_sys( + &bun_sys::Error::from_code(bun_sys::E::ENAMETOOLONG, bun_sys::Tag::copyfile) + .with_path(&path), + ) + } + fn is_dir(path: &bun_core::ZStr) -> bun_sys::Maybe { #[cfg(windows)] { @@ -618,13 +628,15 @@ impl ShellCpTask { } else { resolve_path::join_z::(&[&self.cwd_path, &self.src]) }; - let mut tgt: &bun_core::ZStr = if Platform::AUTO.is_absolute(&self.tgt) { - resolve_path::join_z_buf::(buf2.as_mut_slice(), &[&self.tgt]) + let tgt_parts: &[&[u8]] = if Platform::AUTO.is_absolute(&self.tgt) { + &[&self.tgt] } else { - resolve_path::join_z_buf::( - buf2.as_mut_slice(), - &[&self.cwd_path, &self.tgt], - ) + &[&self.cwd_path, &self.tgt] + }; + let Some(mut tgt) = + resolve_path::join_z_buf_checked::(buf2.as_mut_slice(), tgt_parts) + else { + return Some(Self::target_too_long(tgt_parts)); }; // Cases: @@ -678,10 +690,13 @@ impl ShellCpTask { // 2nd synopsis: -R source_files... -> target. if tgt_exists { let basename = resolve_path::basename(src.as_bytes()); - tgt = resolve_path::join_z_buf::( + tgt = match resolve_path::join_z_buf_checked::( buf3.as_mut_slice(), &[tgt.as_bytes(), basename], - ); + ) { + Some(tgt) => tgt, + None => return Some(Self::target_too_long(&[tgt.as_bytes(), basename])), + }; } else if self.operands == 2 { // source_dir -> new_target_dir. } else { @@ -709,10 +724,13 @@ impl ShellCpTask { )); } let basename = resolve_path::basename(src.as_bytes()); - tgt = resolve_path::join_z_buf::( + tgt = match resolve_path::join_z_buf_checked::( buf3.as_mut_slice(), &[tgt.as_bytes(), basename], - ); + ) { + Some(tgt) => tgt, + None => return Some(Self::target_too_long(&[tgt.as_bytes(), basename])), + }; _copying_many = true; } diff --git a/src/runtime/shell/builtin/mv.rs b/src/runtime/shell/builtin/mv.rs index 915d34c3bfaf..7473264b83b0 100644 --- a/src/runtime/shell/builtin/mv.rs +++ b/src/runtime/shell/builtin/mv.rs @@ -633,17 +633,17 @@ impl ShellMvBatchedTask { buf: &mut PathBuffer, ) -> Result<(), bun_sys::Error> { let base = resolve_path::basename(src.as_bytes()); - let len = - resolve_path::normalize_buf::(base, &mut buf[..]).len(); - if len + 1 >= bun_paths::MAX_PATH_BYTES { - return Err(bun_sys::Error::from_code( + let result = match resolve_path::normalize_buf_z_checked::( + base, + &mut buf[..], + ) { + Some(path_in_dir) => Self::do_rename(cwd, src, target_fd, path_in_dir), + None => Err(bun_sys::Error::from_code( bun_sys::E::ENAMETOOLONG, bun_sys::Tag::rename, - )); - } - buf[len] = 0; - let path_in_dir = ZStr::from_buf(buf.as_slice(), len); - Self::do_rename(cwd, src, target_fd, path_in_dir).map_err(|e| { + )), + }; + result.map_err(|e| { // Surface `target/basename(src)` as the failing path. let joined = resolve_path::join_z::(&[target, base]); e.with_path(joined.as_bytes()) diff --git a/src/runtime/shell/interpreter.rs b/src/runtime/shell/interpreter.rs index 3cdf1eba50c1..731b7259597e 100644 --- a/src/runtime/shell/interpreter.rs +++ b/src/runtime/shell/interpreter.rs @@ -2254,13 +2254,18 @@ pub(crate) fn shell_dup(fd: Fd) -> bun_sys::Result { /// Windows-only: rewrite shell paths so POSIX-absolute `/foo` resolves onto /// `dirfd`'s drive root, `/dev/null` maps to `NUL`, and relative paths are /// joined against `dirfd`'s real path. Returns a NUL-terminated slice that -/// either borrows `buf` or is `to` itself. +/// either borrows `buf` or is `to` itself. `to` is a command operand of any +/// length; one whose rewritten path does not fit `buf` fails with the +/// `ENAMETOOLONG` that `syscall` would report for it. #[cfg(windows)] fn shell_get_path<'a>( dirfd: Fd, to: &'a bun_core::ZStr, buf: &'a mut bun_paths::PathBuffer, + syscall: bun_sys::Tag, ) -> bun_sys::Result<&'a bun_core::ZStr> { + let name_too_long = + || bun_sys::Error::from_code(bun_sys::E::ENAMETOOLONG, syscall).with_path(to.as_bytes()); if to.as_bytes() == b"/dev/null" { return Ok(crate::shell::shell_body::WINDOWS_DEV_NULL); } @@ -2271,9 +2276,12 @@ fn shell_get_path<'a>( }; // `dirpath` already // occupies `buf[0..]` and the root is its prefix, so no copy is - // needed. Splice `to[1..]` after the root. + // needed. Splice `to[1..]` after the root, leaving room for the NUL. let to_tail = &to.as_bytes()[1..]; let end = source_root_len + to_tail.len(); + if end >= buf.len() { + return Err(name_too_long()); + } buf[source_root_len..end].copy_from_slice(to_tail); buf[end] = 0; return Ok(bun_core::ZStr::from_buf(buf.as_slice(), end)); @@ -2288,9 +2296,11 @@ fn shell_get_path<'a>( let dirpath = bun_sys::get_fd_path(dirfd, buf) .map_err(|e| e.with_fd(dirfd))? .to_vec(); - Ok(bun_paths::resolve_path::join_z_buf::< - bun_paths::platform::Auto, - >(&mut buf[..], &[&dirpath, to.as_bytes()])) + bun_paths::resolve_path::join_z_buf_checked::( + &mut buf[..], + &[&dirpath, to.as_bytes()], + ) + .ok_or_else(name_too_long) } /// Windows: rewrite the path via `shell_get_path` then `bun_sys::stat`, tagging @@ -2301,7 +2311,7 @@ pub(crate) fn shell_statat(dir: Fd, path_: &bun_core::ZStr) -> bun_sys::Result bun_sys::Result< #[cfg(windows)] { let mut buf = bun_paths::path_buffer_pool::get(); - let p = shell_get_path(dir, path_, &mut buf)?; + let p = shell_get_path(dir, path_, &mut buf, bun_sys::Tag::lstat)?; return bun_sys::lstat(p).map_err(|e| e.with_path(path_.as_bytes())); } #[cfg(not(windows))] @@ -2341,7 +2351,7 @@ pub(crate) fn shell_openat( if flags & bun_sys::O::DIRECTORY != 0 { if bun_paths::Platform::Posix.is_absolute(path.as_bytes()) { let mut buf = bun_paths::path_buffer_pool::get(); - let p = shell_get_path(dir, path, &mut buf)?; + let p = shell_get_path(dir, path, &mut buf, bun_sys::Tag::open)?; return bun_sys::open_dir_at_windows_a( dir, p.as_bytes(), @@ -2370,7 +2380,7 @@ pub(crate) fn shell_openat( .make_lib_uv_owned_for_syscall(bun_sys::Tag::open, bun_sys::ErrorCase::CloseOnFail); } let mut buf = bun_paths::path_buffer_pool::get(); - let p = shell_get_path(dir, path, &mut buf)?; + let p = shell_get_path(dir, path, &mut buf, bun_sys::Tag::open)?; // No `makeLibUVOwnedForSyscall` here: `bun_sys::open` on Windows // routes through `sys_uv` and already yields a uv-owned fd. return bun_sys::open(p, flags, perm); diff --git a/src/sys/lib.rs b/src/sys/lib.rs index 8f907ae08409..ae96aff13836 100644 --- a/src/sys/lib.rs +++ b/src/sys/lib.rs @@ -6408,47 +6408,36 @@ pub fn normalize_path_windows_opts<'a>( } if opts.add_nt_prefix { // Absolute → add `\??\` (idempotent if already present), normalize - // separators/`.`/`..` and NUL-terminate. - // `nt_prefix_headroom = 8`: reject when `path.len > - // buf.len - nt_prefix_headroom`. - // `normalizeStringGenericTZ` performs no bounds checking of its - // own, so reserve room for `\??\` + trailing-`\` growth + NUL - // before calling it. NOTE: `to_nt_path16` is NOT a substitute here - // — it only normalizes slashes and leaves `.`/`..` segments in - // place, which `NtCreateFile` rejects (e.g. `\??\C:\dir\.` → - // OBJECT_NAME_NOT_FOUND). - if path.len() > buf.len().saturating_sub(8) { - return Err(too_long()); - } - let norm = bun_paths::resolve_path::normalize_string_generic_tz::< + // separators/`.`/`..` and NUL-terminate. NOTE: `to_nt_path16` is + // NOT a substitute here — it only normalizes slashes and leaves + // `.`/`..` segments in place, which `NtCreateFile` rejects (e.g. + // `\??\C:\dir\.` → OBJECT_NAME_NOT_FOUND). + let len = bun_paths::resolve_path::normalize_string_generic_tz::< u16, /*ALLOW_ABOVE_ROOT*/ false, /*PRESERVE_TRAILING_SLASH*/ false, /*ZERO_TERMINATE*/ true, /*ADD_NT_PREFIX*/ true, - >(path, buf, b'\\' as u16, bun_paths::is_sep_any_t::); - let len = norm.len(); - // SAFETY: ZERO_TERMINATE wrote NUL at buf[len]. - return Ok(unsafe { WStr::from_raw(norm.as_ptr(), len) }); + >(path, buf, b'\\' as u16, bun_paths::is_sep_any_t::) + .ok_or_else(too_long)? + .len(); + // ZERO_TERMINATE wrote the NUL at buf[len]. + return Ok(WStr::from_buf(&buf[..], len)); } // `add_nt_prefix = false` — produce a Win32 path // (no `\??\` object prefix) for callers that feed kernel32 APIs - // (CreateDirectoryW / CopyFileW). With .add_nt_prefix = false the - // normalizer can still grow the input by one u16 (trailing `\` after a - // bare UNC volume name) plus the NUL terminator. - if path.len() > buf.len().saturating_sub(2) { - return Err(too_long()); - } - let norm = bun_paths::resolve_path::normalize_string_generic_tz::< + // (CreateDirectoryW / CopyFileW). + let len = bun_paths::resolve_path::normalize_string_generic_tz::< u16, /*ALLOW_ABOVE_ROOT*/ false, /*PRESERVE_TRAILING_SLASH*/ false, /*ZERO_TERMINATE*/ true, /*ADD_NT_PREFIX*/ false, - >(path, buf, b'\\' as u16, bun_paths::is_sep_any_t::); - let len = norm.len(); - // SAFETY: ZERO_TERMINATE wrote NUL at buf[len]. - return Ok(unsafe { WStr::from_raw(norm.as_ptr(), len) }); + >(path, buf, b'\\' as u16, bun_paths::is_sep_any_t::) + .ok_or_else(too_long)? + .len(); + // ZERO_TERMINATE wrote the NUL at buf[len]. + return Ok(WStr::from_buf(&buf[..], len)); } // Strip a leading drive letter (`C:`) on the relative part; the bypass @@ -6607,6 +6596,7 @@ pub fn normalize_path_windows_opts<'a>( b'\\' as u16, bun_paths::is_sep_any_t::, ) + .ok_or_else(too_long)? .len(); // A lone-separator suffix means `rel` collapsed to nothing: drop it for a // directory-path prefix, keep it for a bare device name where it selects diff --git a/test/js/bun/archive.test.ts b/test/js/bun/archive.test.ts index fcbdc1a2204d..a5faef4aefb9 100644 --- a/test/js/bun/archive.test.ts +++ b/test/js/bun/archive.test.ts @@ -796,6 +796,57 @@ describe("Bun.Archive", () => { expect(exitCode).toBe(0); }); + test("applies the path limit to the normalized path of an entry", async () => { + // The limit is on the path an entry is extracted to, not on the name as + // written in the archive: `d/../` repeated 10000 times collapses to + // `payload.txt`. The second name is the other way around. On Windows it + // is one code unit short of the path buffer and normalizes to one unit + // more (`C:\..\..\` keeps its `..` segments and its trailing separator), + // which used to fill the buffer exactly and put the NUL terminator past + // its end (`panic: index out of bounds: the len is 32767 but the index is + // 32767`); it has to be skipped like any other name that does not fit. + // Elsewhere it is a single 32766-byte component and is skipped as well. + // The extraction with a glob is a separate implementation (it also + // rejects the drive letter outright) and has to apply the same limit. + const collapsing = Buffer.alloc(50_000, "d/../").toString() + "payload.txt"; + const growing = "C:\\" + Buffer.alloc(32763, "..\\").toString(); + expect(growing).toHaveLength(32766); + + using dir = tempDir("archive-normalized-path-limit", { + "input.tar": buildPaxTarball([ + { name: collapsing, data: "collapsed" }, + { name: growing, data: "grown" }, + { name: "ok.txt", data: "ok" }, + ]), + "extract.ts": ` + import fs from "node:fs"; + const archive = new Bun.Archive(new Uint8Array(fs.readFileSync("input.tar"))); + const results = {}; + for (const [out, options] of [["out", undefined], ["out-glob", { glob: "**" }]]) { + fs.mkdirSync(out); + const count = await archive.extract(out, options); + results[out] = { count, files: fs.readdirSync(out).sort() }; + } + console.log(JSON.stringify(results)); + `, + }); + + // In a child process: the failure mode on Windows was a process abort. + await using proc = Bun.spawn({ + cmd: [bunExe(), "extract.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + const extracted = { count: 2, files: ["ok.txt", "payload.txt"] }; + expect(JSON.parse(stdout)).toEqual({ "out": extracted, "out-glob": extracted }); + expect(exitCode).toBe(0); + }); + test("normalizes paths with redundant separators", async () => { const archive = new Bun.Archive({ "dir//subdir///file.txt": "content", diff --git a/test/js/bun/resolve/resolve.test.ts b/test/js/bun/resolve/resolve.test.ts index 554b7d8e1046..61188ce4de81 100644 --- a/test/js/bun/resolve/resolve.test.ts +++ b/test/js/bun/resolve/resolve.test.ts @@ -573,6 +573,62 @@ describe.concurrent("browser map with a key longer than 1024 bytes", () => { }); }); +// Every "sideEffects" entry is joined onto the package directory when its +// package.json is parsed. The join wrote into a fixed 4 KB threadlocal buffer +// without a bounds check, so an entry longer than that aborted the process +// (`panic: range end index 100072 out of range for slice of length 4095`) as +// soon as the package was resolved. Longer than a path buffer on every +// platform, including Windows (32767 UTF-16 code units). +describe.concurrent("sideEffects entries longer than the path buffers", () => { + const longName = Buffer.alloc(100_000, "e").toString(); + const sideEffects = [`./${longName}.js`, `./${longName}/*.js`, "./effect.js"]; + + it("does not crash resolving the package at runtime", async () => { + using dir = tempDir("resolver-side-effects-long-entry-runtime", { + "node_modules/dep/package.json": JSON.stringify({ name: "dep", main: "index.js", sideEffects }), + "node_modules/dep/index.js": "module.exports = 1;", + "index.js": `console.log(require("dep"));`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "index.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + expect(stdout).toBe("1\n"); + expect(exitCode).toBe(0); + }); + + // On Windows no sideEffects entry matches anything yet (#30320). + it.skipIf(isWindows)("still applies the other entries when bundling", async () => { + using dir = tempDir("resolver-side-effects-long-entry-build", { + "node_modules/dep/package.json": JSON.stringify({ name: "dep", sideEffects }), + "node_modules/dep/effect.js": `console.log("effect kept");`, + "node_modules/dep/pure.js": `console.log("pure dropped");`, + "entry.js": `import "dep/effect.js"; import "dep/pure.js";`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "build", "entry.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + expect(stdout).toContain("effect kept"); + expect(stdout).not.toContain("pure dropped"); + expect(exitCode).toBe(0); + }); +}); + // ESModule.Package.parse scanned the entire specifier for an `@` to split off a // version. For wildcard `exports` maps the matched substring can contain `@` // (e.g. `ember-source/@ember/renderer/...`, `pkg/@scope/sub`) — those `@`s diff --git a/test/js/bun/shell/bunshell.test.ts b/test/js/bun/shell/bunshell.test.ts index 2d57ca0587c9..e732cb29a0c9 100644 --- a/test/js/bun/shell/bunshell.test.ts +++ b/test/js/bun/shell/bunshell.test.ts @@ -3286,3 +3286,59 @@ test.skipIf(isWindows)("external command resolution uses the PATH from the shell expect(exitCode).toBe(0); } }); + +// On Windows the shell rewrites every file operand against the cwd it tracks +// before opening or stat-ing it (a relative name is joined onto the cwd, a +// `/name` is put on the cwd's drive). The rewrite goes through one path +// buffer, and an operand that did not fit it took the process down +// (`panic: range end index 100064 out of range for slice of length 98301`). +// POSIX hands operands to the *at() syscalls as-is, so there is nothing to +// rewrite there. Runs in a child process so that a regression fails the test +// instead of the test runner. +test.if(isWindows)("operands longer than the path buffer fail with ENAMETOOLONG", async () => { + using dir = tempDir("shell-long-operand", { + "in.txt": "content\n", + "fixture.ts": ` + import { $ } from "bun"; + $.nothrow().cwd(process.cwd()); + const long = Buffer.alloc(100_000, "a").toString(); + // Longer than the buffer as written, but normalizes to a name inside the cwd. + const collapsing = Buffer.alloc(100_000, "a/../").toString() + "in.txt"; + const run = async (promise) => { + const { exitCode, stdout, stderr } = await promise.quiet(); + const show = (buf) => buf.toString().replaceAll(long, ""); + return { exitCode, stdout: show(stdout), stderr: show(stderr) }; + }; + console.log( + JSON.stringify({ + relative: await run($\`cat \${long}\`), + rooted: await run($\`cat \${"/" + long}\`), + stat: await run($\`[[ -f \${long} ]]\`), + lstat: await run($\`ls \${long}\`), + redirect: await run($\`echo hi > \${long}\`), + collapsing: await run($\`cat \${collapsing}\`), + }), + ); + `, + }); + + await using proc = Bun.spawn({ + cmd: [BUN, "fixture.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + relative: { exitCode: 1, stdout: "", stderr: "cat: : File name too long\n" }, + rooted: { exitCode: 1, stdout: "", stderr: "cat: /: File name too long\n" }, + stat: { exitCode: 1, stdout: "", stderr: "" }, + lstat: { exitCode: 1, stdout: "", stderr: "ls: : File name too long\n" }, + redirect: { exitCode: 1, stdout: "", stderr: "bun: File name too long: " }, + collapsing: { exitCode: 0, stdout: "content\n", stderr: "" }, + }); + expect(exitCode).toBe(0); +}); diff --git a/test/js/bun/shell/commands/cp.test.ts b/test/js/bun/shell/commands/cp.test.ts index 80a28d2c7e2c..7edaf73f95db 100644 --- a/test/js/bun/shell/commands/cp.test.ts +++ b/test/js/bun/shell/commands/cp.test.ts @@ -1,7 +1,7 @@ import { $ } from "bun"; import { shellInternals } from "bun:internal-for-testing"; -import { describe, expect } from "bun:test"; -import { tempDirWithFiles } from "harness"; +import { describe, expect, test } from "bun:test"; +import { bunEnv, tempDir, tempDirWithFiles } from "harness"; import { bunExe, createTestBuilder } from "../test_builder"; import { sortedShellOutput } from "../util"; const { builtinDisabled } = shellInternals; @@ -173,6 +173,56 @@ describe.if(!builtinDisabled("cp"))("bunshell cp", async () => { }); }); +// cp resolves its operands against the cwd into path buffers. An operand that +// did not fit took the process down (`panic: range end index 5012 out of range +// for slice of length 4094`), so the fixture runs in a child. The flag turns the +// builtin on where it is off by default (POSIX). +test("operands longer than the path buffer fail with ENAMETOOLONG", async () => { + using dir = tempDir("cp-long-operand", { + "in.txt": "content\n", + "fixture.ts": ` + import { $ } from "bun"; + $.nothrow(); + const long = Buffer.alloc(100_000, "a").toString(); + const run = async (promise) => { + const { exitCode, stdout, stderr } = await promise.quiet(); + return { exitCode, stdout: stdout.toString(), stderr: stderr.toString().replaceAll(long, "") }; + }; + console.log( + JSON.stringify({ + target: await run($\`cp in.txt \${long}\`), + source: await run($\`cp \${long} out.txt\`), + }), + ); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "fixture.ts"], + env: { ...bunEnv, BUN_ENABLE_EXPERIMENTAL_SHELL_BUILTINS: "1" }, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + target: { + exitCode: 1, + stdout: "", + stderr: expect.stringMatching(/^cp: File name too long: .+[\\/]\n$/), + }, + // The source goes to the OS first; Windows reports every failed stat as a missing file. + source: { + exitCode: 1, + stdout: "", + stderr: expect.stringMatching(/^cp: (File name too long|No such file or directory): .+[\\/]\n$/), + }, + }); + expect(exitCode).toBe(0); +}); + function expectSortedOutput(expected: string) { return (stdout: string, tempdir: string) => expect(sortedShellOutput(stdout).join("\n")).toEqual( diff --git a/test/js/bun/shell/commands/mv.test.ts b/test/js/bun/shell/commands/mv.test.ts index 4389044ae861..e2273c282812 100644 --- a/test/js/bun/shell/commands/mv.test.ts +++ b/test/js/bun/shell/commands/mv.test.ts @@ -1,6 +1,6 @@ import { $ } from "bun"; import { describe, expect, test } from "bun:test"; -import { isPosix } from "harness"; +import { bunEnv, bunExe, isFreeBSD, isMacOS, isPosix, tempDir } from "harness"; import { accessSync, chmodSync, @@ -234,4 +234,36 @@ describe("mv", async () => { } }); }); + + // `mv src dir` builds `basename(src)` in a path buffer before the rename. A + // name longer than the buffer took the process down (`panic: range end index + // 5000 out of range for slice of length 4096`), so the fixture runs in a child. + test("source name longer than the path buffer fails with ENAMETOOLONG", async () => { + using dir = tempDir("mv-long-name", { + "d": {}, + "fixture.ts": ` + import { $ } from "bun"; + const long = Buffer.alloc(100_000, "a").toString(); + const { exitCode, stdout, stderr } = await $\`mv \${long} d\`.nothrow().quiet(); + console.log(JSON.stringify({ exitCode, stdout: stdout.toString(), stderr: stderr.toString().replaceAll(long, "") })); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "fixture.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + exitCode: isMacOS || isFreeBSD ? 63 : 36, // the rename's errno, ENAMETOOLONG + stdout: "", + stderr: `mv: ${join("d", "")}: File name too long\n`, + }); + expect(exitCode).toBe(0); + }); });