Compare commits

...

2 Commits

Author SHA1 Message Date
Dmitriy Kovalenko 8e66394049 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.
2026-07-07 06:53:29 -07:00
elee7420-gif 3dda83d832 fix(simd_path): clamp resolve_ptrs iteration to buf.len()
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.
2026-07-06 00:33:09 +03:00
4 changed files with 82 additions and 21 deletions
Generated
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -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"
+35 -10
View File
@@ -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::<Vec<_>>()
);
}
#[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";
+44 -8
View File
@@ -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::<u8>(); 32];
let mut ptrs = [std::ptr::null::<u8>(); 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::<u8>(); 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"]);