diff --git a/.agents/docs/performance-strategy.md b/.agents/docs/performance-strategy.md index 3a09f99..3d66564 100644 --- a/.agents/docs/performance-strategy.md +++ b/.agents/docs/performance-strategy.md @@ -194,8 +194,9 @@ This checkpoint is historical and predates the breaking design: the final API de Clean-path fast cases (0-alloc normalize, absolute absolutize, borrowable descendant `relative`, clean owned normalize/slash, owned-cwd `absolutize_with` reuse) are done and gated. **Slow path means the full general algorithm, not a rare edge:** dirty normalize, non-borrowable `relative` (upward, unequal parents, complex roots), dirty join pipelines, and invalid-encoding rebuilds. Those are the default path for many inputs and remain the **in-crate optimization target**. -- **Recorded unequal leading-parent relative improvement:** pure relative pairs that miss the equal-parent lexical hit now resolve both sides against one ambient cwd as normal-component stacks and allocate only the result buffer (allocation hard gate: 5 alloc / multi-realloc → 1 / 0 for shallow and p99 unequal rows). Absolute/absolute and equal-parent hits still return earlier and are untaxed. Same-machine Criterion on Apple ARM64 with `cached_current_dir` saw about 50% wall-time reduction on the shallow unequal workload and about 35% on the p99 unequal workload (directional local evidence; continuous CodSpeed remains Linux corroboration). -- Still open on the general path: dirty join→PathBuf/String intermediate costs (2–3 alloc), invalid-encoding absolute relative rebuilds, and any further cheapening of dirty `normalize_inner` without regressing clean 0-alloc gates. +- **Recorded unequal leading-parent relative improvement (#44):** pure relative pairs that miss the equal-parent lexical hit resolve both sides against one ambient cwd as normal-component stacks and allocate only the result buffer (allocation hard gate: 5 alloc / multi-realloc → 1 / 0 for shallow and p99 unequal rows). Absolute/absolute and equal-parent hits still return earlier and are untaxed. Same-machine Criterion on Apple ARM64 with `cached_current_dir` saw about 50% wall-time reduction on the shallow unequal workload and about 35% on the p99 unequal workload (directional local evidence; continuous CodSpeed remains Linux corroboration). +- **Recorded owned dirty normalize buffer reuse:** dirty `into_normalized` inputs up to 512 encoded bytes and a maximum live depth of 24 normal components stage their components in a stack arena, then rewrite the original `PathBuf` storage without a second heap allocation when its capacity fits. Windows prefixes write directly into that arena rather than using a spillable temporary. On non-Unix targets, allocation-free component replay is capped at 64 total components and 512 encoded bytes; larger inputs reserve one auxiliary component buffer once and remain linear. Hard-gate moves include owned dirty/collapsing normalize 1→0, dirty join owned PathBuf/String 2→1, dirty `absolutize_with` borrowed cwd 2→1 and owned cwd 1→0; exact-capacity owned cwd controls ensure the optimization does not add growth when `PathBuf::push` already fits. +- Still open: dirty join **borrowed** `normalize` (2 alloc; cannot reuse without changing the measured API), borrowed collapsing `normalize().into_owned()` (2), residual reallocs on join/absolutize growth, and owned normalize fallback above 512 encoded bytes or a maximum live depth of 24 normal components (still uses `normalize_inner`). - Keep the Windows native-separator-aware scan under native CI allocation and correctness gates (drive, UNC, verbatim, device, mixed-separator, invalid-wide, exact spelling). - De-monomorphize `relative` only with binary-size / `llvm-lines` evidence. More SIMD only with profile proof. No public fused relative-to-string API and no ArcStr dependency in this crate by default. @@ -211,7 +212,7 @@ The pinned [`rust-performance-improvement-plan` at `fe59e3d`](https://github.com | Reduce filesystem calls | Adopted where input semantics make the call unnecessary. Commit `1590fd1` removes cwd access for absolute `absolutize`; relative inputs still need a base unless a separately proven lexical fast path applies. | | Use byte scans and `memchr` when Unicode code points are irrelevant | Adopted for ASCII separators, roots, and lexical markers. Exact invalid-encoding tests prevent a UTF-8 fast path from silently becoming the only semantic path. `0da5a61` adds explicit ARM64 NEON only for the remaining scalar common-prefix loop; existing `memchr` operations are not replaced. | | Cut work with early returns, lazy allocation, borrowing, and small-case specialization | Adopted. Clean normalization borrows, the absolute `absolutize` path bypasses cwd, and `22af24b` proves the leading-parent pre-scan against both hit and double-scan miss costs. | -| Reserve capacity, reuse collections, and avoid `clone`/`to_owned` | Adopted where ownership proves reuse. The final `into_normalized` and strict `into_slash` operations reuse clean owned buffers, and an owned cwd moves into `absolutize_with`; dirty normalization still builds the exact transformed representation rather than forcing mutation into the old buffer. | +| Reserve capacity, reuse collections, and avoid `clone`/`to_owned` | Adopted where ownership proves reuse. The final `into_normalized` and strict `into_slash` operations reuse clean owned buffers, an owned cwd moves into `absolutize_with`, and bounded dirty normalization stages exact component bytes before rewriting the old buffer. `absolutize_with` leaves growth to `PathBuf::push`: an unconditional append-sized reserve regresses already-sufficient trailing-separator and Windows replacement buffers, while narrower hints need workload evidence before adding classification work. | | Use `Cow`, `SmallVec`, and compact storage for short values | Retained where justified. `Cow` exposes allocation-free clean paths; normalization uses eight inline references, while lexical relative calculation uses 16 because the pinned Rolldown distribution is 10 components at p99 and 13 at maximum. Dedicated dirty and 24-level tests execute both spill paths. | | Avoid repeated `Path::to_string_lossy` | Adopted for valid-Unicode owned input through strict `into_slash` and for the ordinary relative-to-final-`String` composition. The earlier public fused method was removed; invalid native encoding still requires explicit lossy re-encoding, with byte- and wide-unit tests proving the fallback semantics. | | Add `#[inline]`, `#[cold]`, unchecked indexing, or compiler assertions | Use only with generated-code or profile evidence. The normalization classifier is outlined because generated code and CodSpeed favor that separation, while the rebuilding core and leading-parent helper remain eligible for inlining. No current profile justifies unsafe indexing or marking the general rebuilding path cold. | diff --git a/benchmarks/allocations/x86_64-pc-windows-msvc.snap b/benchmarks/allocations/x86_64-pc-windows-msvc.snap index dd8cd79..0418850 100644 --- a/benchmarks/allocations/x86_64-pc-windows-msvc.snap +++ b/benchmarks/allocations/x86_64-pc-windows-msvc.snap @@ -20,17 +20,23 @@ Each row measures one operation after allocation-capable setup and an untracked | normalize / owned clean input via borrowed receiver -> PathBuf | 1 | 0 | 0 | | normalize / owned clean input via owned receiver -> PathBuf | 0 | 0 | 0 | | normalize / owned dirty input via borrowed receiver -> PathBuf | 1 | 0 | 0 | -| normalize / owned dirty input via owned receiver -> PathBuf | 1 | 0 | 0 | +| normalize / owned dirty input via owned receiver -> PathBuf | 0 | 0 | 0 | | normalize / owned dot input via borrowed receiver -> PathBuf | 1 | 0 | 0 | | normalize / owned dot input via owned receiver -> PathBuf | 0 | 0 | 0 | | normalize / owned collapsing input via borrowed receiver -> PathBuf | 2 | 0 | 0 | -| normalize / owned collapsing input via owned receiver -> PathBuf | 1 | 0 | 0 | +| normalize / owned collapsing input via owned receiver -> PathBuf | 0 | 0 | 0 | | normalize / owned invalid input via borrowed receiver -> PathBuf | 1 | 0 | 0 | | normalize / owned invalid input via owned receiver -> PathBuf | 0 | 0 | 0 | +| normalize / owned dirty 24-component boundary via owned receiver -> PathBuf | 0 | 0 | 0 | +| normalize / owned dirty 25-component fallback via owned receiver -> PathBuf | 1 | 0 | 0 | +| normalize / owned dirty 33-component deep fallback via owned receiver -> PathBuf | 1 | 0 | 0 | +| normalize / non-Unix dirty 64-component replay boundary via owned receiver -> PathBuf | 1 | 0 | 0 | +| normalize / non-Unix dirty 65-component linear spill via owned receiver -> PathBuf | 2 | 0 | 0 | +| normalize / non-Unix dirty long 33-component linear spill via owned receiver -> PathBuf | 2 | 0 | 0 | | pipeline / dirty join via borrowed receiver -> PathBuf | 2 | 0 | 2 | -| pipeline / dirty join via owned receiver -> PathBuf | 2 | 0 | 2 | +| pipeline / dirty join via owned receiver -> PathBuf | 1 | 0 | 2 | | pipeline / dirty join via borrowed receiver -> String | 3 | 0 | 2 | -| pipeline / dirty join via owned receiver -> String | 2 | 0 | 2 | +| pipeline / dirty join via owned receiver -> String | 1 | 0 | 2 | | pipeline / clean join via borrowed receiver -> PathBuf | 2 | 0 | 2 | | pipeline / clean join via owned receiver -> PathBuf | 1 | 0 | 2 | | pipeline / clean join via borrowed receiver -> String | 2 | 0 | 2 | @@ -38,11 +44,15 @@ Each row measures one operation after allocation-capable setup and an untracked | Windows / absolute mixed separators -> normalized path | 1 | 0 | 0 | | Windows / canonical ordinary UNC -> normalized path | 1 | 0 | 0 | | Windows / canonical verbatim UNC -> normalized path | 1 | 0 | 0 | +| Windows / long-prefix 24-component UNC boundary via owned receiver -> PathBuf | 0 | 0 | 0 | +| Windows / long-prefix 25-component UNC fallback via owned receiver -> PathBuf | 1 | 0 | 0 | | absolutize / clean absolute input -> absolute path | 0 | 0 | 0 | | absolutize_with / absolute input + borrowed cwd -> absolute path | 0 | 0 | 0 | -| absolutize_with / relative input + borrowed cwd -> absolute path | 2 | 0 | 1 | -| absolutize_with / relative input + owned cwd -> absolute path (setup excluded) | 1 | 0 | 1 | +| absolutize_with / relative input + borrowed cwd -> absolute path | 1 | 0 | 1 | +| absolutize_with / relative input + owned cwd -> absolute path (setup excluded) | 0 | 0 | 1 | | absolutize_with / clean relative input + owned cwd -> absolute path (setup excluded) | 0 | 0 | 1 | +| absolutize_with / empty input + exact owned root cwd -> absolute path (setup excluded) | 0 | 0 | 0 | +| absolutize_with / relative input + exact trailing-separator owned cwd -> absolute path (setup excluded) | 0 | 0 | 0 | | relative / canonical native descendant -> natural result | 0 | 0 | 0 | | relative / canonical native descendant -> PathBuf | 1 | 0 | 0 | | relative / relative inputs -> relative path | 1 | 0 | 0 | @@ -105,17 +115,23 @@ Each row measures one operation after allocation-capable setup and an untracked | normalize / owned clean input via borrowed receiver -> PathBuf | 58 | 0 | | normalize / owned clean input via owned receiver -> PathBuf | 0 | 0 | | normalize / owned dirty input via borrowed receiver -> PathBuf | 58 | 0 | -| normalize / owned dirty input via owned receiver -> PathBuf | 58 | 0 | +| normalize / owned dirty input via owned receiver -> PathBuf | 0 | 0 | | normalize / owned dot input via borrowed receiver -> PathBuf | 1 | 0 | | normalize / owned dot input via owned receiver -> PathBuf | 0 | 0 | | normalize / owned collapsing input via borrowed receiver -> PathBuf | 7 | 0 | -| normalize / owned collapsing input via owned receiver -> PathBuf | 6 | 0 | +| normalize / owned collapsing input via owned receiver -> PathBuf | 0 | 0 | | normalize / owned invalid input via borrowed receiver -> PathBuf | 70 | 0 | | normalize / owned invalid input via owned receiver -> PathBuf | 0 | 0 | +| normalize / owned dirty 24-component boundary via owned receiver -> PathBuf | 0 | 0 | +| normalize / owned dirty 25-component fallback via owned receiver -> PathBuf | 51 | 0 | +| normalize / owned dirty 33-component deep fallback via owned receiver -> PathBuf | 67 | 0 | +| normalize / non-Unix dirty 64-component replay boundary via owned receiver -> PathBuf | 129 | 0 | +| normalize / non-Unix dirty 65-component linear spill via owned receiver -> PathBuf | 1171 | 0 | +| normalize / non-Unix dirty long 33-component linear spill via owned receiver -> PathBuf | 1090 | 0 | | pipeline / dirty join via borrowed receiver -> PathBuf | 100 | 126 | -| pipeline / dirty join via owned receiver -> PathBuf | 100 | 126 | +| pipeline / dirty join via owned receiver -> PathBuf | 21 | 126 | | pipeline / dirty join via borrowed receiver -> String | 170 | 126 | -| pipeline / dirty join via owned receiver -> String | 100 | 126 | +| pipeline / dirty join via owned receiver -> String | 21 | 126 | | pipeline / clean join via borrowed receiver -> PathBuf | 91 | 126 | | pipeline / clean join via owned receiver -> PathBuf | 21 | 126 | | pipeline / clean join via borrowed receiver -> String | 91 | 126 | @@ -123,11 +139,15 @@ Each row measures one operation after allocation-capable setup and an untracked | Windows / absolute mixed separators -> normalized path | 60 | 0 | | Windows / canonical ordinary UNC -> normalized path | 36 | 0 | | Windows / canonical verbatim UNC -> normalized path | 42 | 0 | +| Windows / long-prefix 24-component UNC boundary via owned receiver -> PathBuf | 0 | 0 | +| Windows / long-prefix 25-component UNC fallback via owned receiver -> PathBuf | 117 | 0 | | absolutize / clean absolute input -> absolute path | 0 | 0 | | absolutize_with / absolute input + borrowed cwd -> absolute path | 0 | 0 | -| absolutize_with / relative input + borrowed cwd -> absolute path | 107 | 74 | -| absolutize_with / relative input + owned cwd -> absolute path (setup excluded) | 70 | 74 | +| absolutize_with / relative input + borrowed cwd -> absolute path | 37 | 74 | +| absolutize_with / relative input + owned cwd -> absolute path (setup excluded) | 0 | 74 | | absolutize_with / clean relative input + owned cwd -> absolute path (setup excluded) | 0 | 74 | +| absolutize_with / empty input + exact owned root cwd -> absolute path (setup excluded) | 0 | 0 | +| absolutize_with / relative input + exact trailing-separator owned cwd -> absolute path (setup excluded) | 0 | 0 | | relative / canonical native descendant -> natural result | 0 | 0 | | relative / canonical native descendant -> PathBuf | 32 | 0 | | relative / relative inputs -> relative path | 31 | 0 | diff --git a/benchmarks/allocations/x86_64-unknown-linux-gnu.snap b/benchmarks/allocations/x86_64-unknown-linux-gnu.snap index 20202f4..7b007a3 100644 --- a/benchmarks/allocations/x86_64-unknown-linux-gnu.snap +++ b/benchmarks/allocations/x86_64-unknown-linux-gnu.snap @@ -20,26 +20,31 @@ Each row measures one operation after allocation-capable setup and an untracked | normalize / owned clean input via borrowed receiver -> PathBuf | 1 | 0 | 0 | | normalize / owned clean input via owned receiver -> PathBuf | 0 | 0 | 0 | | normalize / owned dirty input via borrowed receiver -> PathBuf | 1 | 0 | 0 | -| normalize / owned dirty input via owned receiver -> PathBuf | 1 | 0 | 0 | +| normalize / owned dirty input via owned receiver -> PathBuf | 0 | 0 | 0 | | normalize / owned dot input via borrowed receiver -> PathBuf | 1 | 0 | 0 | | normalize / owned dot input via owned receiver -> PathBuf | 0 | 0 | 0 | | normalize / owned collapsing input via borrowed receiver -> PathBuf | 2 | 0 | 0 | -| normalize / owned collapsing input via owned receiver -> PathBuf | 1 | 0 | 0 | +| normalize / owned collapsing input via owned receiver -> PathBuf | 0 | 0 | 0 | | normalize / owned invalid input via borrowed receiver -> PathBuf | 1 | 0 | 0 | | normalize / owned invalid input via owned receiver -> PathBuf | 0 | 0 | 0 | +| normalize / owned dirty 24-component boundary via owned receiver -> PathBuf | 0 | 0 | 0 | +| normalize / owned dirty 25-component fallback via owned receiver -> PathBuf | 1 | 0 | 0 | +| normalize / owned dirty 33-component deep fallback via owned receiver -> PathBuf | 1 | 0 | 0 | | pipeline / dirty join via borrowed receiver -> PathBuf | 2 | 0 | 2 | -| pipeline / dirty join via owned receiver -> PathBuf | 2 | 0 | 2 | +| pipeline / dirty join via owned receiver -> PathBuf | 1 | 0 | 2 | | pipeline / dirty join via borrowed receiver -> String | 3 | 0 | 2 | -| pipeline / dirty join via owned receiver -> String | 2 | 0 | 2 | +| pipeline / dirty join via owned receiver -> String | 1 | 0 | 2 | | pipeline / clean join via borrowed receiver -> PathBuf | 2 | 0 | 2 | | pipeline / clean join via owned receiver -> PathBuf | 1 | 0 | 2 | | pipeline / clean join via borrowed receiver -> String | 2 | 0 | 2 | | pipeline / clean join via owned receiver -> String | 1 | 0 | 2 | | absolutize / clean absolute input -> absolute path | 0 | 0 | 0 | | absolutize_with / absolute input + borrowed cwd -> absolute path | 0 | 0 | 0 | -| absolutize_with / relative input + borrowed cwd -> absolute path | 2 | 0 | 1 | -| absolutize_with / relative input + owned cwd -> absolute path (setup excluded) | 1 | 0 | 1 | +| absolutize_with / relative input + borrowed cwd -> absolute path | 1 | 0 | 1 | +| absolutize_with / relative input + owned cwd -> absolute path (setup excluded) | 0 | 0 | 1 | | absolutize_with / clean relative input + owned cwd -> absolute path (setup excluded) | 0 | 0 | 1 | +| absolutize_with / empty input + exact owned root cwd -> absolute path (setup excluded) | 0 | 0 | 0 | +| absolutize_with / relative input + exact trailing-separator owned cwd -> absolute path (setup excluded) | 0 | 0 | 0 | | relative / canonical native descendant -> natural result | 0 | 0 | 0 | | relative / canonical native descendant -> PathBuf | 1 | 0 | 0 | | relative / relative inputs -> relative path | 1 | 0 | 0 | @@ -92,26 +97,31 @@ Each row measures one operation after allocation-capable setup and an untracked | normalize / owned clean input via borrowed receiver -> PathBuf | 56 | 0 | | normalize / owned clean input via owned receiver -> PathBuf | 0 | 0 | | normalize / owned dirty input via borrowed receiver -> PathBuf | 58 | 0 | -| normalize / owned dirty input via owned receiver -> PathBuf | 58 | 0 | +| normalize / owned dirty input via owned receiver -> PathBuf | 0 | 0 | | normalize / owned dot input via borrowed receiver -> PathBuf | 1 | 0 | | normalize / owned dot input via owned receiver -> PathBuf | 0 | 0 | | normalize / owned collapsing input via borrowed receiver -> PathBuf | 7 | 0 | -| normalize / owned collapsing input via owned receiver -> PathBuf | 6 | 0 | +| normalize / owned collapsing input via owned receiver -> PathBuf | 0 | 0 | | normalize / owned invalid input via borrowed receiver -> PathBuf | 66 | 0 | | normalize / owned invalid input via owned receiver -> PathBuf | 0 | 0 | +| normalize / owned dirty 24-component boundary via owned receiver -> PathBuf | 0 | 0 | +| normalize / owned dirty 25-component fallback via owned receiver -> PathBuf | 51 | 0 | +| normalize / owned dirty 33-component deep fallback via owned receiver -> PathBuf | 67 | 0 | | pipeline / dirty join via borrowed receiver -> PathBuf | 96 | 115 | -| pipeline / dirty join via owned receiver -> PathBuf | 96 | 115 | +| pipeline / dirty join via owned receiver -> PathBuf | 19 | 115 | | pipeline / dirty join via borrowed receiver -> String | 164 | 115 | -| pipeline / dirty join via owned receiver -> String | 96 | 115 | +| pipeline / dirty join via owned receiver -> String | 19 | 115 | | pipeline / clean join via borrowed receiver -> PathBuf | 87 | 114 | | pipeline / clean join via owned receiver -> PathBuf | 19 | 114 | | pipeline / clean join via borrowed receiver -> String | 87 | 114 | | pipeline / clean join via owned receiver -> String | 19 | 114 | | absolutize / clean absolute input -> absolute path | 0 | 0 | | absolutize_with / absolute input + borrowed cwd -> absolute path | 0 | 0 | -| absolutize_with / relative input + borrowed cwd -> absolute path | 103 | 70 | -| absolutize_with / relative input + owned cwd -> absolute path (setup excluded) | 68 | 70 | +| absolutize_with / relative input + borrowed cwd -> absolute path | 35 | 70 | +| absolutize_with / relative input + owned cwd -> absolute path (setup excluded) | 0 | 70 | | absolutize_with / clean relative input + owned cwd -> absolute path (setup excluded) | 0 | 70 | +| absolutize_with / empty input + exact owned root cwd -> absolute path (setup excluded) | 0 | 0 | +| absolutize_with / relative input + exact trailing-separator owned cwd -> absolute path (setup excluded) | 0 | 0 | | relative / canonical native descendant -> natural result | 0 | 0 | | relative / canonical native descendant -> PathBuf | 32 | 0 | | relative / relative inputs -> relative path | 31 | 0 | diff --git a/src/encoded_arena.rs b/src/encoded_arena.rs new file mode 100644 index 0000000..3479ab8 --- /dev/null +++ b/src/encoded_arena.rs @@ -0,0 +1,59 @@ +use std::{ + ffi::{OsStr, OsString}, + marker::PhantomData, +}; + +// Invariance prevents a fragment created by one arena session from being used +// with another arena, even when both sessions are nested in the same function. +type Brand<'id> = PhantomData &'id ()>; + +#[derive(Clone, Copy)] +pub(crate) struct EncodedFragment<'id> { + start: u16, + end: u16, + brand: Brand<'id>, +} + +pub(crate) struct EncodedArena<'id, const N: usize> { + bytes: [u8; N], + len: usize, + brand: Brand<'id>, +} + +pub(crate) fn with_encoded_arena( + f: impl for<'id> FnOnce(EncodedArena<'id, N>) -> R, +) -> R { + f(EncodedArena { bytes: [0; N], len: 0, brand: PhantomData }) +} + +impl<'id, const N: usize> EncodedArena<'id, N> { + pub(crate) fn store_os_str(&mut self, value: &OsStr) -> Option> { + let source = value.as_encoded_bytes(); + let start = self.len; + let end = start.checked_add(source.len())?; + if end > N || end > usize::from(u16::MAX) { + return None; + } + + self.bytes[start..end].copy_from_slice(source); + self.len = end; + Some(EncodedFragment { start: start as u16, end: end as u16, brand: PhantomData }) + } + + pub(crate) fn push_to(&self, output: &mut OsString, fragment: EncodedFragment<'id>) { + let bytes = &self.bytes[usize::from(fragment.start)..usize::from(fragment.end)]; + // SAFETY: `EncodedFragment` can only be created by `store_os_str` in this + // branded, append-only arena. Its range therefore contains one complete + // `OsStr::as_encoded_bytes()` result from this Rust version and target. + #[expect(unsafe_code, reason = "single audited native-encoding reconstruction")] + let value = unsafe { OsStr::from_encoded_bytes_unchecked(bytes) }; + output.push(value); + } + + #[cfg(target_family = "windows")] + pub(crate) fn is_standalone_windows_relative(&self, fragment: EncodedFragment<'id>) -> bool { + let bytes = &self.bytes[usize::from(fragment.start)..usize::from(fragment.end)]; + !matches!(bytes.first(), Some(b'/' | b'\\')) + && !(bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':') + } +} diff --git a/src/impl_sugar_path.rs b/src/impl_sugar_path.rs index a500a95..16e042b 100644 --- a/src/impl_sugar_path.rs +++ b/src/impl_sugar_path.rs @@ -9,23 +9,205 @@ use std::{ use memchr::{memchr, memrchr}; use smallvec::SmallVec; +#[cfg(not(unix))] +use crate::encoded_arena::{EncodedArena, EncodedFragment, with_encoded_arena}; use crate::{SugarPath, utils::try_get_current_dir}; type StrVec<'a> = SmallVec<[&'a str; 8]>; type OsStrVec<'a> = SmallVec<[&'a OsStr; 16]>; -enum OwnedNormalizeOutcome { - Unchanged, - CurrentDirectory, - Owned(PathBuf), -} - #[derive(Clone, Copy, PartialEq, Eq)] enum TrailingSeparator { Preserve, Strip, } +/// Stack arena for owned normalize component bytes. Paths longer than this or +/// deeper than the component stack fall back to `normalize_inner`. +const OWNED_NORMALIZE_STACK_ARENA: usize = 512; +const OWNED_NORMALIZE_COMPONENT_STACK: usize = 24; +#[cfg(not(unix))] +const NATIVE_NORMALIZE_COMPONENT_STACK: usize = 32; +// Bound the quadratic, allocation-free replay used just past the inline stack. +// Larger inputs reserve one component spill up front and stay linear. +#[cfg(not(unix))] +const NATIVE_NORMALIZE_REPLAY_TOTAL_COMPONENT_LIMIT: usize = 64; +#[cfg(not(unix))] +const NATIVE_NORMALIZE_REPLAY_ENCODED_BYTE_LIMIT: usize = 512; + +#[cfg(all(test, not(unix)))] +std::thread_local! { + static NATIVE_NORMALIZATION_PROFILE_CALLS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +#[cfg(unix)] +#[inline] +fn push_owned_normalize_arena( + arena: &mut [u8; OWNED_NORMALIZE_STACK_ARENA], + arena_len: &mut usize, + bytes: &[u8], +) -> Option<(u16, u16)> { + if *arena_len + bytes.len() > OWNED_NORMALIZE_STACK_ARENA { + return None; + } + let start = *arena_len; + arena[start..start + bytes.len()].copy_from_slice(bytes); + *arena_len += bytes.len(); + Some((start as u16, *arena_len as u16)) +} + +#[cfg(target_family = "windows")] +#[derive(Clone, Copy)] +enum WindowsPrefixPlan<'id> { + VerbatimDisk { drive: u8 }, + DeviceNs { device: EncodedFragment<'id> }, + Unc { server: EncodedFragment<'id>, share: EncodedFragment<'id> }, + Disk { drive: u8 }, + RawVerbatim { raw: EncodedFragment<'id> }, +} + +#[cfg(target_family = "windows")] +#[derive(Clone, Copy)] +enum BorrowedWindowsPrefixPlan<'a> { + VerbatimDisk { drive: u8 }, + DeviceNs { device: &'a OsStr }, + Unc { server: &'a OsStr, share: &'a OsStr }, + Disk { drive: u8 }, + RawVerbatim { raw: &'a OsStr }, +} + +#[cfg(target_family = "windows")] +impl<'id> WindowsPrefixPlan<'id> { + fn write_to(self, arena: &EncodedArena<'id, N>, output: &mut OsString) { + match self { + Self::VerbatimDisk { drive } => { + output.push(r"\\?\"); + push_ascii_byte(output, drive); + output.push(":"); + } + Self::DeviceNs { device } => { + output.push(r"\\.\"); + arena.push_to(output, device); + } + Self::Unc { server, share } => { + output.push(r"\\"); + arena.push_to(output, server); + output.push(r"\"); + arena.push_to(output, share); + } + Self::Disk { drive } => { + push_ascii_byte(output, drive); + output.push(":"); + } + Self::RawVerbatim { raw } => arena.push_to(output, raw), + } + } + + fn prefix_only_suffix(self) -> Option<&'static str> { + match self { + Self::Unc { .. } => Some(r"\"), + Self::Disk { .. } => Some("."), + _ => None, + } + } + + fn root_is_optional(self) -> bool { + matches!(self, Self::DeviceNs { .. } | Self::RawVerbatim { .. }) + } +} + +#[cfg(target_family = "windows")] +impl BorrowedWindowsPrefixPlan<'_> { + fn write_to(self, output: &mut OsString) { + match self { + Self::VerbatimDisk { drive } => { + output.push(r"\\?\"); + push_ascii_byte(output, drive); + output.push(":"); + } + Self::DeviceNs { device } => { + output.push(r"\\.\"); + output.push(device); + } + Self::Unc { server, share } => { + output.push(r"\\"); + output.push(server); + output.push(r"\"); + output.push(share); + } + Self::Disk { drive } => { + push_ascii_byte(output, drive); + output.push(":"); + } + Self::RawVerbatim { raw } => output.push(raw), + } + } + + fn prefix_only_suffix(self) -> Option<&'static str> { + match self { + Self::Unc { .. } => Some(r"\"), + Self::Disk { .. } => Some("."), + _ => None, + } + } + + fn root_is_optional(self) -> bool { + matches!(self, Self::DeviceNs { .. } | Self::RawVerbatim { .. }) + } +} + +#[cfg(target_family = "windows")] +fn windows_prefix_plan<'id, const N: usize>( + arena: &mut EncodedArena<'id, N>, + prefix: std::path::PrefixComponent<'_>, + drive_spelling: Option, +) -> Option> { + match prefix.kind() { + std::path::Prefix::VerbatimDisk(drive) => { + Some(WindowsPrefixPlan::VerbatimDisk { drive: drive_spelling.unwrap_or(drive) }) + } + std::path::Prefix::DeviceNS(device) => { + Some(WindowsPrefixPlan::DeviceNs { device: arena.store_os_str(device)? }) + } + std::path::Prefix::UNC(server, share) => Some(WindowsPrefixPlan::Unc { + server: arena.store_os_str(server)?, + share: arena.store_os_str(share)?, + }), + std::path::Prefix::Disk(drive) => { + Some(WindowsPrefixPlan::Disk { drive: drive_spelling.unwrap_or(drive) }) + } + std::path::Prefix::Verbatim(_) | std::path::Prefix::VerbatimUNC(_, _) => { + Some(WindowsPrefixPlan::RawVerbatim { raw: arena.store_os_str(prefix.as_os_str())? }) + } + } +} + +#[cfg(target_family = "windows")] +fn borrowed_windows_prefix_plan<'a>( + prefix: std::path::PrefixComponent<'a>, + drive_spelling: Option, +) -> BorrowedWindowsPrefixPlan<'a> { + match prefix.kind() { + std::path::Prefix::VerbatimDisk(drive) => { + BorrowedWindowsPrefixPlan::VerbatimDisk { drive: drive_spelling.unwrap_or(drive) } + } + std::path::Prefix::DeviceNS(device) => BorrowedWindowsPrefixPlan::DeviceNs { device }, + std::path::Prefix::UNC(server, share) => BorrowedWindowsPrefixPlan::Unc { server, share }, + std::path::Prefix::Disk(drive) => { + BorrowedWindowsPrefixPlan::Disk { drive: drive_spelling.unwrap_or(drive) } + } + std::path::Prefix::Verbatim(_) | std::path::Prefix::VerbatimUNC(_, _) => { + BorrowedWindowsPrefixPlan::RawVerbatim { raw: prefix.as_os_str() } + } + } +} + +#[cfg(target_family = "windows")] +fn push_ascii_byte(output: &mut OsString, byte: u8) { + let bytes = [byte]; + output.push(str::from_utf8(&bytes).expect("Windows drive prefixes are ASCII")); +} + enum RelativeOutcome<'a> { BorrowedNative(&'a Path), Native(PathBuf), @@ -495,29 +677,357 @@ where Cow::Owned(normalize_owned_for_resolution(PathBuf::from(resolved))) } -fn normalize_owned_path_buf_with(mut path: PathBuf, trailing: TrailingSeparator) -> PathBuf { - let outcome = match normalize_path(&path, trailing) { - Cow::Borrowed(normalized) if std::ptr::eq(normalized, path.as_path()) => { - OwnedNormalizeOutcome::Unchanged +/// Normalize an owned buffer, reusing its allocation whenever capacity is enough. +/// +/// Dirty inputs almost always shrink (`.` / `..` / duplicate separators removed), +/// so join → `into_normalized` and `absolutize_with` (cwd push then normalize) +/// avoid a second heap allocation for the rebuild on typical path lengths. +fn normalize_owned_path_buf_with(path: PathBuf, trailing: TrailingSeparator) -> PathBuf { + if !needs_normalization(&path, trailing) { + return path; + } + + // Long paths: keep the previous one-allocation rebuild via `normalize_inner`. + if path.as_os_str().len() > OWNED_NORMALIZE_STACK_ARENA { + return normalize_owned_path_buf_via_inner(path, trailing); + } + + normalize_owned_path_buf_reusing(path, trailing) +} + +// This handles only long or overflowing owned inputs. Keeping it cold lets fat +// LTO retain the established single-call borrowed normalization path. +#[cold] +fn normalize_owned_path_buf_via_inner(mut path: PathBuf, trailing: TrailingSeparator) -> PathBuf { + let preserve_trailing = trailing == TrailingSeparator::Preserve && has_trailing_separator(&path); + let drive = { + #[cfg(target_family = "windows")] + { + windows_drive_spelling(&path) } - Cow::Borrowed(normalized) if normalized.as_os_str() == OsStr::new(".") => { - OwnedNormalizeOutcome::CurrentDirectory + #[cfg(not(target_family = "windows"))] + { + None } - Cow::Borrowed(normalized) => OwnedNormalizeOutcome::Owned(normalized.to_owned()), - Cow::Owned(normalized) => OwnedNormalizeOutcome::Owned(normalized), }; - - match outcome { - OwnedNormalizeOutcome::Unchanged => path, - OwnedNormalizeOutcome::CurrentDirectory => { + match normalize_inner( + path.components().peekable(), + path.as_os_str().len(), + preserve_trailing, + drive, + ) { + Cow::Borrowed(normalized) if std::ptr::eq(normalized, path.as_path()) => path, + Cow::Borrowed(normalized) if normalized.as_os_str() == OsStr::new(".") => { path.clear(); path.push("."); path } - OwnedNormalizeOutcome::Owned(normalized) => normalized, + Cow::Borrowed(normalized) => normalized.to_path_buf(), + Cow::Owned(normalized) => normalized, } } +#[cfg(unix)] +fn normalize_owned_path_buf_reusing(path: PathBuf, trailing: TrailingSeparator) -> PathBuf { + use std::os::unix::ffi::OsStringExt; + + let preserve_trailing = trailing == TrailingSeparator::Preserve && has_trailing_separator(&path); + let mut arena = [0u8; OWNED_NORMALIZE_STACK_ARENA]; + let mut arena_len = 0usize; + let mut stack: [(u16, u16); OWNED_NORMALIZE_COMPONENT_STACK] = + [(0, 0); OWNED_NORMALIZE_COMPONENT_STACK]; + let mut stack_len = 0usize; + let mut has_root = false; + let mut leading_parents = 0usize; + + #[cfg(target_family = "windows")] + let mut prefix_range: Option<(u16, u16)> = None; + #[cfg(target_family = "windows")] + let mut prefix_only_suffix: Option = None; + #[cfg(target_family = "windows")] + let mut prefix_root_is_optional = false; + + // Do not move `path` while `components()` borrows it. Overflow of the stack + // arena or component stack sets a flag and breaks; the owned fallback runs + // only after the iterator is dropped. + let mut fallback_to_inner = false; + for component in path.components() { + match component { + #[cfg(target_family = "windows")] + Component::Prefix(_) => unreachable!("Windows uses the encoded arena path"), + #[cfg(not(target_family = "windows"))] + Component::Prefix(_) => unreachable!("prefix components only exist on Windows"), + Component::RootDir => { + has_root = true; + stack_len = 0; + leading_parents = 0; + } + Component::CurDir => {} + Component::ParentDir => { + if stack_len > 0 { + stack_len -= 1; + } else if !has_root { + leading_parents += 1; + } + } + Component::Normal(normal) => { + if stack_len >= stack.len() { + fallback_to_inner = true; + break; + } + let Some(range) = + push_owned_normalize_arena(&mut arena, &mut arena_len, normal.as_encoded_bytes()) + else { + fallback_to_inner = true; + break; + }; + stack[stack_len] = range; + stack_len += 1; + } + } + } + if fallback_to_inner { + return normalize_owned_path_buf_via_inner(path, trailing); + } + + let sep_byte = std::path::MAIN_SEPARATOR as u8; + let mut buf = path.into_os_string().into_encoded_bytes(); + buf.clear(); + + #[cfg(target_family = "windows")] + let prefix_len = { + if let Some((start, end)) = prefix_range { + buf.extend_from_slice(&arena[start as usize..end as usize]); + } + buf.len() + }; + #[cfg(not(target_family = "windows"))] + let _prefix_len = 0usize; + + if has_root { + #[cfg(target_family = "windows")] + { + let prefix_only_input = prefix_root_is_optional && prefix_len == original_len; + if !prefix_only_input { + buf.push(sep_byte); + } + } + #[cfg(not(target_family = "windows"))] + { + buf.push(sep_byte); + } + } + + let mut need_sep = false; + for _ in 0..leading_parents { + if need_sep { + buf.push(sep_byte); + } + buf.extend_from_slice(b".."); + need_sep = true; + } + for range in stack.iter().take(stack_len) { + if need_sep { + buf.push(sep_byte); + } + buf.extend_from_slice(&arena[range.0 as usize..range.1 as usize]); + need_sep = true; + } + + #[cfg(target_family = "windows")] + if prefix_root_is_optional && stack_len == 0 && leading_parents == 0 && !preserve_trailing { + buf.truncate(prefix_len); + } + + #[cfg(target_family = "windows")] + if prefix_len == 0 && !has_root && !windows_standalone_relative_bytes_are_representable(&buf) { + let len = buf.len(); + buf.reserve(2); + buf.resize(len + 2, 0); + buf.copy_within(0..len, 2); + buf[0] = b'.'; + buf[1] = sep_byte; + } + + if buf.is_empty() { + if preserve_trailing { + buf.extend_from_slice(b"."); + buf.push(sep_byte); + } else { + buf.extend_from_slice(b"."); + } + } else { + #[cfg(target_family = "windows")] + if buf.len() == prefix_len + && prefix_len > 0 + && let Some(suffix) = prefix_only_suffix + { + buf.push(suffix); + } + + if preserve_trailing && buf.last() != Some(&sep_byte) { + buf.push(sep_byte); + } + } + + PathBuf::from(OsString::from_vec(buf)) +} + +#[cfg(not(unix))] +fn normalize_owned_path_buf_reusing(path: PathBuf, trailing: TrailingSeparator) -> PathBuf { + with_encoded_arena(|arena| normalize_owned_path_buf_in_arena(path, trailing, arena)) +} + +#[cfg(not(unix))] +fn normalize_owned_path_buf_in_arena<'id>( + path: PathBuf, + trailing: TrailingSeparator, + mut arena: EncodedArena<'id, OWNED_NORMALIZE_STACK_ARENA>, +) -> PathBuf { + let preserve_trailing = trailing == TrailingSeparator::Preserve && has_trailing_separator(&path); + #[cfg(target_family = "windows")] + let original_len = path.as_os_str().len(); + #[cfg(target_family = "windows")] + let drive_spelling = windows_drive_spelling(&path); + + let mut stack: [Option>; OWNED_NORMALIZE_COMPONENT_STACK] = + [None; OWNED_NORMALIZE_COMPONENT_STACK]; + let mut stack_len = 0usize; + let mut has_root = false; + let mut leading_parents = 0usize; + + #[cfg(target_family = "windows")] + let mut prefix_plan: Option> = None; + + // Do not move `path` while `components()` borrows it. Overflow of the stack + // arena or component stack sets a flag and breaks; the owned fallback runs + // only after the iterator is dropped. + let mut fallback_to_inner = false; + for component in path.components() { + match component { + #[cfg(target_family = "windows")] + Component::Prefix(p) => { + let Some(plan) = windows_prefix_plan(&mut arena, p, drive_spelling) else { + fallback_to_inner = true; + break; + }; + prefix_plan = Some(plan); + } + #[cfg(not(target_family = "windows"))] + Component::Prefix(_) => unreachable!("prefix components only exist on Windows"), + Component::RootDir => { + has_root = true; + stack_len = 0; + leading_parents = 0; + } + Component::CurDir => {} + Component::ParentDir => { + if stack_len > 0 { + stack_len -= 1; + } else if !has_root { + leading_parents += 1; + } + } + Component::Normal(normal) => { + if stack_len >= stack.len() { + fallback_to_inner = true; + break; + } + let Some(fragment) = arena.store_os_str(normal) else { + fallback_to_inner = true; + break; + }; + stack[stack_len] = Some(fragment); + stack_len += 1; + } + } + } + if fallback_to_inner { + return normalize_owned_path_buf_via_inner(path, trailing); + } + + let mut output = path.into_os_string(); + output.clear(); + + #[cfg(target_family = "windows")] + let prefix_len = { + if let Some(plan) = prefix_plan { + plan.write_to(&arena, &mut output); + } + output.len() + }; + + if has_root { + #[cfg(target_family = "windows")] + { + let optional_root = prefix_plan.is_some_and(WindowsPrefixPlan::root_is_optional); + let prefix_only_input = optional_root && prefix_len == original_len; + let collapse_to_prefix = + optional_root && stack_len == 0 && leading_parents == 0 && !preserve_trailing; + if !prefix_only_input && !collapse_to_prefix { + output.push(std::path::MAIN_SEPARATOR_STR); + } + } + #[cfg(not(target_family = "windows"))] + { + output.push(std::path::MAIN_SEPARATOR_STR); + } + } + + #[cfg(target_family = "windows")] + let needs_dot_prefix = prefix_plan.is_none() + && !has_root + && leading_parents == 0 + && stack_len > 0 + && stack[0].is_some_and(|fragment| !arena.is_standalone_windows_relative(fragment)); + #[cfg(target_family = "windows")] + if needs_dot_prefix { + output.push("."); + output.push(std::path::MAIN_SEPARATOR_STR); + } + + let mut need_sep = false; + for _ in 0..leading_parents { + if need_sep { + output.push(std::path::MAIN_SEPARATOR_STR); + } + output.push(".."); + need_sep = true; + } + for fragment in stack.iter().take(stack_len) { + if need_sep { + output.push(std::path::MAIN_SEPARATOR_STR); + } + arena.push_to(&mut output, fragment.expect("initialized component fragment")); + need_sep = true; + } + + if output.is_empty() { + if preserve_trailing { + output.push("."); + output.push(std::path::MAIN_SEPARATOR_STR); + } else { + output.push("."); + } + } else { + #[cfg(target_family = "windows")] + if output.len() == prefix_len + && prefix_len > 0 + && let Some(suffix) = prefix_plan.and_then(WindowsPrefixPlan::prefix_only_suffix) + { + output.push(suffix); + } + + if preserve_trailing + && output.as_encoded_bytes().last() != Some(&(std::path::MAIN_SEPARATOR as u8)) + { + output.push(std::path::MAIN_SEPARATOR_STR); + } + } + + PathBuf::from(output) +} + pub(crate) fn normalize_owned_path_buf(path: PathBuf) -> PathBuf { normalize_owned_path_buf_with(path, TrailingSeparator::Preserve) } @@ -527,6 +1037,9 @@ fn normalize_owned_for_resolution(path: PathBuf) -> PathBuf { } fn normalize_path(path: &Path, trailing: TrailingSeparator) -> Cow<'_, Path> { + if path.as_os_str().is_empty() { + return Cow::Borrowed(Path::new(".")); + } if !needs_normalization(path, trailing) { return Cow::Borrowed(path); } @@ -1297,6 +1810,7 @@ fn leading_parent_path_is_normalized(bytes: &[u8], separator: u8, preserve_trail } #[inline] +#[cfg(unix)] fn normalize_inner<'a>( mut components: Peekable>>, hint_cap: usize, @@ -1454,11 +1968,386 @@ fn normalize_inner<'a>( buf.push(sep_byte); } - // SAFETY: `buf` was built entirely from: - // - encoded bytes of OsStr components (valid platform encoding) - // - ASCII separator bytes and ASCII '.' characters - // This preserves the encoding invariants required by OsString. - Cow::Owned(PathBuf::from(unsafe { OsString::from_encoded_bytes_unchecked(buf) })) + use std::os::unix::ffi::OsStringExt; + Cow::Owned(PathBuf::from(OsString::from_vec(buf))) +} + +#[cfg(not(unix))] +#[inline] +fn normalize_inner<'a>( + mut components: Peekable> + Clone>, + hint_cap: usize, + preserve_trailing: bool, + _drive_spelling: Option, +) -> Cow<'a, Path> { + let original_components = components.clone(); + + #[cfg(target_family = "windows")] + let prefix_plan = if let Some(Component::Prefix(prefix)) = components.peek().copied() { + let plan = borrowed_windows_prefix_plan(prefix, _drive_spelling); + components.next(); + Some(plan) + } else { + None + }; + + let has_root = if matches!(components.peek(), Some(Component::RootDir)) { + components.next(); + true + } else { + false + }; + + let mut stack: SmallVec<[&'a OsStr; NATIVE_NORMALIZE_COMPONENT_STACK]> = SmallVec::new(); + let mut leading_parents = 0usize; + for component in components { + match component { + Component::Prefix(prefix) => unreachable!("Unexpected prefix for {:?}", prefix), + Component::RootDir => unreachable!("Unexpected RootDir after initial position"), + Component::CurDir => {} + Component::ParentDir => { + if stack.pop().is_none() && !has_root { + leading_parents += 1; + } + } + Component::Normal(normal) => { + if stack.len() == NATIVE_NORMALIZE_COMPONENT_STACK && !stack.spilled() { + let profile = profile_native_normalization(original_components.clone()); + if profile.component_count <= NATIVE_NORMALIZE_REPLAY_TOTAL_COMPONENT_LIMIT + && hint_cap <= NATIVE_NORMALIZE_REPLAY_ENCODED_BYTE_LIMIT + { + return normalize_inner_bounded_replay( + original_components, + hint_cap, + preserve_trailing, + _drive_spelling, + ); + } + // Reserve the exact maximum live depth once. Counting every input + // component here could amplify parent-heavy paths into excess memory. + stack.reserve_exact(profile.max_normal_depth.saturating_sub(stack.len())); + } + stack.push(normal); + } + } + } + + let mut output = OsString::with_capacity(hint_cap); + #[cfg(target_family = "windows")] + let prefix_len = { + if let Some(plan) = prefix_plan { + plan.write_to(&mut output); + } + output.len() + }; + + if has_root { + #[cfg(target_family = "windows")] + { + let optional_root = prefix_plan.is_some_and(BorrowedWindowsPrefixPlan::root_is_optional); + let prefix_only_input = optional_root && prefix_len == hint_cap; + let collapse_to_prefix = + optional_root && stack.is_empty() && leading_parents == 0 && !preserve_trailing; + if !prefix_only_input && !collapse_to_prefix { + output.push(std::path::MAIN_SEPARATOR_STR); + } + } + #[cfg(not(target_family = "windows"))] + { + output.push(std::path::MAIN_SEPARATOR_STR); + } + } + + #[cfg(target_family = "windows")] + if prefix_plan.is_none() + && !has_root + && leading_parents == 0 + && stack.first().is_some_and(|normal| { + !windows_standalone_relative_bytes_are_representable(normal.as_encoded_bytes()) + }) + { + output.push("."); + output.push(std::path::MAIN_SEPARATOR_STR); + } + + let mut need_sep = false; + for _ in 0..leading_parents { + if need_sep { + output.push(std::path::MAIN_SEPARATOR_STR); + } + output.push(".."); + need_sep = true; + } + for normal in stack { + if need_sep { + output.push(std::path::MAIN_SEPARATOR_STR); + } + output.push(normal); + need_sep = true; + } + + if output.is_empty() { + if preserve_trailing { + output.push("."); + output.push(std::path::MAIN_SEPARATOR_STR); + return Cow::Owned(PathBuf::from(output)); + } + return Cow::Borrowed(Path::new(".")); + } + + #[cfg(target_family = "windows")] + if output.len() == prefix_len + && prefix_len > 0 + && let Some(suffix) = prefix_plan.and_then(BorrowedWindowsPrefixPlan::prefix_only_suffix) + { + output.push(suffix); + } + + if preserve_trailing + && output.as_encoded_bytes().last() != Some(&(std::path::MAIN_SEPARATOR as u8)) + { + output.push(std::path::MAIN_SEPARATOR_STR); + } + + Cow::Owned(PathBuf::from(output)) +} + +#[cfg(not(unix))] +struct NativeNormalizationProfile { + component_count: usize, + max_normal_depth: usize, +} + +#[cfg(not(unix))] +#[cold] +fn profile_native_normalization<'a>( + components: impl Iterator>, +) -> NativeNormalizationProfile { + #[cfg(test)] + NATIVE_NORMALIZATION_PROFILE_CALLS.with(|calls| calls.set(calls.get() + 1)); + + let mut component_count = 0usize; + let mut normal_depth = 0usize; + let mut max_normal_depth = 0usize; + for component in components { + component_count += 1; + match component { + Component::Prefix(_) | Component::CurDir => {} + Component::RootDir => normal_depth = 0, + Component::ParentDir => normal_depth = normal_depth.saturating_sub(1), + Component::Normal(_) => { + normal_depth += 1; + max_normal_depth = max_normal_depth.max(normal_depth); + } + } + } + NativeNormalizationProfile { component_count, max_normal_depth } +} + +#[cfg(all(test, not(unix)))] +mod native_normalization_profile_tests { + use std::path::PathBuf; + + use crate::{SugarPath, SugarPathBuf}; + + use super::NATIVE_NORMALIZATION_PROFILE_CALLS; + + fn take_profile_calls() -> usize { + NATIVE_NORMALIZATION_PROFILE_CALLS.with(|calls| calls.replace(0)) + } + + fn assert_profiles_once(path: PathBuf) { + assert_eq!(take_profile_calls(), 0); + drop(path.normalize()); + assert_eq!(take_profile_calls(), 1, "borrowed normalization profile count"); + + drop(path.into_normalized()); + assert_eq!(take_profile_calls(), 1, "consuming normalization profile count"); + } + + fn dirty_depth(depth: usize, component: &str) -> PathBuf { + let mut path = PathBuf::new(); + for _ in 0..depth { + path.push(component); + } + path.push("."); + path + } + + #[test] + fn deep_strategy_profiles_each_normalization_once() { + assert_profiles_once(dirty_depth(64, "a")); + assert_profiles_once(dirty_depth(65, "a")); + assert_profiles_once(dirty_depth(33, "aaaaaaaaaaaaaaaa")); + + let mut oscillating = dirty_depth(32, "base"); + for _ in 0..256 { + oscillating.push("temporary"); + oscillating.push(".."); + } + oscillating.push("."); + assert_profiles_once(oscillating); + } +} + +#[cfg(not(unix))] +fn native_normal_survives<'a>(components: impl Iterator>) -> bool { + let mut later_depth = 0usize; + for component in components { + match component { + Component::CurDir => {} + Component::Normal(_) => later_depth += 1, + Component::ParentDir => { + if later_depth == 0 { + return false; + } + later_depth -= 1; + } + Component::Prefix(prefix) => unreachable!("Unexpected prefix for {:?}", prefix), + Component::RootDir => unreachable!("Unexpected RootDir after initial position"), + } + } + true +} + +// A path just beyond the 32-component inline stack would make `SmallVec` add a +// second allocation. For at most 64 total components, re-scan the clonable +// iterator instead. The caller limits replay to 64 total components and 512 +// encoded bytes; larger inputs use a pre-reserved, linear spill. +#[cfg(not(unix))] +#[cold] +fn normalize_inner_bounded_replay<'a>( + mut components: Peekable> + Clone>, + hint_cap: usize, + preserve_trailing: bool, + _drive_spelling: Option, +) -> Cow<'a, Path> { + debug_assert!(components.clone().count() <= NATIVE_NORMALIZE_REPLAY_TOTAL_COMPONENT_LIMIT); + debug_assert!(hint_cap <= NATIVE_NORMALIZE_REPLAY_ENCODED_BYTE_LIMIT); + #[cfg(target_family = "windows")] + let prefix_plan = if let Some(Component::Prefix(prefix)) = components.peek().copied() { + let plan = borrowed_windows_prefix_plan(prefix, _drive_spelling); + components.next(); + Some(plan) + } else { + None + }; + + let has_root = if matches!(components.peek(), Some(Component::RootDir)) { + components.next(); + true + } else { + false + }; + let body = components; + + let mut surviving_depth = 0usize; + let mut leading_parents = 0usize; + for component in body.clone() { + match component { + Component::CurDir => {} + Component::Normal(_) => surviving_depth += 1, + Component::ParentDir => { + if surviving_depth > 0 { + surviving_depth -= 1; + } else if !has_root { + leading_parents += 1; + } + } + Component::Prefix(prefix) => unreachable!("Unexpected prefix for {:?}", prefix), + Component::RootDir => unreachable!("Unexpected RootDir after initial position"), + } + } + + let mut output = OsString::with_capacity(hint_cap); + #[cfg(target_family = "windows")] + let prefix_len = { + if let Some(plan) = prefix_plan { + plan.write_to(&mut output); + } + output.len() + }; + + if has_root { + #[cfg(target_family = "windows")] + { + let optional_root = prefix_plan.is_some_and(BorrowedWindowsPrefixPlan::root_is_optional); + let prefix_only_input = optional_root && prefix_len == hint_cap; + let collapse_to_prefix = + optional_root && surviving_depth == 0 && leading_parents == 0 && !preserve_trailing; + if !prefix_only_input && !collapse_to_prefix { + output.push(std::path::MAIN_SEPARATOR_STR); + } + } + #[cfg(not(target_family = "windows"))] + { + output.push(std::path::MAIN_SEPARATOR_STR); + } + } + + let mut need_sep = false; + for _ in 0..leading_parents { + if need_sep { + output.push(std::path::MAIN_SEPARATOR_STR); + } + output.push(".."); + need_sep = true; + } + + let mut remaining = body; + let mut wrote_normal = false; + while let Some(component) = remaining.next() { + let Component::Normal(normal) = component else { + continue; + }; + if !native_normal_survives(remaining.clone()) { + continue; + } + + #[cfg(target_family = "windows")] + if !wrote_normal + && prefix_plan.is_none() + && !has_root + && leading_parents == 0 + && !windows_standalone_relative_bytes_are_representable(normal.as_encoded_bytes()) + { + output.push("."); + output.push(std::path::MAIN_SEPARATOR_STR); + } + + if need_sep { + output.push(std::path::MAIN_SEPARATOR_STR); + } + output.push(normal); + need_sep = true; + wrote_normal = true; + } + debug_assert_eq!(wrote_normal, surviving_depth > 0); + + if output.is_empty() { + if preserve_trailing { + output.push("."); + output.push(std::path::MAIN_SEPARATOR_STR); + return Cow::Owned(PathBuf::from(output)); + } + return Cow::Borrowed(Path::new(".")); + } + + #[cfg(target_family = "windows")] + if output.len() == prefix_len + && prefix_len > 0 + && let Some(suffix) = prefix_plan.and_then(BorrowedWindowsPrefixPlan::prefix_only_suffix) + { + output.push(suffix); + } + + if preserve_trailing + && output.as_encoded_bytes().last() != Some(&(std::path::MAIN_SEPARATOR as u8)) + { + output.push(std::path::MAIN_SEPARATOR_STR); + } + + Cow::Owned(PathBuf::from(output)) } impl SugarPath for str { @@ -1664,6 +2553,7 @@ fn common_prefix_len_scalar(left: &[u8], right: &[u8]) -> usize { } #[cfg(all(not(target_family = "windows"), target_arch = "aarch64", target_feature = "neon"))] +#[allow(unsafe_code)] #[inline] fn common_prefix_len_neon(left: &[u8], right: &[u8]) -> usize { use std::arch::aarch64::{vceqq_u8, vld1q_u8, vminvq_u8, vst1q_u8}; @@ -1681,7 +2571,8 @@ fn common_prefix_len_neon(left: &[u8], right: &[u8]) -> usize { let right_chunk = vld1q_u8(right.as_ptr().add(offset)); vceqq_u8(left_chunk, right_chunk) }; - // Equality lanes are all ones for a match and all zeroes for a mismatch. + // SAFETY: `equal` is a fully initialized NEON vector value. Equality lanes + // are all ones for a match and all zeroes for a mismatch. if unsafe { vminvq_u8(equal) } != u8::MAX { let mut equal_bytes = [0; 16]; // SAFETY: `equal_bytes` has exactly enough initialized space for one @@ -1957,6 +2848,7 @@ mod relative_str_tests { /// Replace `\` with `/` using memchr SIMD search. Returns the input unchanged /// (zero allocation) when no backslashes are present. #[cfg(target_family = "windows")] +#[allow(unsafe_code)] fn normalize_backslash_cow(s: &str) -> Cow<'_, str> { let bytes = s.as_bytes(); let Some(first) = memchr(b'\\', bytes) else { diff --git a/src/lib.rs b/src/lib.rs index 0cf1432..e67c7d9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,5 @@ +#![deny(unsafe_code)] +#![deny(clippy::undocumented_unsafe_blocks)] #![warn(missing_docs, rustdoc::broken_intra_doc_links)] //! Host-native lexical path manipulation as extension methods on standard Rust types. //! @@ -95,6 +97,8 @@ //! the [changelog](https://github.com/hyf0/sugar_path/blob/main/CHANGELOG.md) //! for release and migration information. +#[cfg(not(unix))] +mod encoded_arena; mod impl_sugar_path; mod sugar_path; mod sugar_path_buf; diff --git a/tasks/track_allocations/README.md b/tasks/track_allocations/README.md index d58a85c..21569f0 100644 --- a/tasks/track_allocations/README.md +++ b/tasks/track_allocations/README.md @@ -12,7 +12,7 @@ The clean allocation targets have dedicated rows rather than being inferred from Each scenario has separate setup and operation phases. Allocation-capable input setup runs with tracking disabled before the warm-up operation and before every measured operation; this excludes construction of invalid-encoding paths, owned cwd values, and owned clean inputs. Operations named `join` include the join allocation by design. Every scenario is warmed once and then measured seven times in a single-threaded process, and the executable fails if any sample differs. Result formatting, snapshot parsing, and file I/O stay outside tracked regions; `std::hint::black_box` keeps inputs and results observable to the optimizer. -The scenario matrix covers clean and dirty normalization, current-directory spellings, leading-parent paths, invalid native encoding, absolute and relative path relations, explicit borrowed and owned cwd inputs, native slash conversion, and composed PathBuf, String, and ArcStr outputs. The sideEffects rows compare the public relative operation with a caller-side strip-prefix fallback for both descendant hits and upward misses; their temporary text borrows from the path result. Windows builds add mixed separators, drive-relative and root-relative paths, ordinary and verbatim UNC roots, and invalid wide strings. +The scenario matrix covers clean and dirty normalization, the 24/25-component owned-reuse boundary, current-directory spellings, leading-parent paths, invalid native encoding, absolute and relative path relations, exact-capacity as well as ordinary borrowed and owned cwd inputs, native slash conversion, and composed PathBuf, String, and ArcStr outputs. The sideEffects rows compare the public relative operation with a caller-side strip-prefix fallback for both descendant hits and upward misses; their temporary text borrows from the path result. Windows builds add the 64/65-component and 512-byte replay/spill boundaries, mixed separators, drive-relative and root-relative paths, ordinary and verbatim UNC roots, long-prefix consuming normalization, and invalid wide strings. ## Run diff --git a/tasks/track_allocations/src/main.rs b/tasks/track_allocations/src/main.rs index b7e881f..6c8a817 100644 --- a/tasks/track_allocations/src/main.rs +++ b/tasks/track_allocations/src/main.rs @@ -265,6 +265,33 @@ const SCENARIOS: &[Scenario] = &[ name: "normalize / owned invalid input via owned receiver -> PathBuf", run: normalize_owned_invalid_consuming, }, + Scenario { + name: "normalize / owned dirty 24-component boundary via owned receiver -> PathBuf", + run: normalize_owned_dirty_depth_24_consuming, + }, + Scenario { + name: "normalize / owned dirty 25-component fallback via owned receiver -> PathBuf", + run: normalize_owned_dirty_depth_25_consuming, + }, + Scenario { + name: "normalize / owned dirty 33-component deep fallback via owned receiver -> PathBuf", + run: normalize_owned_dirty_depth_33_consuming, + }, + #[cfg(not(unix))] + Scenario { + name: "normalize / non-Unix dirty 64-component replay boundary via owned receiver -> PathBuf", + run: normalize_owned_dirty_depth_64_consuming, + }, + #[cfg(not(unix))] + Scenario { + name: "normalize / non-Unix dirty 65-component linear spill via owned receiver -> PathBuf", + run: normalize_owned_dirty_depth_65_consuming, + }, + #[cfg(not(unix))] + Scenario { + name: "normalize / non-Unix dirty long 33-component linear spill via owned receiver -> PathBuf", + run: normalize_owned_dirty_long_depth_33_consuming, + }, Scenario { name: "pipeline / dirty join via borrowed receiver -> PathBuf", run: join_normalize_owned, @@ -312,6 +339,16 @@ const SCENARIOS: &[Scenario] = &[ name: "Windows / canonical verbatim UNC -> normalized path", run: windows_normalize_canonical_verbatim_unc, }, + #[cfg(target_family = "windows")] + Scenario { + name: "Windows / long-prefix 24-component UNC boundary via owned receiver -> PathBuf", + run: windows_normalize_long_prefix_unc_consuming, + }, + #[cfg(target_family = "windows")] + Scenario { + name: "Windows / long-prefix 25-component UNC fallback via owned receiver -> PathBuf", + run: windows_normalize_long_prefix_deep_unc_consuming, + }, Scenario { name: "absolutize / clean absolute input -> absolute path", run: absolutize_clean_absolute, @@ -332,6 +369,14 @@ const SCENARIOS: &[Scenario] = &[ name: "absolutize_with / clean relative input + owned cwd -> absolute path (setup excluded)", run: absolutize_with_clean_relative_owned_cwd, }, + Scenario { + name: "absolutize_with / empty input + exact owned root cwd -> absolute path (setup excluded)", + run: absolutize_with_empty_exact_owned_root, + }, + Scenario { + name: "absolutize_with / relative input + exact trailing-separator owned cwd -> absolute path (setup excluded)", + run: absolutize_with_relative_exact_trailing_owned_cwd, + }, Scenario { name: "relative / canonical native descendant -> natural result", run: relative_absolute, @@ -518,7 +563,9 @@ const SCENARIOS: &[Scenario] = &[ #[cfg(target_family = "windows")] mod native_paths { + pub const ABSOLUTE_ROOT: &str = r"C:\"; pub const ABSOLUTE_BASE: &str = r"C:\workspace\rolldown\crates\rolldown"; + pub const ABSOLUTE_TRAILING_BASE: &str = r"C:\base\"; pub const ABSOLUTE_CLEAN: &str = r"C:\workspace\rolldown\crates\rolldown\src\bundle\bundle.rs"; pub const ABSOLUTE_TARGET: &str = r"C:\workspace\rolldown\crates\rolldown\src\stages\generate_stage\mod.rs"; @@ -534,6 +581,7 @@ mod native_paths { pub const CANONICAL_LEADING_PARENTS: &str = r"..\..\chunks\shared.js"; pub const RELATIVE_BASE: &str = r"crates\rolldown\src\module_loader"; pub const RELATIVE_CLEAN_INPUT: &str = r"src\module_loader\module_task.rs"; + pub const RELATIVE_FITS: &str = "file"; pub const RELATIVE_INPUT: &str = r".\src\stages\..\bundle\bundle.rs"; pub const RELATIVE_TARGET: &str = r"crates\rolldown\src\stages\generate_stage\mod.rs"; pub const RELATIVE_CURRENT_BASE: &str = ""; @@ -579,7 +627,9 @@ mod native_paths { #[cfg(not(target_family = "windows"))] mod native_paths { + pub const ABSOLUTE_ROOT: &str = "/"; pub const ABSOLUTE_BASE: &str = "/workspace/rolldown/crates/rolldown"; + pub const ABSOLUTE_TRAILING_BASE: &str = "/base/"; pub const ABSOLUTE_CLEAN: &str = "/workspace/rolldown/crates/rolldown/src/bundle/bundle.rs"; pub const ABSOLUTE_TARGET: &str = "/workspace/rolldown/crates/rolldown/src/stages/generate_stage/mod.rs"; @@ -595,6 +645,7 @@ mod native_paths { pub const CANONICAL_LEADING_PARENTS: &str = "../../chunks/shared.js"; pub const RELATIVE_BASE: &str = "crates/rolldown/src/module_loader"; pub const RELATIVE_CLEAN_INPUT: &str = "src/module_loader/module_task.rs"; + pub const RELATIVE_FITS: &str = "file"; pub const RELATIVE_INPUT: &str = "./src/stages/../bundle/bundle.rs"; pub const RELATIVE_TARGET: &str = "crates/rolldown/src/stages/generate_stage/mod.rs"; pub const RELATIVE_CURRENT_BASE: &str = ""; @@ -823,6 +874,65 @@ fn normalize_owned_invalid_consuming(mode: RunMode) -> AllocationStats { }) } +fn dirty_owned_path_with_depth(depth: usize) -> PathBuf { + let mut path = PathBuf::with_capacity(256); + for _ in 0..depth { + path.push("a"); + } + path.push("."); + path +} + +fn normalize_owned_dirty_depth_consuming(mode: RunMode, depth: usize) -> AllocationStats { + run_prepared( + mode, + || dirty_owned_path_with_depth(depth), + |path| { + black_box(black_box(path).into_normalized()); + }, + ) +} + +fn normalize_owned_dirty_depth_24_consuming(mode: RunMode) -> AllocationStats { + normalize_owned_dirty_depth_consuming(mode, 24) +} + +fn normalize_owned_dirty_depth_25_consuming(mode: RunMode) -> AllocationStats { + normalize_owned_dirty_depth_consuming(mode, 25) +} + +fn normalize_owned_dirty_depth_33_consuming(mode: RunMode) -> AllocationStats { + normalize_owned_dirty_depth_consuming(mode, 33) +} + +#[cfg(not(unix))] +fn normalize_owned_dirty_depth_64_consuming(mode: RunMode) -> AllocationStats { + normalize_owned_dirty_depth_consuming(mode, 64) +} + +#[cfg(not(unix))] +fn normalize_owned_dirty_depth_65_consuming(mode: RunMode) -> AllocationStats { + normalize_owned_dirty_depth_consuming(mode, 65) +} + +#[cfg(not(unix))] +fn normalize_owned_dirty_long_depth_33_consuming(mode: RunMode) -> AllocationStats { + run_prepared( + mode, + || { + let mut path = PathBuf::with_capacity(1024); + for _ in 0..33 { + path.push("aaaaaaaaaaaaaaaa"); + } + path.push("."); + path + }, + |path| { + black_box(black_box(path).into_normalized()); + }, + ) +} + fn join_normalize_owned(mode: RunMode) -> AllocationStats { run_prepared( mode, @@ -950,6 +1060,39 @@ fn windows_normalize_canonical_verbatim_unc(mode: RunMode) -> AllocationStats { windows_normalize_case(mode, native_paths::VERBATIM_UNC_CANONICAL) } +#[cfg(target_family = "windows")] +fn windows_long_prefix_unc_path(depth: usize) -> PathBuf { + let server = "s".repeat(57); + let mut path = PathBuf::from(format!(r"\\{server}\share")); + for _ in 0..depth { + path.push("a"); + } + path.push("."); + path +} + +#[cfg(target_family = "windows")] +fn windows_normalize_long_prefix_unc_consuming(mode: RunMode) -> AllocationStats { + run_prepared( + mode, + || windows_long_prefix_unc_path(24), + |path| { + black_box(black_box(path).into_normalized()); + }, + ) +} + +#[cfg(target_family = "windows")] +fn windows_normalize_long_prefix_deep_unc_consuming(mode: RunMode) -> AllocationStats { + run_prepared( + mode, + || windows_long_prefix_unc_path(25), + |path| { + black_box(black_box(path).into_normalized()); + }, + ) +} + fn absolutize_clean_absolute(mode: RunMode) -> AllocationStats { run_prepared( mode, @@ -1009,6 +1152,37 @@ fn absolutize_with_clean_relative_owned_cwd(mode: RunMode) -> AllocationStats { ) } +fn absolutize_with_empty_exact_owned_root(mode: RunMode) -> AllocationStats { + run_prepared( + mode, + || { + let mut cwd = PathBuf::with_capacity(native_paths::ABSOLUTE_ROOT.len()); + cwd.push(native_paths::ABSOLUTE_ROOT); + cwd + }, + |cwd| { + let value = black_box(Path::new("")).absolutize_with(black_box(cwd)); + black_box(value); + }, + ) +} + +fn absolutize_with_relative_exact_trailing_owned_cwd(mode: RunMode) -> AllocationStats { + run_prepared( + mode, + || { + let capacity = native_paths::ABSOLUTE_TRAILING_BASE.len() + native_paths::RELATIVE_FITS.len(); + let mut cwd = PathBuf::with_capacity(capacity); + cwd.push(native_paths::ABSOLUTE_TRAILING_BASE); + cwd + }, + |cwd| { + let value = black_box(Path::new(native_paths::RELATIVE_FITS)).absolutize_with(black_box(cwd)); + black_box(value); + }, + ) +} + fn relative_absolute(mode: RunMode) -> AllocationStats { run_prepared( mode, diff --git a/tests/absolutize_with.rs b/tests/absolutize_with.rs index 14eb404..a6587e7 100644 --- a/tests/absolutize_with.rs +++ b/tests/absolutize_with.rs @@ -1,6 +1,5 @@ use std::borrow::Cow; -#[cfg(target_family = "unix")] -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use sugar_path::SugarPath; mod test_utils; @@ -50,6 +49,33 @@ fn unix_absolutize_with_borrowed_and_owned_cwd_arguments() { assert_eq_str!("app.log".absolutize_with(base_path), "/var/log/app.log"); } +#[test] +fn absolutize_with_reuses_an_exactly_sized_owned_cwd_when_push_fits() { + #[cfg(not(target_family = "windows"))] + let cases = [("/", "", "/"), ("/base/", "file", "/base/file")]; + #[cfg(target_family = "windows")] + let cases = [ + (r"C:\", "", r"C:\"), + (r"C:\base\", "file", r"C:\base\file"), + (r"C:\base", r"\file", r"C:\file"), + ]; + + for (base, input, expected) in cases { + let mut cwd = PathBuf::with_capacity(base.len().max(expected.len())); + cwd.push(base); + let identity = (cwd.as_os_str().as_encoded_bytes().as_ptr(), cwd.capacity()); + + let absolute = Path::new(input).absolutize_with(cwd).into_owned(); + + assert_eq!(absolute.as_os_str(), Path::new(expected).as_os_str(), "input {input:?}"); + assert_eq!( + (absolute.as_os_str().as_encoded_bytes().as_ptr(), absolute.capacity()), + identity, + "input {input:?}", + ); + } +} + #[cfg(target_family = "windows")] #[test] fn windows_absolutize_with() { @@ -82,6 +108,7 @@ fn windows_absolutize_with() { assert_eq_str!(r"C:file".absolutize_with(r"\\?\c:\base"), r"\\?\C:\base\file"); assert_eq_str!("C:file".absolutize_with("D:\\base"), "C:file"); assert_eq_str!("C:.\\file".absolutize_with("D:\\base"), "C:file"); + assert_eq_str!("file".absolutize_with(PathBuf::from(r"\\?\C:\base")), r"\\?\C:\base\file"); } #[cfg(target_family = "windows")] diff --git a/tests/invalid_encoding.rs b/tests/invalid_encoding.rs index be79e3a..727c25e 100644 --- a/tests/invalid_encoding.rs +++ b/tests/invalid_encoding.rs @@ -13,6 +13,7 @@ mod unix { }; use super::*; + use sugar_path::SugarPathBuf; fn path(bytes: &[u8]) -> PathBuf { PathBuf::from(OsString::from_vec(bytes.to_vec())) @@ -29,11 +30,21 @@ mod unix { } fn assert_normalizes_exactly_and_is_idempotent(input: &[u8], expected: &[u8]) { - let once = path(input).normalize().into_owned(); - assert_bytes(&once, expected); + let source = path(input); + let borrowed = source.normalize().into_owned(); + let identity = (source.as_os_str().as_bytes().as_ptr(), source.capacity()); + let owned = source.into_normalized(); + assert_eq!( + (owned.as_os_str().as_bytes().as_ptr(), owned.capacity()), + identity, + "owned normalization did not reuse its input buffer", + ); - let twice = once.normalize().into_owned(); - assert_bytes(&twice, expected); + for once in [borrowed, owned] { + assert_bytes(&once, expected); + let twice = once.normalize().into_owned(); + assert_bytes(&twice, expected); + } } #[test] @@ -61,6 +72,23 @@ mod unix { assert_bytes(&normalized, b"/workspace/segment-\x80/file"); } + #[test] + fn normalize_preserves_arbitrary_bytes_at_component_and_arena_boundaries() { + assert_normalizes_exactly_and_is_idempotent( + b"\xff-first-\x80/./\xc0-second-\0/drop-\xed\xa0\x80/../tail/", + b"\xff-first-\x80/\xc0-second-\0/tail/", + ); + + let mut input = b"./".to_vec(); + input.resize(511, b'x'); + input.push(0xff); + assert_eq!(input.len(), 512); + + let mut expected = vec![b'x'; 509]; + expected.push(0xff); + assert_normalizes_exactly_and_is_idempotent(&input, &expected); + } + #[test] fn relative_fallback_preserves_an_invalid_normal_component() { let target = path(b"/base/segment-\x80/file"); @@ -149,10 +177,28 @@ mod windows { }; use super::*; + use sugar_path::SugarPathBuf; const LONE_HIGH_SURROGATE: u16 = 0xd800; const DISTINCT_HIGH_SURROGATE: u16 = 0xd801; + #[derive(Clone, Copy)] + enum WidePart<'a> { + Text(&'a str), + Unit(u16), + } + + #[derive(Clone, Copy, Debug)] + enum PrefixClass { + None, + Disk, + VerbatimDisk, + Unc, + VerbatimUnc, + DeviceNs, + Verbatim, + } + fn wide_with_invalid(prefix: &str, suffix: &str) -> Vec { wide_with_invalid_unit(prefix, LONE_HIGH_SURROGATE, suffix) } @@ -168,6 +214,77 @@ mod windows { PathBuf::from(OsString::from_wide(wide)) } + fn wide(parts: &[WidePart<'_>]) -> Vec { + let mut result = Vec::new(); + for part in parts { + match *part { + WidePart::Text(text) => result.extend(text.encode_utf16()), + WidePart::Unit(unit) => result.push(unit), + } + } + result + } + + fn assert_prefix_class(name: &str, path: &Path, expected: PrefixClass) { + use std::path::{Component, Prefix}; + + let actual = match path.components().next() { + Some(Component::Prefix(prefix)) => Some(prefix.kind()), + _ => None, + }; + let matches = matches!( + (expected, actual), + (PrefixClass::None, None) + | (PrefixClass::Disk, Some(Prefix::Disk(_))) + | (PrefixClass::VerbatimDisk, Some(Prefix::VerbatimDisk(_))) + | (PrefixClass::Unc, Some(Prefix::UNC(_, _))) + | (PrefixClass::VerbatimUnc, Some(Prefix::VerbatimUNC(_, _))) + | (PrefixClass::DeviceNs, Some(Prefix::DeviceNS(_))) + | (PrefixClass::Verbatim, Some(Prefix::Verbatim(_))) + ); + assert!(matches, "{name}: fixture parsed as {actual:?}"); + } + + fn assert_wide_case(name: &str, stage: &str, actual: &Path, expected: &[u16]) { + assert_eq!(actual.as_os_str().encode_wide().collect::>(), expected, "{name}: {stage}",); + } + + fn assert_normalization_case( + name: &str, + prefix: PrefixClass, + input: &[WidePart<'_>], + expected: &[WidePart<'_>], + ) { + let source = path(&wide(input)); + let expected = wide(expected); + + assert_prefix_class(name, &source, prefix); + assert!(source.to_str().is_none(), "{name}: fixture must contain unpaired surrogates"); + assert!(source.as_os_str().len() <= 512, "{name}: fixture must use the arena path"); + + let borrowed = source.normalize().into_owned(); + let owned_input = source.clone(); + let identity = (owned_input.as_os_str().as_encoded_bytes().as_ptr(), owned_input.capacity()); + let owned = owned_input.into_normalized(); + assert_eq!( + (owned.as_os_str().as_encoded_bytes().as_ptr(), owned.capacity()), + identity, + "{name}: owned normalization did not reuse its input buffer", + ); + + for (api, once) in [("normalize", borrowed), ("into_normalized", owned)] { + assert_wide_case(name, api, &once, &expected); + assert!(once.to_str().is_none(), "{name}: {api} paired or lost a surrogate"); + + let twice_borrowed = once.normalize(); + assert_wide_case(name, "idempotent normalize", twice_borrowed.as_ref(), &expected); + drop(twice_borrowed); + + let twice_owned = once.into_normalized(); + assert_wide_case(name, "idempotent into_normalized", &twice_owned, &expected); + } + } + fn invalid_path(prefix: &str, suffix: &str) -> PathBuf { path(&wide_with_invalid(prefix, suffix)) } @@ -236,6 +353,195 @@ mod windows { assert_wide(&normalized, &wide_with_invalid(r"C:\workspace\segment-", r"\file")); } + #[test] + fn normalization_preserves_surrogates_at_every_windows_chunk_boundary() { + use WidePart::{Text as T, Unit as U}; + + const HIGH: u16 = 0xd800; + const LOW: u16 = 0xdc00; + + assert_normalization_case( + "relative normal components", + PrefixClass::None, + &[U(LOW), T("first"), U(HIGH), T(r"\.\"), U(LOW), T("second"), U(HIGH), T(r"\drop\..\tail")], + &[U(LOW), T("first"), U(HIGH), T(r"\"), U(LOW), T("second"), U(HIGH), T(r"\tail")], + ); + + assert_normalization_case( + "drive-relative disk", + PrefixClass::Disk, + &[T("c:"), U(LOW), T("disk"), U(HIGH), T(r"\.\tail")], + &[T("c:"), U(LOW), T("disk"), U(HIGH), T(r"\tail")], + ); + + assert_normalization_case( + "verbatim disk", + PrefixClass::VerbatimDisk, + &[T(r"\\?\c:\"), U(LOW), T("disk"), U(HIGH), T(r"\.\tail")], + &[T(r"\\?\c:\"), U(LOW), T("disk"), U(HIGH), T(r"\tail")], + ); + + assert_normalization_case( + "UNC fields", + PrefixClass::Unc, + &[ + T(r"\\"), + U(LOW), + T("server"), + U(HIGH), + T(r"\"), + U(LOW), + T("share"), + U(HIGH), + T(r"\"), + U(LOW), + T("leaf"), + U(HIGH), + T(r"\.\tail"), + ], + &[ + T(r"\\"), + U(LOW), + T("server"), + U(HIGH), + T(r"\"), + U(LOW), + T("share"), + U(HIGH), + T(r"\"), + U(LOW), + T("leaf"), + U(HIGH), + T(r"\tail"), + ], + ); + + assert_normalization_case( + "verbatim UNC fields", + PrefixClass::VerbatimUnc, + &[ + T(r"\\?\UNC\"), + U(LOW), + T("server"), + U(HIGH), + T(r"\"), + U(LOW), + T("share"), + U(HIGH), + T(r"\"), + U(LOW), + T("leaf"), + U(HIGH), + T(r"\.\tail"), + ], + &[ + T(r"\\?\UNC\"), + U(LOW), + T("server"), + U(HIGH), + T(r"\"), + U(LOW), + T("share"), + U(HIGH), + T(r"\"), + U(LOW), + T("leaf"), + U(HIGH), + T(r"\tail"), + ], + ); + + assert_normalization_case( + "device namespace field", + PrefixClass::DeviceNs, + &[T(r"\\.\"), U(LOW), T("PIPE"), U(HIGH), T(r"\"), U(LOW), T("leaf"), U(HIGH), T(r"\.\tail")], + &[T(r"\\.\"), U(LOW), T("PIPE"), U(HIGH), T(r"\"), U(LOW), T("leaf"), U(HIGH), T(r"\tail")], + ); + + assert_normalization_case( + "generic verbatim field", + PrefixClass::Verbatim, + &[ + T(r"\\?\"), + U(LOW), + T("Volume"), + U(HIGH), + T(r"\"), + U(LOW), + T("leaf"), + U(HIGH), + T(r"\.\tail"), + ], + &[T(r"\\?\"), U(LOW), T("Volume"), U(HIGH), T(r"\"), U(LOW), T("leaf"), U(HIGH), T(r"\tail")], + ); + + assert_normalization_case( + "invalid prefix-only collapse", + PrefixClass::Verbatim, + &[ + T(r"\\?\"), + U(LOW), + T("namespace"), + U(HIGH), + T(r"\"), + U(LOW), + T("gone"), + U(HIGH), + T(r"\.."), + ], + &[T(r"\\?\"), U(LOW), T("namespace"), U(HIGH)], + ); + + assert_normalization_case( + "invalid prefix-only trailing separator", + PrefixClass::Verbatim, + &[ + T(r"\\?\"), + U(LOW), + T("namespace"), + U(HIGH), + T(r"\"), + U(LOW), + T("gone"), + U(HIGH), + T(r"\..\"), + ], + &[T(r"\\?\"), U(LOW), T("namespace"), U(HIGH), T(r"\")], + ); + } + + #[test] + fn deep_linear_spill_preserves_invalid_verbatim_unc_and_trailing_separator() { + let mut input = wide_with_invalid(r"\\?\UNC\server-", r"\share"); + let mut expected = input.clone(); + for _ in 0..65 { + input.extend(r"\a".encode_utf16()); + expected.extend(r"\a".encode_utf16()); + } + for _ in 0..8 { + input.extend(r"\gone\..".encode_utf16()); + } + input.extend(r"\.\".encode_utf16()); + expected.extend(r"\".encode_utf16()); + + let source = path(&input); + assert_prefix_class("deep invalid verbatim UNC", &source, PrefixClass::VerbatimUnc); + assert!(source.to_str().is_none()); + assert!(source.as_os_str().len() <= 512); + + let borrowed = source.normalize().into_owned(); + let owned = source.into_normalized(); + for (api, normalized) in [("normalize", borrowed), ("into_normalized", owned)] { + assert_wide_case("deep invalid verbatim UNC", api, &normalized, &expected); + assert_wide_case( + "deep invalid verbatim UNC", + "idempotent normalize", + normalized.normalize().as_ref(), + &expected, + ); + } + } + #[test] fn relative_fallback_preserves_an_invalid_normal_component() { assert_relative_cases(&[ diff --git a/tests/owned_api.rs b/tests/owned_api.rs index 81f1a81..ee5e66f 100644 --- a/tests/owned_api.rs +++ b/tests/owned_api.rs @@ -54,16 +54,259 @@ fn into_normalized_reuses_current_directory_buffers_without_confusing_borrowed_d #[test] fn into_normalized_matches_borrowed_api_for_dirty_paths() { + // Dual algorithm: dirty into_normalized uses stack-arena reuse; borrowed + // normalize uses normalize_inner. Keep a broad corpus so the two stay aligned. #[cfg(target_family = "unix")] - let cases = ["foo/./bar/../baz", "../../foo/../bar", "foo//bar/"]; + let cases = [ + "foo/./bar/../baz", + "../../foo/../bar", + "foo//bar/", + "", + ".", + "./", + "foo/..", + "foo/../", + "a/b/../../c/./d//e/", + "../../../x", + "/a/./b/../c//", + "/../file", + "name/../name/../name/../out", + ]; #[cfg(target_family = "windows")] - let cases = [r"foo\.\bar\..\baz", r"..\..\foo\..\bar", r"foo\\bar\"]; + let cases = [ + r"foo\.\bar\..\baz", + r"..\..\foo\..\bar", + r"foo\\bar\", + "", + ".", + r".\", + r"foo\..", + r"foo\..\", + r"a\b\..\..\c\.\d\\e\", + r"..\..\..\x", + r"C:\a\.\b\..\c\\", + r"C:\..\file", + r"dir\..\C:foo", + r"dir\..\C:foo\..", + r"dir\..\C:foo\..\", + r"C:foo\.\bar", + r"\\?\c:\foo\..", + r"\\.\PIPE\foo\..", + r"\\?\UNC\server\share\foo\..", + r"\\?\Volume{abc}\foo\..", + r"name\..\name\..\name\..\out", + ]; for input in cases { - let path = owned_path_with_capacity(input); + let path = PathBuf::from(input); let expected = path.normalize().into_owned(); - assert_eq!(path.into_normalized().as_os_str(), expected.as_os_str(), "input {input:?}"); + assert_eq!(path.clone().into_normalized().as_os_str(), expected.as_os_str(), "input {input:?}"); + } +} + +#[cfg(target_family = "windows")] +#[test] +fn windows_owned_normalization_keeps_prefix_only_and_collapsed_relative_spellings() { + let cases = [ + (r"dir\..\C:foo\..", "."), + (r"dir\..\C:foo\..\", r".\"), + (r"c:foo\..", "c:."), + (r"c:foo\..\", r"c:.\"), + ]; + + for (input, expected) in cases { + let path = PathBuf::from(input); + assert_eq!(path.normalize().as_os_str(), Path::new(expected).as_os_str(), "input {input:?}"); + assert_eq!( + path.into_normalized().as_os_str(), + Path::new(expected).as_os_str(), + "input {input:?}", + ); + } +} + +fn dirty_path_with_depth(depth: usize) -> PathBuf { + let mut path = PathBuf::with_capacity(256); + for _ in 0..depth { + path.push("a"); + } + path.push("."); + path +} + +#[test] +fn into_normalized_reuses_the_component_and_byte_arena_boundaries() { + let path = dirty_path_with_depth(24); + let identity = buffer_identity(&path); + let expected = path.normalize().into_owned(); + let normalized = path.into_normalized(); + assert_eq!(normalized.as_os_str(), expected.as_os_str()); + assert_eq!(buffer_identity(&normalized), identity); + + let path = dirty_path_with_depth(25); + let expected = path.normalize().into_owned(); + assert_eq!(path.into_normalized().as_os_str(), expected.as_os_str()); + + let separator = std::path::MAIN_SEPARATOR; + let input = format!(".{separator}{}", "x".repeat(510)); + assert_eq!(input.len(), 512); + let path = PathBuf::from(input); + let identity = buffer_identity(&path); + let expected = path.normalize().into_owned(); + let normalized = path.into_normalized(); + assert_eq!(normalized.as_os_str(), expected.as_os_str()); + assert_eq!(buffer_identity(&normalized), identity); + + let input = format!(".{separator}{}", "x".repeat(511)); + assert_eq!(input.len(), 513); + let path = PathBuf::from(input); + let expected = path.normalize().into_owned(); + assert_eq!(path.into_normalized().as_os_str(), expected.as_os_str()); +} + +#[cfg(target_family = "windows")] +#[test] +fn into_normalized_reuses_long_unc_prefixes_without_a_temporary_heap_buffer() { + let server = "s".repeat(57); + let prefix = format!(r"\\{server}\share"); + assert_eq!(prefix.len(), 65); + let mut path = PathBuf::with_capacity(256); + path.push(&prefix); + for _ in 0..24 { + path.push("a"); + } + path.push("."); + assert_eq!(path.as_os_str().len(), 115); + let identity = buffer_identity(&path); + let expected = path.normalize().into_owned(); + let normalized = path.into_normalized(); + assert_eq!(normalized.as_os_str(), expected.as_os_str()); + assert_eq!(buffer_identity(&normalized), identity); + + let mut deep = PathBuf::from(prefix); + for _ in 0..25 { + deep.push("a"); } + deep.push("."); + assert_eq!(deep.as_os_str().len(), 117); + let expected = deep.normalize().into_owned(); + assert_eq!(deep.into_normalized().as_os_str(), expected.as_os_str()); +} + +#[test] +fn into_normalized_matches_borrowed_api_on_overflow_fallbacks() { + // Depth > 32 reaches the allocation-free deep replay in the non-Unix + // normalizer after the owned 24-component stack has already overflowed. + let mut deep = PathBuf::new(); + for i in 0..34 { + deep.push(format!("level-{i}")); + } + deep.push(".."); + deep.push(".."); + deep.push("tail"); + let mut expected = PathBuf::new(); + for i in 0..32 { + expected.push(format!("level-{i}")); + } + expected.push("tail"); + assert_eq!(deep.normalize().as_os_str(), expected.as_os_str()); + assert_eq!(deep.into_normalized().as_os_str(), expected.as_os_str()); + + // Encoded length > 512 forces the long-path → normalize_inner path. + let long_component = "x".repeat(280); + #[cfg(target_family = "unix")] + let long = format!("a/{long_component}/./b/{long_component}/../c/"); + #[cfg(target_family = "windows")] + let long = format!(r"a\{long_component}\.\b\{long_component}\..\c\"); + assert!(long.len() > 512, "fixture must exceed the owned-normalize stack arena"); + let path = PathBuf::from(&long); + let expected = path.normalize().into_owned(); + assert_eq!(path.clone().into_normalized().as_os_str(), expected.as_os_str()); +} + +#[cfg(not(unix))] +#[test] +fn native_normalization_stays_correct_beyond_the_bounded_replay() { + for depth in [64, 65] { + let mut boundary = PathBuf::new(); + let mut boundary_expected = PathBuf::new(); + for _ in 0..depth { + boundary.push("a"); + boundary_expected.push("a"); + } + boundary.push("."); + assert_eq!(boundary.normalize().as_os_str(), boundary_expected.as_os_str()); + assert_eq!(boundary.into_normalized().as_os_str(), boundary_expected.as_os_str()); + } + + let mut long_boundary = PathBuf::new(); + let mut long_boundary_expected = PathBuf::new(); + for _ in 0..33 { + long_boundary.push("aaaaaaaaaaaaaaaa"); + long_boundary_expected.push("aaaaaaaaaaaaaaaa"); + } + long_boundary.push("."); + assert!(long_boundary.as_os_str().len() > 512); + assert_eq!(long_boundary.normalize().as_os_str(), long_boundary_expected.as_os_str()); + assert_eq!(long_boundary.into_normalized().as_os_str(), long_boundary_expected.as_os_str()); + + let mut deep = PathBuf::new(); + let mut expected = PathBuf::new(); + for i in 0..128 { + let component = format!("level-{i}"); + deep.push(&component); + if i < 64 { + expected.push(component); + } + } + for _ in 0..64 { + deep.push(".."); + } + deep.push("tail"); + expected.push("tail"); + deep.push("."); + + assert_eq!(deep.normalize().as_os_str(), expected.as_os_str()); + assert_eq!(deep.into_normalized().as_os_str(), expected.as_os_str()); + + // Once the component stack spills, returning to exactly 32 live normals + // must not select the deep strategy and rescan the full input again. + let mut oscillating = PathBuf::new(); + let mut oscillating_expected = PathBuf::new(); + for i in 0..32 { + let component = format!("base-{i}"); + oscillating.push(&component); + oscillating_expected.push(component); + } + for _ in 0..256 { + oscillating.push("temporary"); + oscillating.push(".."); + } + oscillating.push("tail"); + oscillating_expected.push("tail"); + oscillating.push("."); + + assert_eq!(oscillating.normalize().as_os_str(), oscillating_expected.as_os_str()); + assert_eq!(oscillating.into_normalized().as_os_str(), oscillating_expected.as_os_str()); +} + +#[cfg(target_family = "windows")] +#[test] +fn windows_linear_spill_preserves_a_drive_like_first_survivor() { + let mut deep = PathBuf::new(); + for i in 0..65 { + deep.push(format!("level-{i}")); + } + for _ in 0..65 { + deep.push(".."); + } + // Append raw spelling so `C:foo` stays a Normal component in the source; + // `PathBuf::push("C:foo")` would replace the path with a drive-relative one. + deep.as_mut_os_string().push(r"\C:foo\.\"); + + let expected = Path::new(r".\C:foo\"); + assert_eq!(deep.normalize().as_os_str(), expected.as_os_str()); + assert_eq!(deep.into_normalized().as_os_str(), expected.as_os_str()); } #[test]