From 3dda83d83200b4c1f047f345a7ff71de428ed4f1 Mon Sep 17 00:00:00 2001 From: elee7420-gif Date: Mon, 6 Jul 2026 00:33:09 +0300 Subject: [PATCH 1/2] fix(simd_path): clamp resolve_ptrs iteration to buf.len() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolve_ptrs() iterates self.indices.len() times over a fixed-size [*const u8; 32] buffer with no guard. When a file path exceeds 512 bytes (32 chunks × 16 bytes), the loop accesses buf[32] and panics: index out of bounds: the len is 32 but the index is 32 On macOS PATH_MAX is 1024, so any legitimately long path can trigger this. Clamp count to buf.len() so pathological paths are truncated gracefully instead of crashing. Fixes an OOB panic found in pi-fff v0.9.6. --- crates/fff-core/src/simd_path.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/fff-core/src/simd_path.rs b/crates/fff-core/src/simd_path.rs index d4d0acd8..d06bb49d 100644 --- a/crates/fff-core/src/simd_path.rs +++ b/crates/fff-core/src/simd_path.rs @@ -103,7 +103,7 @@ impl ChunkedString { arena: ArenaPtr, buf: &'a mut [*const u8; 32], ) -> &'a [*const u8] { - let count = self.indices.len(); + let count = self.indices.len().min(buf.len()); let base = arena.as_ptr(); for (i, &idx) in self.indices.iter().enumerate() { buf[i] = unsafe { base.add(idx as usize * SIMD_CHUNK_BYTES) }; From 8e663940496d8b249d3d3f7c22c88d4ab96e851a Mon Sep 17 00:00:00 2001 From: Dmitriy Kovalenko Date: Tue, 7 Jul 2026 06:53:29 -0700 Subject: [PATCH 2/2] fix(score): size chunk ptr buffers to PATH_MAX instead of 512 bytes The scoring hot path passed fixed [*const u8; 32] buffers (32 * 16 = 512 bytes) to resolve_ptrs while PATH_BUF_SIZE allows PATH_MAX-long paths (1024 on macOS, 4096 on Linux), so any path over 512 bytes panicked with an out of bounds index. neo_frizbee 0.10.4 makes the resolver buffer size a const generic, so the buffers are now sized MAX_PATH_CHUNKS = PATH_BUF_SIZE / 16 at compile time and long paths are matched in full instead of truncated. Also covers the frizbee greedy fallback for haystacks longer than its DP matrix which previously scanned a stale score matrix and panicked. Adds regression tests for both the resolve_ptrs unit level and the full scoring pipeline. --- Cargo.lock | 4 +-- Cargo.toml | 2 +- crates/fff-core/src/score.rs | 45 +++++++++++++++++++++------- crates/fff-core/src/simd_path.rs | 50 +++++++++++++++++++++++++++----- 4 files changed, 81 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d48be9c2..4a6093b0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1534,9 +1534,9 @@ dependencies = [ [[package]] name = "neo_frizbee" -version = "0.10.3" +version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dd76fab81213d184cc28a7757791775bdcfd7f2a15e3558d7a4f7e4ee7de864" +checksum = "12af02496d6e51f324af42ffbd1ed31eb275d4aedd17b18b2cb4542f103f86cb" dependencies = [ "itertools 0.14.0", "raw-cpuid", diff --git a/Cargo.toml b/Cargo.toml index 1602a4b1..b229f5b9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,7 +36,7 @@ signal-hook-registry = "1.4" zlob = { version = "=1.6.0-dev.7" } mlua = { version = "0.11.1", features = ["module", "luajit"] } -neo_frizbee = { version = "0.10.3", features = ["match_end_col"] } +neo_frizbee = { version = "0.10.4", features = ["match_end_col"] } notify = { version = "9.0.0-rc.3" } notify-debouncer-full = { package = "fff-notify-debouncer-full", version = "0.9.4" } once_cell = "1.20.2" diff --git a/crates/fff-core/src/score.rs b/crates/fff-core/src/score.rs index b96134b2..02faead4 100644 --- a/crates/fff-core/src/score.rs +++ b/crates/fff-core/src/score.rs @@ -2,7 +2,7 @@ use crate::{ constraints::apply_constraints, git::is_modified_status, path_utils::calculate_distance_penalty, - simd_path::ArenaPtr, + simd_path::{ArenaPtr, MAX_PATH_CHUNKS}, sort_buffer::{sort_by_key_with_buffer, sort_with_buffer}, types::{DirItem, FileItem, Score, ScoringContext}, }; @@ -32,7 +32,7 @@ impl<'a> FileItems<'a> { fn resolve_file_chunks( file: &FileItem, arena: ArenaPtr, - buf: &mut [*const u8; 32], + buf: &mut [*const u8; MAX_PATH_CHUNKS], ) -> Option<(usize, u16)> { if file.is_deleted() { return None; @@ -60,15 +60,15 @@ fn match_fuzzy_parts( return vec![]; } - let resolve = |file: &FileItem, buf: &mut [*const u8; 32]| -> Option<(usize, u16)> { - resolve_file_chunks(file, arena, buf) - }; + let resolve = |file: &FileItem, + buf: &mut [*const u8; MAX_PATH_CHUNKS]| + -> Option<(usize, u16)> { resolve_file_chunks(file, arena, buf) }; // because we reassemble the vec of reference we have to use a different type // to narrow down the [&FileItem] which would be resolved by frizbee as && - let resolve_ref = |file: &&FileItem, buf: &mut [*const u8; 32]| -> Option<(usize, u16)> { - resolve_file_chunks(file, arena, buf) - }; + let resolve_ref = |file: &&FileItem, + buf: &mut [*const u8; MAX_PATH_CHUNKS]| + -> Option<(usize, u16)> { resolve_file_chunks(file, arena, buf) }; let first_part_matches = match working_files { FileItems::All(files) => neo_frizbee::match_list_parallel_resolved( @@ -173,7 +173,7 @@ pub(crate) fn fuzzy_match_and_score_files<'a>( fn resolve_dir_chunks( dir: &DirItem, arena: ArenaPtr, - buf: &mut [*const u8; 32], + buf: &mut [*const u8; MAX_PATH_CHUNKS], ) -> Option<(usize, u16)> { let ptrs = dir.path.resolve_ptrs(arena, buf); Some((ptrs.len(), dir.path.byte_len)) @@ -199,7 +199,7 @@ fn match_fuzzy_parts_dirs( } let resolve_chunks_for_frizbee = - |dir: &&DirItem, buf: &mut [*const u8; 32]| -> Option<(usize, u16)> { + |dir: &&DirItem, buf: &mut [*const u8; MAX_PATH_CHUNKS]| -> Option<(usize, u16)> { resolve_dir_chunks(dir, arena, buf) }; @@ -1315,6 +1315,31 @@ mod filename_bonus_tests { ); } + /// Regression: PR #652 / field panic in pi-fff v0.9.6. + /// A path >512 bytes (but within PATH_MAX) overflows the fixed + /// `[*const u8; 32]` chunk-pointer buffer during scoring and panics with + /// "index out of bounds: the len is 32 but the index is 32". + #[test] + fn test_path_longer_than_512_bytes_does_not_panic_and_matches() { + let mut long_path = String::new(); + while long_path.len() < 600 { + long_path.push_str("deeply_nested_directory_segment/"); + } + long_path.push_str("needle_file.rs"); + assert!(long_path.len() > 512 && long_path.len() < crate::simd_path::PATH_BUF_SIZE); + + let (files, arena) = make_files(&[long_path.as_str(), "src/other.rs"]); + + // Panics here on unfixed code: frizbee resolves chunk ptrs per file. + let results = search(&files, "needle", arena); + + assert!( + results.iter().any(|(p, _)| p == &long_path), + "filename at the tail of a >512-byte path must still match, got: {:?}", + results.iter().map(|(p, _)| p).collect::>() + ); + } + #[test] fn test_single_path_matching() { let path = "core_workflow_service/kafka_event_consumer/src/ai_part_extraction_request/ai_part_extraction_request_handler.rs"; diff --git a/crates/fff-core/src/simd_path.rs b/crates/fff-core/src/simd_path.rs index d06bb49d..881f1096 100644 --- a/crates/fff-core/src/simd_path.rs +++ b/crates/fff-core/src/simd_path.rs @@ -62,6 +62,9 @@ impl std::fmt::Debug for SimdChunk { pub use crate::constants::PATH_BUF_SIZE; +/// Chunk pointer capacity needed for the longest path the platform allows. +pub(crate) const MAX_PATH_CHUNKS: usize = PATH_BUF_SIZE.div_ceil(SIMD_CHUNK_BYTES); + /// Indices into a shared `SimdChunk` arena representing a file path. /// /// All read methods require an explicit `arena_base` pointer from the owning @@ -98,14 +101,10 @@ impl ChunkedString { } #[inline] - pub fn resolve_ptrs<'a>( - &self, - arena: ArenaPtr, - buf: &'a mut [*const u8; 32], - ) -> &'a [*const u8] { + pub fn resolve_ptrs<'a>(&self, arena: ArenaPtr, buf: &'a mut [*const u8]) -> &'a [*const u8] { let count = self.indices.len().min(buf.len()); let base = arena.as_ptr(); - for (i, &idx) in self.indices.iter().enumerate() { + for (i, &idx) in self.indices[..count].iter().enumerate() { buf[i] = unsafe { base.add(idx as usize * SIMD_CHUNK_BYTES) }; } &buf[..count] @@ -460,7 +459,7 @@ mod tests { let arena = store.as_arena_ptr(); let cs = &strings[0]; - let mut ptrs = [std::ptr::null::(); 32]; + let mut ptrs = [std::ptr::null::(); MAX_PATH_CHUNKS]; let resolved = cs.resolve_ptrs(arena, &mut ptrs); assert_eq!(resolved.len(), 2); // 25 bytes = 2 chunks @@ -478,6 +477,43 @@ mod tests { ); } + #[test] + fn test_resolve_ptrs_path_exceeding_512_bytes() { + // Regression: a fixed 32-ptr buffer covered only 512 bytes while + // PATH_BUF_SIZE (libc::PATH_MAX) allows longer paths, panicking with + // "index out of bounds: the len is 32 but the index is 32" + let mut path = String::new(); + while path.len() < 600 { + path.push_str("deeply_nested_directory_segment/"); + } + path.push_str("needle_file.rs"); + assert!(path.len() > 512 && path.len() < PATH_BUF_SIZE); + + let (store, strings, _files) = build_test_store(&[path.as_str()]); + let arena = store.as_arena_ptr(); + let cs = &strings[0]; + assert!(cs.chunk_count() > 32, "path must span more than 32 chunks"); + + let mut ptrs = [std::ptr::null::(); MAX_PATH_CHUNKS]; + let resolved = cs.resolve_ptrs(arena, &mut ptrs); + + // Truncation is not acceptable either: it silently drops the tail of + // the path (including the filename here) from fuzzy matching. + assert_eq!( + resolved.len(), + cs.chunk_count(), + "resolve_ptrs must resolve every chunk of a PATH_MAX-legal path" + ); + + let total = cs.byte_len as usize; + let mut reconstructed = Vec::with_capacity(total); + for (i, &ptr) in resolved.iter().enumerate() { + let take = SIMD_CHUNK_BYTES.min(total - i * SIMD_CHUNK_BYTES); + reconstructed.extend_from_slice(unsafe { std::slice::from_raw_parts(ptr, take) }); + } + assert_eq!(std::str::from_utf8(&reconstructed).unwrap(), path); + } + #[test] fn test_filename_cow_mid_chunk() { let (store, strings, _files) = build_test_store(&["src/components/Button.tsx"]);