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 d4d0acd8..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] { - let count = self.indices.len(); + 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"]);