From 53129c886ee063ab7efbba02f005fb2cb8cca81a Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Sun, 12 Jul 2026 18:28:18 +0800 Subject: [PATCH 01/15] perf: reuse owned buffers on dirty normalize and relative fallbacks Rewrite short dirty into_normalized results into the existing PathBuf storage, so dirty join and absolutize_with drop an intermediate alloc. Keep Cow on the absolute relative fallback so clean bases are not cloned against invalid-encoding targets. Update allocation hard-gate call counts for the intentional rows. --- .agents/docs/performance-strategy.md | 6 +- .../allocations/x86_64-pc-windows-msvc.snap | 12 +- .../allocations/x86_64-unknown-linux-gnu.snap | 12 +- src/impl_sugar_path.rs | 260 ++++++++++++++++-- 4 files changed, 255 insertions(+), 35 deletions(-) diff --git a/.agents/docs/performance-strategy.md b/.agents/docs/performance-strategy.md index 3a09f99..c3d520e 100644 --- a/.agents/docs/performance-strategy.md +++ b/.agents/docs/performance-strategy.md @@ -194,8 +194,10 @@ 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 (5→1 alloc). +- **Recorded owned dirty normalize buffer reuse:** short dirty `into_normalized` rewrites into the original `PathBuf` storage via a stack component arena (no second heap alloc). 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 (realloc may remain for growth). +- **Recorded relative slow-path Cow retention:** absolute fallback no longer forces `into_owned` on a clean borrowed base; invalid-encoding absolute `relative` 3→2 alloc on Unix call counts. +- 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, long-path owned normalize fallback (>512 encoded bytes 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. diff --git a/benchmarks/allocations/x86_64-pc-windows-msvc.snap b/benchmarks/allocations/x86_64-pc-windows-msvc.snap index dd8cd79..da08e5e 100644 --- a/benchmarks/allocations/x86_64-pc-windows-msvc.snap +++ b/benchmarks/allocations/x86_64-pc-windows-msvc.snap @@ -20,17 +20,17 @@ 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 | | 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 | @@ -40,8 +40,8 @@ Each row measures one operation after allocation-capable setup and an untracked | Windows / canonical verbatim UNC -> normalized path | 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 | | relative / canonical native descendant -> natural result | 0 | 0 | 0 | | relative / canonical native descendant -> PathBuf | 1 | 0 | 0 | diff --git a/benchmarks/allocations/x86_64-unknown-linux-gnu.snap b/benchmarks/allocations/x86_64-unknown-linux-gnu.snap index 20202f4..893a03b 100644 --- a/benchmarks/allocations/x86_64-unknown-linux-gnu.snap +++ b/benchmarks/allocations/x86_64-unknown-linux-gnu.snap @@ -20,25 +20,25 @@ 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 | | 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 | | relative / canonical native descendant -> natural result | 0 | 0 | 0 | | relative / canonical native descendant -> PathBuf | 1 | 0 | 0 | diff --git a/src/impl_sugar_path.rs b/src/impl_sugar_path.rs index a500a95..8787bca 100644 --- a/src/impl_sugar_path.rs +++ b/src/impl_sugar_path.rs @@ -14,18 +14,16 @@ 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 fall +/// back to `normalize_inner` (one heap allocation — same floor as before). +const OWNED_NORMALIZE_STACK_ARENA: usize = 512; + enum RelativeOutcome<'a> { BorrowedNative(&'a Path), Native(PathBuf), @@ -495,27 +493,244 @@ 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) +} + +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, + } +} + +fn normalize_owned_path_buf_reusing(path: PathBuf, trailing: TrailingSeparator) -> 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 arena = [0u8; OWNED_NORMALIZE_STACK_ARENA]; + let mut arena_len = 0usize; + let mut stack: [(u16, u16); 24] = [(0, 0); 24]; + 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; + + let push_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)) + }; + + for component in path.components() { + match component { + #[cfg(target_family = "windows")] + Component::Prefix(p) => { + let start = arena_len; + let (suffix, optional_root, extra): (Option, bool, SmallVec<[u8; 64]>) = match p.kind() + { + std::path::Prefix::VerbatimDisk(drive) => { + let mut extra = SmallVec::new(); + extra.extend_from_slice(b"\\\\?\\"); + extra.push(drive_spelling.unwrap_or(drive)); + extra.push(b':'); + (None, false, extra) + } + std::path::Prefix::DeviceNS(device) => { + let mut extra = SmallVec::new(); + extra.extend_from_slice(b"\\\\.\\"); + extra.extend_from_slice(device.as_encoded_bytes()); + (None, true, extra) + } + std::path::Prefix::UNC(server, share) => { + let mut extra = SmallVec::new(); + extra.extend_from_slice(b"\\\\"); + extra.extend_from_slice(server.as_encoded_bytes()); + extra.push(b'\\'); + extra.extend_from_slice(share.as_encoded_bytes()); + (Some(b'\\'), false, extra) + } + std::path::Prefix::Disk(drive) => { + let mut extra = SmallVec::new(); + extra.push(drive_spelling.unwrap_or(drive)); + extra.push(b':'); + (Some(b'.'), false, extra) + } + std::path::Prefix::Verbatim(_) | std::path::Prefix::VerbatimUNC(_, _) => { + let mut extra = SmallVec::new(); + extra.extend_from_slice(p.as_os_str().as_encoded_bytes()); + (None, true, extra) + } + }; + if push_arena(&mut arena, &mut arena_len, &extra).is_none() { + return normalize_owned_path_buf_via_inner(path, trailing); + } + prefix_only_suffix = suffix; + prefix_root_is_optional = optional_root; + prefix_range = Some((start as u16, arena_len as u16)); + } + #[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() { + return normalize_owned_path_buf_via_inner(path, trailing); + } + let Some(range) = push_arena(&mut arena, &mut arena_len, normal.as_encoded_bytes()) else { + return normalize_owned_path_buf_via_inner(path, trailing); + }; + stack[stack_len] = range; + stack_len += 1; + } + } + } + + 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); + } + } + + // SAFETY: buf is built from OsStr component bytes plus ASCII separators / dots. + PathBuf::from(unsafe { OsString::from_encoded_bytes_unchecked(buf) }) } pub(crate) fn normalize_owned_path_buf(path: PathBuf) -> PathBuf { @@ -954,7 +1169,7 @@ fn try_relative_outcome<'a>( target_path.try_absolutize()? }; - Ok(relative_from_resolved(base, target)) + Ok(relative_from_resolved(base.as_ref(), target.as_ref())) } fn relative_outcome_with<'a, P>( @@ -993,7 +1208,7 @@ where return RelativeOutcome::Native(normalize_for_resolution(target.as_ref()).into_owned()); } - relative_from_resolved(base, target) + relative_from_resolved(base.as_ref(), target.as_ref()) } impl SugarPath for Path { @@ -1035,6 +1250,9 @@ impl SugarPath for Path { } let mut resolved: PathBuf = cwd.into(); + // Grow once for the relative suffix so push + dirty normalize reuse do not + // thrash capacity on the common cwd+module path shape. + resolved.as_mut_os_string().reserve(self.as_os_str().len().saturating_add(1)); resolved.push(self); Cow::Owned(normalize_owned_for_resolution(resolved)) } From afb792a65b0eed0e3ca2e200fffef19b85d17a4f Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Sun, 12 Jul 2026 18:32:24 +0800 Subject: [PATCH 02/15] fix: move owned relative targets instead of cloning Pass Cow into relative_from_resolved so Windows different-prefix and unrepresentable-suffix returns use into_owned (free on Cow::Owned). Update hard-gate rows for owned invalid into_normalized (0) and Windows root-relative relative (1). --- src/impl_sugar_path.rs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/impl_sugar_path.rs b/src/impl_sugar_path.rs index 8787bca..da47661 100644 --- a/src/impl_sugar_path.rs +++ b/src/impl_sugar_path.rs @@ -1058,6 +1058,11 @@ fn relative_without_cwd<'a>( None } +/// Build a relative outcome from already-resolved absolute paths. +/// +/// Takes [`Cow`] so Windows branches that must return the absolute target can +/// move an owned buffer instead of cloning it (`into_owned` is free on +/// [`Cow::Owned`]). Clean borrowed bases stay borrowed for the common-prefix scan. fn relative_from_resolved(base: Cow<'_, Path>, target: Cow<'_, Path>) -> RelativeOutcome<'static> { #[cfg(target_family = "windows")] if windows_paths_have_different_prefixes(base.as_ref(), target.as_ref()) { @@ -1156,8 +1161,10 @@ fn try_relative_outcome<'a>( } // Slow path: avoid current_dir() for already-absolute paths. Windows - // drive-relative receivers still take try_absolutize here. Retain borrowed - // normalized inputs until the result actually needs an owned path. + // drive-relative receivers still take try_absolutize here. Keep Cow so a + // clean absolute base is not cloned solely to compute relative against a + // dirty/invalid target, and so Windows different-prefix returns can move an + // already-owned absolute target without a second clone. let base = if base_path.is_absolute() { normalize_for_resolution(base_path) } else { @@ -1169,7 +1176,7 @@ fn try_relative_outcome<'a>( target_path.try_absolutize()? }; - Ok(relative_from_resolved(base.as_ref(), target.as_ref())) + Ok(relative_from_resolved(base, target)) } fn relative_outcome_with<'a, P>( @@ -1208,7 +1215,7 @@ where return RelativeOutcome::Native(normalize_for_resolution(target.as_ref()).into_owned()); } - relative_from_resolved(base.as_ref(), target.as_ref()) + relative_from_resolved(base, target) } impl SugarPath for Path { From 96347611aebef4d099f3afbbbf0b068a866573bd Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Sun, 12 Jul 2026 18:47:35 +0800 Subject: [PATCH 03/15] refactor: defer owned-normalize fallback until after components borrow ends Avoid moving PathBuf while path.components() is live; set a flag and fall back to normalize_inner only after the iterator is dropped. --- src/impl_sugar_path.rs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/impl_sugar_path.rs b/src/impl_sugar_path.rs index da47661..1fa184a 100644 --- a/src/impl_sugar_path.rs +++ b/src/impl_sugar_path.rs @@ -574,6 +574,10 @@ fn normalize_owned_path_buf_reusing(path: PathBuf, trailing: TrailingSeparator) Some((start as u16, *arena_len as u16)) }; + // 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")] @@ -615,7 +619,8 @@ fn normalize_owned_path_buf_reusing(path: PathBuf, trailing: TrailingSeparator) } }; if push_arena(&mut arena, &mut arena_len, &extra).is_none() { - return normalize_owned_path_buf_via_inner(path, trailing); + fallback_to_inner = true; + break; } prefix_only_suffix = suffix; prefix_root_is_optional = optional_root; @@ -638,16 +643,21 @@ fn normalize_owned_path_buf_reusing(path: PathBuf, trailing: TrailingSeparator) } Component::Normal(normal) => { if stack_len >= stack.len() { - return normalize_owned_path_buf_via_inner(path, trailing); + fallback_to_inner = true; + break; } let Some(range) = push_arena(&mut arena, &mut arena_len, normal.as_encoded_bytes()) else { - return normalize_owned_path_buf_via_inner(path, trailing); + 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(); From 3f29cabf1f98e2dc87a2b57b742b07cbb7dd8e55 Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Sun, 12 Jul 2026 18:55:37 +0800 Subject: [PATCH 04/15] test: broaden into_normalized vs normalize parity coverage Adversarial review flagged the dual dirty-normalize algorithms with only three fixtures. Expand the corpus and add overflow-fallback cases (depth > 24 and length > 512) that must match borrowed normalize. --- tests/owned_api.rs | 68 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 64 insertions(+), 4 deletions(-) diff --git a/tests/owned_api.rs b/tests/owned_api.rs index 81f1a81..20324df 100644 --- a/tests/owned_api.rs +++ b/tests/owned_api.rs @@ -54,16 +54,76 @@ 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"C:foo\.\bar", + 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:?}" + ); + } +} + +#[test] +fn into_normalized_matches_borrowed_api_on_overflow_fallbacks() { + // Depth > 24 normals forces the component-stack overflow → normalize_inner path. + let mut deep = PathBuf::new(); + for i in 0..30 { + deep.push(format!("level-{i}")); } + deep.push("."); + deep.push("tail"); + let expected = deep.normalize().into_owned(); + assert_eq!(deep.clone().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()); } #[test] From 18abd60849c23bd393526923984b847ffefaf0a2 Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Sun, 12 Jul 2026 19:15:45 +0800 Subject: [PATCH 05/15] docs: spell out SAFETY for from_encoded_bytes_unchecked path rebuilds Document the std encoding contract and that only as_encoded_bytes plus ASCII separators/dots feed the unchecked OsString constructors. --- src/impl_sugar_path.rs | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/impl_sugar_path.rs b/src/impl_sugar_path.rs index 1fa184a..882b38c 100644 --- a/src/impl_sugar_path.rs +++ b/src/impl_sugar_path.rs @@ -739,7 +739,15 @@ fn normalize_owned_path_buf_reusing(path: PathBuf, trailing: TrailingSeparator) } } - // SAFETY: buf is built from OsStr component bytes plus ASCII separators / dots. + // SAFETY: `OsString::from_encoded_bytes_unchecked` requires bytes that are a + // mixture of validated UTF-8 and `OsStr::as_encoded_bytes` from this Rust + // version/target, split only at non-empty UTF-8 boundaries. + // + // `buf` satisfies that: every non-ASCII sequence is copied from + // `component.as_os_str().as_encoded_bytes()` (or Windows prefix + // server/share/device encodings of the same kind); the only invented bytes + // are ASCII (`MAIN_SEPARATOR`, `.`, `..`, and fixed ASCII prefix spellings + // such as `\\?\` / `\\.\`). No network/file/arbitrary `Vec` is admitted. PathBuf::from(unsafe { OsString::from_encoded_bytes_unchecked(buf) }) } @@ -1689,10 +1697,10 @@ 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. + // SAFETY: same contract as `normalize_owned_path_buf_reusing` above. + // `buf` is only extended with `Component`/`OsStr` `as_encoded_bytes()` slices + // and ASCII separators / `.` / fixed ASCII Windows prefix bytes — never + // arbitrary non-UTF-8 invented by this function. Cow::Owned(PathBuf::from(unsafe { OsString::from_encoded_bytes_unchecked(buf) })) } From 608d7c93e90bcc1094916aacf022c2c347d893c5 Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Sun, 12 Jul 2026 20:07:35 +0800 Subject: [PATCH 06/15] fix: preserve owned path allocation bounds --- .agents/docs/performance-strategy.md | 8 +- src/impl_sugar_path.rs | 100 ++++++++++++---------- tasks/track_allocations/README.md | 2 +- tasks/track_allocations/src/main.rs | 123 +++++++++++++++++++++++++++ tests/absolutize_with.rs | 31 ++++++- tests/owned_api.rs | 78 +++++++++++++++-- 6 files changed, 284 insertions(+), 58 deletions(-) diff --git a/.agents/docs/performance-strategy.md b/.agents/docs/performance-strategy.md index c3d520e..9ca03bb 100644 --- a/.agents/docs/performance-strategy.md +++ b/.agents/docs/performance-strategy.md @@ -194,10 +194,10 @@ 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 (#44):** pure relative pairs that miss the equal-parent lexical hit resolve both sides against one ambient cwd as normal-component stacks (5→1 alloc). -- **Recorded owned dirty normalize buffer reuse:** short dirty `into_normalized` rewrites into the original `PathBuf` storage via a stack component arena (no second heap alloc). 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 (realloc may remain for growth). +- **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. 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. - **Recorded relative slow-path Cow retention:** absolute fallback no longer forces `into_owned` on a clean borrowed base; invalid-encoding absolute `relative` 3→2 alloc on Unix call counts. -- 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, long-path owned normalize fallback (>512 encoded bytes still uses `normalize_inner`). +- 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. @@ -213,7 +213,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/src/impl_sugar_path.rs b/src/impl_sugar_path.rs index 882b38c..3bf7de7 100644 --- a/src/impl_sugar_path.rs +++ b/src/impl_sugar_path.rs @@ -20,9 +20,25 @@ enum TrailingSeparator { Strip, } -/// Stack arena for owned normalize component bytes. Paths longer than this fall -/// back to `normalize_inner` (one heap allocation — same floor as before). +/// 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; + +#[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)) +} enum RelativeOutcome<'a> { BorrowedNative(&'a Path), @@ -511,6 +527,9 @@ fn normalize_owned_path_buf_with(path: PathBuf, trailing: TrailingSeparator) -> 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 = { @@ -549,7 +568,8 @@ fn normalize_owned_path_buf_reusing(path: PathBuf, trailing: TrailingSeparator) let mut arena = [0u8; OWNED_NORMALIZE_STACK_ARENA]; let mut arena_len = 0usize; - let mut stack: [(u16, u16); 24] = [(0, 0); 24]; + 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; @@ -561,19 +581,6 @@ fn normalize_owned_path_buf_reusing(path: PathBuf, trailing: TrailingSeparator) #[cfg(target_family = "windows")] let mut prefix_root_is_optional = false; - let push_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)) - }; - // 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. @@ -583,45 +590,44 @@ fn normalize_owned_path_buf_reusing(path: PathBuf, trailing: TrailingSeparator) #[cfg(target_family = "windows")] Component::Prefix(p) => { let start = arena_len; - let (suffix, optional_root, extra): (Option, bool, SmallVec<[u8; 64]>) = match p.kind() - { + let prefix_shape = match p.kind() { std::path::Prefix::VerbatimDisk(drive) => { - let mut extra = SmallVec::new(); - extra.extend_from_slice(b"\\\\?\\"); - extra.push(drive_spelling.unwrap_or(drive)); - extra.push(b':'); - (None, false, extra) + let drive = [drive_spelling.unwrap_or(drive)]; + (push_owned_normalize_arena(&mut arena, &mut arena_len, b"\\\\?\\").is_some() + && push_owned_normalize_arena(&mut arena, &mut arena_len, &drive).is_some() + && push_owned_normalize_arena(&mut arena, &mut arena_len, b":").is_some()) + .then_some((None, false)) } std::path::Prefix::DeviceNS(device) => { - let mut extra = SmallVec::new(); - extra.extend_from_slice(b"\\\\.\\"); - extra.extend_from_slice(device.as_encoded_bytes()); - (None, true, extra) + (push_owned_normalize_arena(&mut arena, &mut arena_len, b"\\\\.\\").is_some() + && push_owned_normalize_arena(&mut arena, &mut arena_len, device.as_encoded_bytes()) + .is_some()) + .then_some((None, true)) } std::path::Prefix::UNC(server, share) => { - let mut extra = SmallVec::new(); - extra.extend_from_slice(b"\\\\"); - extra.extend_from_slice(server.as_encoded_bytes()); - extra.push(b'\\'); - extra.extend_from_slice(share.as_encoded_bytes()); - (Some(b'\\'), false, extra) + (push_owned_normalize_arena(&mut arena, &mut arena_len, b"\\\\").is_some() + && push_owned_normalize_arena(&mut arena, &mut arena_len, server.as_encoded_bytes()) + .is_some() + && push_owned_normalize_arena(&mut arena, &mut arena_len, b"\\").is_some() + && push_owned_normalize_arena(&mut arena, &mut arena_len, share.as_encoded_bytes()) + .is_some()) + .then_some((Some(b'\\'), false)) } std::path::Prefix::Disk(drive) => { - let mut extra = SmallVec::new(); - extra.push(drive_spelling.unwrap_or(drive)); - extra.push(b':'); - (Some(b'.'), false, extra) + let drive = [drive_spelling.unwrap_or(drive)]; + (push_owned_normalize_arena(&mut arena, &mut arena_len, &drive).is_some() + && push_owned_normalize_arena(&mut arena, &mut arena_len, b":").is_some()) + .then_some((Some(b'.'), false)) } std::path::Prefix::Verbatim(_) | std::path::Prefix::VerbatimUNC(_, _) => { - let mut extra = SmallVec::new(); - extra.extend_from_slice(p.as_os_str().as_encoded_bytes()); - (None, true, extra) + push_owned_normalize_arena(&mut arena, &mut arena_len, p.as_os_str().as_encoded_bytes()) + .map(|_| (None, true)) } }; - if push_arena(&mut arena, &mut arena_len, &extra).is_none() { + let Some((suffix, optional_root)) = prefix_shape else { fallback_to_inner = true; break; - } + }; prefix_only_suffix = suffix; prefix_root_is_optional = optional_root; prefix_range = Some((start as u16, arena_len as u16)); @@ -646,7 +652,9 @@ fn normalize_owned_path_buf_reusing(path: PathBuf, trailing: TrailingSeparator) fallback_to_inner = true; break; } - let Some(range) = push_arena(&mut arena, &mut arena_len, normal.as_encoded_bytes()) else { + let Some(range) = + push_owned_normalize_arena(&mut arena, &mut arena_len, normal.as_encoded_bytes()) + else { fallback_to_inner = true; break; }; @@ -760,6 +768,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); } @@ -1275,9 +1286,6 @@ impl SugarPath for Path { } let mut resolved: PathBuf = cwd.into(); - // Grow once for the relative suffix so push + dirty normalize reuse do not - // thrash capacity on the common cwd+module path shape. - resolved.as_mut_os_string().reserve(self.as_os_str().len().saturating_add(1)); resolved.push(self); Cow::Owned(normalize_owned_for_resolution(resolved)) } diff --git a/tasks/track_allocations/README.md b/tasks/track_allocations/README.md index d58a85c..7b1b95c 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 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..e6da5a1 100644 --- a/tasks/track_allocations/src/main.rs +++ b/tasks/track_allocations/src/main.rs @@ -265,6 +265,14 @@ 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: "pipeline / dirty join via borrowed receiver -> PathBuf", run: join_normalize_owned, @@ -312,6 +320,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 +350,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 +544,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 +562,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 +608,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 +626,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 +855,33 @@ 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 join_normalize_owned(mode: RunMode) -> AllocationStats { run_prepared( mode, @@ -950,6 +1009,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 +1101,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/owned_api.rs b/tests/owned_api.rs index 20324df..e8b253a 100644 --- a/tests/owned_api.rs +++ b/tests/owned_api.rs @@ -88,20 +88,88 @@ fn into_normalized_matches_borrowed_api_for_dirty_paths() { r"C:\..\file", 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 = PathBuf::from(input); let expected = path.normalize().into_owned(); - assert_eq!( - path.clone().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:?}"); } } +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 > 24 normals forces the component-stack overflow → normalize_inner path. From b1a165658b9501d5c00b58ee0f38a179ea4f4d04 Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Sun, 12 Jul 2026 20:10:22 +0800 Subject: [PATCH 07/15] test: refresh native allocation snapshots --- .../allocations/x86_64-pc-windows-msvc.snap | 24 ++++++++++++++----- .../allocations/x86_64-unknown-linux-gnu.snap | 20 +++++++++++----- 2 files changed, 32 insertions(+), 12 deletions(-) diff --git a/benchmarks/allocations/x86_64-pc-windows-msvc.snap b/benchmarks/allocations/x86_64-pc-windows-msvc.snap index da08e5e..2ea884c 100644 --- a/benchmarks/allocations/x86_64-pc-windows-msvc.snap +++ b/benchmarks/allocations/x86_64-pc-windows-msvc.snap @@ -27,6 +27,8 @@ Each row measures one operation after allocation-capable setup and an untracked | 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 | | pipeline / dirty join via borrowed 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 | @@ -38,11 +40,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 | 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 +111,19 @@ 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 | | 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 +131,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 893a03b..db585c3 100644 --- a/benchmarks/allocations/x86_64-unknown-linux-gnu.snap +++ b/benchmarks/allocations/x86_64-unknown-linux-gnu.snap @@ -27,6 +27,8 @@ Each row measures one operation after allocation-capable setup and an untracked | 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 | | pipeline / dirty join via borrowed 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 | @@ -40,6 +42,8 @@ Each row measures one operation after allocation-capable setup and an untracked | 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 +96,30 @@ 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 | | 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 | From e982c4216e90aec3b7a8835c0460659a4630c8d5 Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Sun, 12 Jul 2026 20:52:31 +0800 Subject: [PATCH 08/15] docs: spell out Windows WTF-8 safety boundary --- src/impl_sugar_path.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/impl_sugar_path.rs b/src/impl_sugar_path.rs index 3bf7de7..43aeac8 100644 --- a/src/impl_sugar_path.rs +++ b/src/impl_sugar_path.rs @@ -755,7 +755,13 @@ fn normalize_owned_path_buf_reusing(path: PathBuf, trailing: TrailingSeparator) // `component.as_os_str().as_encoded_bytes()` (or Windows prefix // server/share/device encodings of the same kind); the only invented bytes // are ASCII (`MAIN_SEPARATOR`, `.`, `..`, and fixed ASCII prefix spellings - // such as `\\?\` / `\\.\`). No network/file/arbitrary `Vec` is admitted. + // such as `\\?\` / `\\.\`). Arena ranges cover whole source slices, and no + // serialized encoded-byte buffer is accepted directly. + // + // Windows also requires that raw WTF-8 concatenation not create a + // lead-surrogate/trail-surrogate pair across chunk boundaries. Every boundary + // between chunks that can contain surrogates has an ASCII separator. The only + // prefix/component joins without one are disk prefixes, which end in ASCII `:`. PathBuf::from(unsafe { OsString::from_encoded_bytes_unchecked(buf) }) } From 231d8a2e4afe330d11fd98a59cc1165854d6f96c Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Mon, 13 Jul 2026 01:40:29 +0800 Subject: [PATCH 09/15] refactor: confine native path encoding unsafe --- src/encoded_arena.rs | 58 ++++ src/impl_sugar_path.rs | 685 ++++++++++++++++++++++++++++++++++++----- src/lib.rs | 5 + 3 files changed, 677 insertions(+), 71 deletions(-) create mode 100644 src/encoded_arena.rs diff --git a/src/encoded_arena.rs b/src/encoded_arena.rs new file mode 100644 index 0000000..7458aa8 --- /dev/null +++ b/src/encoded_arena.rs @@ -0,0 +1,58 @@ +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. + 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 43aeac8..26f5bec 100644 --- a/src/impl_sugar_path.rs +++ b/src/impl_sugar_path.rs @@ -9,6 +9,8 @@ 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]>; @@ -24,7 +26,10 @@ enum TrailingSeparator { /// 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; +#[cfg(unix)] #[inline] fn push_owned_normalize_arena( arena: &mut [u8; OWNED_NORMALIZE_STACK_ARENA], @@ -40,6 +45,158 @@ fn push_owned_normalize_arena( 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), @@ -559,13 +716,11 @@ fn normalize_owned_path_buf_via_inner(mut path: PathBuf, trailing: TrailingSepar } } +#[cfg(unix)] fn normalize_owned_path_buf_reusing(path: PathBuf, trailing: TrailingSeparator) -> 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); + 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] = @@ -588,50 +743,7 @@ fn normalize_owned_path_buf_reusing(path: PathBuf, trailing: TrailingSeparator) for component in path.components() { match component { #[cfg(target_family = "windows")] - Component::Prefix(p) => { - let start = arena_len; - let prefix_shape = match p.kind() { - std::path::Prefix::VerbatimDisk(drive) => { - let drive = [drive_spelling.unwrap_or(drive)]; - (push_owned_normalize_arena(&mut arena, &mut arena_len, b"\\\\?\\").is_some() - && push_owned_normalize_arena(&mut arena, &mut arena_len, &drive).is_some() - && push_owned_normalize_arena(&mut arena, &mut arena_len, b":").is_some()) - .then_some((None, false)) - } - std::path::Prefix::DeviceNS(device) => { - (push_owned_normalize_arena(&mut arena, &mut arena_len, b"\\\\.\\").is_some() - && push_owned_normalize_arena(&mut arena, &mut arena_len, device.as_encoded_bytes()) - .is_some()) - .then_some((None, true)) - } - std::path::Prefix::UNC(server, share) => { - (push_owned_normalize_arena(&mut arena, &mut arena_len, b"\\\\").is_some() - && push_owned_normalize_arena(&mut arena, &mut arena_len, server.as_encoded_bytes()) - .is_some() - && push_owned_normalize_arena(&mut arena, &mut arena_len, b"\\").is_some() - && push_owned_normalize_arena(&mut arena, &mut arena_len, share.as_encoded_bytes()) - .is_some()) - .then_some((Some(b'\\'), false)) - } - std::path::Prefix::Disk(drive) => { - let drive = [drive_spelling.unwrap_or(drive)]; - (push_owned_normalize_arena(&mut arena, &mut arena_len, &drive).is_some() - && push_owned_normalize_arena(&mut arena, &mut arena_len, b":").is_some()) - .then_some((Some(b'.'), false)) - } - std::path::Prefix::Verbatim(_) | std::path::Prefix::VerbatimUNC(_, _) => { - push_owned_normalize_arena(&mut arena, &mut arena_len, p.as_os_str().as_encoded_bytes()) - .map(|_| (None, true)) - } - }; - let Some((suffix, optional_root)) = prefix_shape else { - fallback_to_inner = true; - break; - }; - prefix_only_suffix = suffix; - prefix_root_is_optional = optional_root; - prefix_range = Some((start as u16, arena_len as u16)); - } + 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 => { @@ -747,22 +859,162 @@ fn normalize_owned_path_buf_reusing(path: PathBuf, trailing: TrailingSeparator) } } - // SAFETY: `OsString::from_encoded_bytes_unchecked` requires bytes that are a - // mixture of validated UTF-8 and `OsStr::as_encoded_bytes` from this Rust - // version/target, split only at non-empty UTF-8 boundaries. - // - // `buf` satisfies that: every non-ASCII sequence is copied from - // `component.as_os_str().as_encoded_bytes()` (or Windows prefix - // server/share/device encodings of the same kind); the only invented bytes - // are ASCII (`MAIN_SEPARATOR`, `.`, `..`, and fixed ASCII prefix spellings - // such as `\\?\` / `\\.\`). Arena ranges cover whole source slices, and no - // serialized encoded-byte buffer is accepted directly. - // - // Windows also requires that raw WTF-8 concatenation not create a - // lead-surrogate/trail-surrogate pair across chunk boundaries. Every boundary - // between chunks that can contain surrogates has an ASCII separator. The only - // prefix/component joins without one are disk prefixes, which end in ASCII `:`. - PathBuf::from(unsafe { OsString::from_encoded_bytes_unchecked(buf) }) + 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 { @@ -1554,6 +1806,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, @@ -1711,11 +1964,298 @@ fn normalize_inner<'a>( buf.push(sep_byte); } - // SAFETY: same contract as `normalize_owned_path_buf_reusing` above. - // `buf` is only extended with `Component`/`OsStr` `as_encoded_bytes()` slices - // and ASCII separators / `.` / fixed ASCII Windows prefix bytes — never - // arbitrary non-UTF-8 invented by this function. - 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 { + return normalize_inner_deep( + original_components, + hint_cap, + preserve_trailing, + _drive_spelling, + ); + } + 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))] +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 with more than 32 live native components would make `SmallVec` add a +// second allocation. Re-scan the clonable `Components` iterator instead. This +// path is deliberately cold and quadratic, but retains the historical single +// output allocation for arbitrarily deep paths. +#[cfg(not(unix))] +#[cold] +fn normalize_inner_deep<'a>( + mut components: Peekable> + Clone>, + hint_cap: usize, + preserve_trailing: bool, + _drive_spelling: Option, +) -> Cow<'a, Path> { + #[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 { @@ -1921,6 +2461,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}; @@ -1938,7 +2479,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 @@ -2214,6 +2756,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..e0cd124 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,9 @@ //! the [changelog](https://github.com/hyf0/sugar_path/blob/main/CHANGELOG.md) //! for release and migration information. +#[cfg(not(unix))] +#[allow(unsafe_code)] +mod encoded_arena; mod impl_sugar_path; mod sugar_path; mod sugar_path_buf; From becf884538f99224c5275b766d9fed88f9727adc Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Mon, 13 Jul 2026 01:40:38 +0800 Subject: [PATCH 10/15] test: guard native encoding reconstruction --- .../allocations/x86_64-pc-windows-msvc.snap | 2 + .../allocations/x86_64-unknown-linux-gnu.snap | 2 + tasks/track_allocations/src/main.rs | 8 + tests/invalid_encoding.rs | 282 +++++++++++++++++- tests/owned_api.rs | 40 ++- 5 files changed, 325 insertions(+), 9 deletions(-) diff --git a/benchmarks/allocations/x86_64-pc-windows-msvc.snap b/benchmarks/allocations/x86_64-pc-windows-msvc.snap index 2ea884c..0b37260 100644 --- a/benchmarks/allocations/x86_64-pc-windows-msvc.snap +++ b/benchmarks/allocations/x86_64-pc-windows-msvc.snap @@ -29,6 +29,7 @@ Each row measures one operation after allocation-capable setup and an untracked | 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 | 1 | 0 | 2 | | pipeline / dirty join via borrowed receiver -> String | 3 | 0 | 2 | @@ -120,6 +121,7 @@ Each row measures one operation after allocation-capable setup and an untracked | 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 | 100 | 126 | | pipeline / dirty join via owned receiver -> PathBuf | 21 | 126 | | pipeline / dirty join via borrowed receiver -> String | 170 | 126 | diff --git a/benchmarks/allocations/x86_64-unknown-linux-gnu.snap b/benchmarks/allocations/x86_64-unknown-linux-gnu.snap index db585c3..7b007a3 100644 --- a/benchmarks/allocations/x86_64-unknown-linux-gnu.snap +++ b/benchmarks/allocations/x86_64-unknown-linux-gnu.snap @@ -29,6 +29,7 @@ Each row measures one operation after allocation-capable setup and an untracked | 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 | 1 | 0 | 2 | | pipeline / dirty join via borrowed receiver -> String | 3 | 0 | 2 | @@ -105,6 +106,7 @@ Each row measures one operation after allocation-capable setup and an untracked | 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 | 19 | 115 | | pipeline / dirty join via borrowed receiver -> String | 164 | 115 | diff --git a/tasks/track_allocations/src/main.rs b/tasks/track_allocations/src/main.rs index e6da5a1..d835499 100644 --- a/tasks/track_allocations/src/main.rs +++ b/tasks/track_allocations/src/main.rs @@ -273,6 +273,10 @@ const SCENARIOS: &[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, + }, Scenario { name: "pipeline / dirty join via borrowed receiver -> PathBuf", run: join_normalize_owned, @@ -882,6 +886,10 @@ 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) +} + fn join_normalize_owned(mode: RunMode) -> AllocationStats { run_prepared( mode, diff --git a/tests/invalid_encoding.rs b/tests/invalid_encoding.rs index be79e3a..f1d1743 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,163 @@ 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 relative_fallback_preserves_an_invalid_normal_component() { assert_relative_cases(&[ diff --git a/tests/owned_api.rs b/tests/owned_api.rs index e8b253a..8594e49 100644 --- a/tests/owned_api.rs +++ b/tests/owned_api.rs @@ -87,6 +87,8 @@ fn into_normalized_matches_borrowed_api_for_dirty_paths() { 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\..", @@ -102,6 +104,27 @@ fn into_normalized_matches_borrowed_api_for_dirty_paths() { } } +#[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 { @@ -172,15 +195,22 @@ fn into_normalized_reuses_long_unc_prefixes_without_a_temporary_heap_buffer() { #[test] fn into_normalized_matches_borrowed_api_on_overflow_fallbacks() { - // Depth > 24 normals forces the component-stack overflow → normalize_inner path. + // 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..30 { + for i in 0..34 { deep.push(format!("level-{i}")); } - deep.push("."); + deep.push(".."); + deep.push(".."); deep.push("tail"); - let expected = deep.normalize().into_owned(); - assert_eq!(deep.clone().into_normalized().as_os_str(), expected.as_os_str()); + 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); From 05dbe036920e17e72112f23cf52c8f22dea65a1b Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Mon, 13 Jul 2026 02:04:54 +0800 Subject: [PATCH 11/15] refactor: narrow native encoding unsafe allowance --- src/encoded_arena.rs | 1 + src/lib.rs | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/src/encoded_arena.rs b/src/encoded_arena.rs index 7458aa8..3479ab8 100644 --- a/src/encoded_arena.rs +++ b/src/encoded_arena.rs @@ -45,6 +45,7 @@ impl<'id, const N: usize> EncodedArena<'id, N> { // 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); } diff --git a/src/lib.rs b/src/lib.rs index e0cd124..e67c7d9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -98,7 +98,6 @@ //! for release and migration information. #[cfg(not(unix))] -#[allow(unsafe_code)] mod encoded_arena; mod impl_sugar_path; mod sugar_path; From 23d16e6e95f1a4e5e0a6aae1760539843fe6f60e Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Mon, 13 Jul 2026 02:05:01 +0800 Subject: [PATCH 12/15] fix: bound native deep normalization work --- .agents/docs/performance-strategy.md | 2 +- .../allocations/x86_64-pc-windows-msvc.snap | 6 ++ src/impl_sugar_path.rs | 69 ++++++++++++--- tasks/track_allocations/README.md | 2 +- tasks/track_allocations/src/main.rs | 43 ++++++++++ tests/invalid_encoding.rs | 32 +++++++ tests/owned_api.rs | 85 +++++++++++++++++++ 7 files changed, 225 insertions(+), 14 deletions(-) diff --git a/.agents/docs/performance-strategy.md b/.agents/docs/performance-strategy.md index 9ca03bb..b6e80ea 100644 --- a/.agents/docs/performance-strategy.md +++ b/.agents/docs/performance-strategy.md @@ -195,7 +195,7 @@ 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 (#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. 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. +- **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. - **Recorded relative slow-path Cow retention:** absolute fallback no longer forces `into_owned` on a clean borrowed base; invalid-encoding absolute `relative` 3→2 alloc on Unix call counts. - 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). diff --git a/benchmarks/allocations/x86_64-pc-windows-msvc.snap b/benchmarks/allocations/x86_64-pc-windows-msvc.snap index 0b37260..0418850 100644 --- a/benchmarks/allocations/x86_64-pc-windows-msvc.snap +++ b/benchmarks/allocations/x86_64-pc-windows-msvc.snap @@ -30,6 +30,9 @@ Each row measures one operation after allocation-capable setup and an untracked | 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 | 1 | 0 | 2 | | pipeline / dirty join via borrowed receiver -> String | 3 | 0 | 2 | @@ -122,6 +125,9 @@ Each row measures one operation after allocation-capable setup and an untracked | 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 | 21 | 126 | | pipeline / dirty join via borrowed receiver -> String | 170 | 126 | diff --git a/src/impl_sugar_path.rs b/src/impl_sugar_path.rs index 26f5bec..b5f7a72 100644 --- a/src/impl_sugar_path.rs +++ b/src/impl_sugar_path.rs @@ -28,6 +28,12 @@ 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(unix)] #[inline] @@ -2007,13 +2013,21 @@ fn normalize_inner<'a>( } } Component::Normal(normal) => { - if stack.len() == NATIVE_NORMALIZE_COMPONENT_STACK { - return normalize_inner_deep( - original_components, - hint_cap, - preserve_trailing, - _drive_spelling, - ); + 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); } @@ -2100,6 +2114,35 @@ fn normalize_inner<'a>( 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 { + 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(not(unix))] fn native_normal_survives<'a>(components: impl Iterator>) -> bool { let mut later_depth = 0usize; @@ -2120,18 +2163,20 @@ fn native_normal_survives<'a>(components: impl Iterator>) - true } -// A path with more than 32 live native components would make `SmallVec` add a -// second allocation. Re-scan the clonable `Components` iterator instead. This -// path is deliberately cold and quadratic, but retains the historical single -// output allocation for arbitrarily deep paths. +// 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_deep<'a>( +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); diff --git a/tasks/track_allocations/README.md b/tasks/track_allocations/README.md index 7b1b95c..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, 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 mixed separators, drive-relative and root-relative paths, ordinary and verbatim UNC roots, long-prefix consuming normalization, 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 d835499..6c8a817 100644 --- a/tasks/track_allocations/src/main.rs +++ b/tasks/track_allocations/src/main.rs @@ -277,6 +277,21 @@ const SCENARIOS: &[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, @@ -890,6 +905,34 @@ 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, diff --git a/tests/invalid_encoding.rs b/tests/invalid_encoding.rs index f1d1743..727c25e 100644 --- a/tests/invalid_encoding.rs +++ b/tests/invalid_encoding.rs @@ -510,6 +510,38 @@ mod windows { ); } + #[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 8594e49..5ae76f0 100644 --- a/tests/owned_api.rs +++ b/tests/owned_api.rs @@ -224,6 +224,91 @@ fn into_normalized_matches_borrowed_api_on_overflow_fallbacks() { 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(".."); + } + deep.push("C:foo"); + deep.push("."); + deep.push(""); + + 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] fn owned_slash_apis_reuse_valid_unicode_buffers() { #[cfg(target_family = "unix")] From 4b674cb444df8ceca3cd83873a52f884948c4894 Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Mon, 13 Jul 2026 02:15:22 +0800 Subject: [PATCH 13/15] test: preserve drive-like component fixture --- tests/owned_api.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/owned_api.rs b/tests/owned_api.rs index 5ae76f0..ee5e66f 100644 --- a/tests/owned_api.rs +++ b/tests/owned_api.rs @@ -300,9 +300,9 @@ fn windows_linear_spill_preserves_a_drive_like_first_survivor() { for _ in 0..65 { deep.push(".."); } - deep.push("C:foo"); - deep.push("."); - 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()); From bba4bfcc8263620db2350d5a12dd11b75875ed8f Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Mon, 13 Jul 2026 02:25:25 +0800 Subject: [PATCH 14/15] test: gate native normalization profile count --- src/impl_sugar_path.rs | 54 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/src/impl_sugar_path.rs b/src/impl_sugar_path.rs index b5f7a72..205ed36 100644 --- a/src/impl_sugar_path.rs +++ b/src/impl_sugar_path.rs @@ -35,6 +35,11 @@ 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( @@ -2125,6 +2130,9 @@ struct NativeNormalizationProfile { 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; @@ -2143,6 +2151,52 @@ fn profile_native_normalization<'a>( 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; From 691a4fde69e66facb15cbdd7535b042df2602b99 Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Wed, 15 Jul 2026 18:05:30 +0800 Subject: [PATCH 15/15] docs: drop superseded relative claim --- .agents/docs/performance-strategy.md | 1 - src/impl_sugar_path.rs | 11 ++--------- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/.agents/docs/performance-strategy.md b/.agents/docs/performance-strategy.md index b6e80ea..3d66564 100644 --- a/.agents/docs/performance-strategy.md +++ b/.agents/docs/performance-strategy.md @@ -196,7 +196,6 @@ Clean-path fast cases (0-alloc normalize, absolute absolutize, borrowable descen - **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. -- **Recorded relative slow-path Cow retention:** absolute fallback no longer forces `into_owned` on a clean borrowed base; invalid-encoding absolute `relative` 3→2 alloc on Unix call counts. - 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. diff --git a/src/impl_sugar_path.rs b/src/impl_sugar_path.rs index 205ed36..16e042b 100644 --- a/src/impl_sugar_path.rs +++ b/src/impl_sugar_path.rs @@ -1356,11 +1356,6 @@ fn relative_without_cwd<'a>( None } -/// Build a relative outcome from already-resolved absolute paths. -/// -/// Takes [`Cow`] so Windows branches that must return the absolute target can -/// move an owned buffer instead of cloning it (`into_owned` is free on -/// [`Cow::Owned`]). Clean borrowed bases stay borrowed for the common-prefix scan. fn relative_from_resolved(base: Cow<'_, Path>, target: Cow<'_, Path>) -> RelativeOutcome<'static> { #[cfg(target_family = "windows")] if windows_paths_have_different_prefixes(base.as_ref(), target.as_ref()) { @@ -1459,10 +1454,8 @@ fn try_relative_outcome<'a>( } // Slow path: avoid current_dir() for already-absolute paths. Windows - // drive-relative receivers still take try_absolutize here. Keep Cow so a - // clean absolute base is not cloned solely to compute relative against a - // dirty/invalid target, and so Windows different-prefix returns can move an - // already-owned absolute target without a second clone. + // drive-relative receivers still take try_absolutize here. Retain borrowed + // normalized inputs until the result actually needs an owned path. let base = if base_path.is_absolute() { normalize_for_resolution(base_path) } else {