From 75f3ec5a200f1a98314f88c2b56de7ac621c7e71 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 15 Oct 2026 10:21:41 +0100 Subject: [PATCH 1/2] fix(grep): retrieve content from explicit file paths --- crates/terraphim_grep/src/hybrid_searcher.rs | 86 +++++++- .../tests/single_file_search.rs | 191 ++++++++++++++++++ 2 files changed, 270 insertions(+), 7 deletions(-) create mode 100644 crates/terraphim_grep/tests/single_file_search.rs diff --git a/crates/terraphim_grep/src/hybrid_searcher.rs b/crates/terraphim_grep/src/hybrid_searcher.rs index 9987e56..f4ec635 100644 --- a/crates/terraphim_grep/src/hybrid_searcher.rs +++ b/crates/terraphim_grep/src/hybrid_searcher.rs @@ -428,13 +428,45 @@ impl HybridSearcher { parse_grep_query, }; - // Refuse the tree before its base path reaches fff-search: a + let metadata = std::fs::symlink_metadata(&search_path).map_err(|error| { + format!( + "Cannot inspect search path {}: {error}", + search_path.display() + ) + })?; + if !metadata.is_file() && !metadata.is_dir() { + return Err(format!( + "Search path must be a regular file or directory, not a symlink or special file: {}", + search_path.display() + )); + } + // An explicit file uses its parent only as the path base. Never + // scan that directory: neighbours were not selected by the caller. + let selected_file = if metadata.is_file() { + Some(std::fs::canonicalize(&search_path).map_err(|error| { + format!( + "Cannot resolve search file {}: {error}", + search_path.display() + ) + })?) + } else { + None + }; + let guarded_path = selected_file.as_deref().unwrap_or(&search_path); + // Refuse the tree or exact file before its path reaches fff-search: a // non-UTF-8 or over-long entry name defeats the library's // `from_utf8_unchecked` on the stored relative path. UF-040 / #296. - crate::path_guard::reject_hostile_tree(&search_path)?; + crate::path_guard::reject_hostile_tree(guarded_path)?; + let base_path = if selected_file.is_some() { + guarded_path + .parent() + .ok_or("Search file has no parent directory")? + } else { + &search_path + }; let mut picker = FilePicker::new(FilePickerOptions { - base_path: search_path.to_string_lossy().to_string(), + base_path: base_path.to_string_lossy().to_string(), mode: FFFMode::Ai, watch: false, // See `no_mmap_budget` -- UF-039 / #293. @@ -448,9 +480,15 @@ impl HybridSearcher { }) .map_err(|e| format!("FilePicker init failed: {}", e))?; - picker - .collect_files() - .map_err(|e| format!("File scan failed: {}", e))?; + if let Some(file) = &selected_file { + picker + .add_new_file(file) + .ok_or_else(|| format!("Cannot index selected file: {}", file.display()))?; + } else { + picker + .collect_files() + .map_err(|e| format!("File scan failed: {}", e))?; + } if picker.get_files().is_empty() { return Ok(vec![]); @@ -473,9 +511,43 @@ impl HybridSearcher { // Exact KG aliases are data, so bypass the query parser entirely. // One Aho-Corasick batch scans each file once, including aliases that // sort after the displayed concepts. Free-text queries retain filters. - let fff_query; + let mut fff_query; let result = if aliases.is_empty() { fff_query = parse_grep_query(query); + if selected_file.is_some() && !fff_query.constraints.is_empty() { + // Pinned FFF 0.8.4's grep constraint path uses the base + // arena for overflow files. Its public file search splits + // arenas correctly, so apply the same parsed constraints + // there before running the unchanged content query. + let mut filter = fff_search::FFFQuery { + raw_query: "", + constraints: fff_query.constraints.clone(), + fuzzy_query: fff_search::FuzzyQuery::Empty, + location: None, + }; + let mut matched = !picker + .fuzzy_search(&filter, None, fff_search::FuzzySearchOptions::default()) + .items + .is_empty(); + // Match grep's existing filename-hint fallback as well. + if !matched + && filter.constraints.iter().any(|constraint| { + matches!(constraint, fff_search::Constraint::FilePath(_)) + }) + { + filter.constraints.retain(|constraint| { + !matches!(constraint, fff_search::Constraint::FilePath(_)) + }); + matched = !picker + .fuzzy_search(&filter, None, fff_search::FuzzySearchOptions::default()) + .items + .is_empty(); + } + if !matched { + return Ok(vec![]); + } + fff_query.constraints.clear(); + } picker.grep(&fff_query, &options) } else { let patterns: Vec<&str> = aliases.iter().map(String::as_str).collect(); diff --git a/crates/terraphim_grep/tests/single_file_search.rs b/crates/terraphim_grep/tests/single_file_search.rs new file mode 100644 index 0000000..bef544c --- /dev/null +++ b/crates/terraphim_grep/tests/single_file_search.rs @@ -0,0 +1,191 @@ +//! Real CLI regressions for explicitly selected files, using an authored synonym graph. +#![cfg(feature = "code-search")] + +use std::path::Path; +use std::process::{Command, Output}; + +fn fixture() -> tempfile::TempDir { + let root = tempfile::tempdir().unwrap(); + std::fs::write( + root.path().join("brief.md"), + "Judgment: review Café.\r\nRisk: lost context.\r\n", + ) + .unwrap(); + std::fs::write( + root.path().join("neighbour.md"), + "Risk: do not retrieve a sibling.\n", + ) + .unwrap(); + std::fs::write(root.path().join("graph.json"), r#"{"name":"file-search","data":{"risk":{"id":1,"nterm":"risk"},"concern":{"id":1,"nterm":"risk"}}}"#).unwrap(); + root +} + +fn search(root: &Path, query: &str, paths: &[&Path]) -> Output { + Command::new(env!("CARGO_BIN_EXE_terraphim-grep")) + .current_dir(root) + .args([ + query, + "--search-only", + "--json", + "--thesaurus", + "graph.json", + "--paths", + ]) + .args(paths) + .env_remove("OPENROUTER_API_KEY") + .env_remove("OLLAMA_BASE_URL") + .output() + .unwrap() +} + +fn chunks(output: Output) -> Vec { + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let value: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert!(value["answer"].is_null()); + value["chunks"].as_array().unwrap().clone() +} + +#[test] +fn absolute_and_relative_file_search_use_synonyms_without_scanning_neighbours() { + let root = fixture(); + let absolute = root.path().join("brief.md"); + for path in [ + absolute.as_path(), + Path::new("brief.md"), + Path::new("./brief.md"), + ] { + let found = chunks(search(root.path(), "Concern", &[path])); + assert_eq!(found.len(), 1, "{found:?}"); + assert_eq!(found[0]["source"], "brief.md"); + assert_eq!(found[0]["line_start"], 2); + assert_eq!(found[0]["content"], "Risk: lost context."); + } + assert_eq!( + std::fs::read(&absolute).unwrap(), + b"Judgment: review Caf\xc3\xa9.\r\nRisk: lost context.\r\n" + ); +} + +#[test] +fn explicit_file_preserves_plain_queries_and_file_filters() { + let root = fixture(); + let selected = [Path::new("brief.md")]; + let found = chunks(search(root.path(), "Café *.md", &selected)); + assert_eq!(found.len(), 1); + assert_eq!(found[0]["line_start"], 1); + assert!(chunks(search(root.path(), "Café *.rs", &selected)).is_empty()); +} + +#[test] +fn file_filters_match_the_existing_directory_query_semantics() { + let root = fixture(); + for query in [ + "Café", + "Café *.md", + "Café *.rs", + "Café !*.rs", + "Café !*.md", + "Café b*.md", + "Café n*.md", + "Café !b*.md", + "Café brief.md", + "Café missing.md", + ] { + let explicit = chunks(search(root.path(), query, &[Path::new("brief.md")])); + let directory = chunks(search(root.path(), query, &[Path::new(".")])); + assert_eq!(explicit, directory, "query {query:?}"); + } +} + +#[test] +fn unicode_filename_and_nested_relative_input_retain_actual_source() { + let root = fixture(); + std::fs::create_dir(root.path().join("notes")).unwrap(); + std::fs::write( + root.path().join("notes/résumé.md"), + "Risk: review Café.\r\n", + ) + .unwrap(); + let found = chunks(search( + root.path(), + "Concern", + &[Path::new("notes/résumé.md")], + )); + assert_eq!(found.len(), 1); + assert_eq!(found[0]["source"], "résumé.md"); + assert_eq!(found[0]["content"], "Risk: review Café."); + let found = chunks(search( + root.path(), + "Concern", + &[Path::new("notes/../brief.md")], + )); + assert_eq!(found.len(), 1); + assert_eq!(found[0]["source"], "brief.md"); +} + +#[test] +fn multiple_explicit_files_do_not_add_unselected_siblings() { + let root = fixture(); + std::fs::write( + root.path().join("second.md"), + "Risk: explicit second file.\n", + ) + .unwrap(); + let found = chunks(search( + root.path(), + "Concern", + &[Path::new("brief.md"), Path::new("second.md")], + )); + assert_eq!(found.len(), 2, "{found:?}"); + assert!( + found + .iter() + .all(|c| c["source"] == "brief.md" || c["source"] == "second.md") + ); +} + +#[test] +fn missing_file_is_an_error_instead_of_an_empty_success() { + let root = fixture(); + assert!( + !search(root.path(), "Concern", &[Path::new("missing.md")]) + .status + .success() + ); +} + +#[cfg(unix)] +#[test] +fn explicit_symlink_and_socket_are_refused() { + use std::os::unix::{fs::symlink, net::UnixListener}; + let root = fixture(); + symlink(root.path().join("brief.md"), root.path().join("linked.md")).unwrap(); + let _socket = UnixListener::bind(root.path().join("socket")).unwrap(); + for path in [Path::new("linked.md"), Path::new("socket")] { + let output = search(root.path(), "Concern", &[path]); + assert!(!output.status.success(), "must refuse {path:?}"); + } +} + +#[cfg(target_os = "linux")] +#[test] +fn invalid_utf8_single_file_is_refused_without_inspecting_neighbours() { + use std::ffi::OsString; + use std::os::unix::ffi::OsStringExt; + let root = fixture(); + let invalid = root + .path() + .join(OsString::from_vec(b"bad-\xff.md".to_vec())); + std::fs::write(&invalid, "Risk: invalid path.\n").unwrap(); + assert!(!search(root.path(), "Concern", &[&invalid]).status.success()); + let found = chunks(search(root.path(), "Concern", &[Path::new("brief.md")])); + assert_eq!( + found.len(), + 1, + "the explicit file must not scan hostile siblings" + ); +} From c9e3e65903c6089820476321ee19bb1ec6b0b0dd Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 15 Oct 2026 10:32:41 +0100 Subject: [PATCH 2/2] test: run single-file grep regressions in CI --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 664218a..d54c7d6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -110,7 +110,7 @@ jobs: run: cargo test --locked -p terraphim_agent --test memory_capture_cli_tests --test memory_apply_cli_tests --test memory_rubric_cli_tests # Exercise the released grep command against real files and exported # synonym graphs, including Linux-only hostile filesystem names. - - run: cargo test --locked -p terraphim_grep --test synonym_retrieval --test answer_request --test path_guard_hostile_tree + - run: cargo test --locked -p terraphim_grep --test synonym_retrieval --test answer_request --test path_guard_hostile_tree --test single_file_search - name: Verify authored synonyms and role boundaries over real MCP stdio run: cargo test --locked -p terraphim_mcp_server --test test_mcp_role_contract # GitHub-side home for the updater integration contracts (managed-mode