| 1 | //! File search tool with fuzzy matching and scoring. |
| 2 | |
| 3 | use std::cmp::Ordering; |
| 4 | use std::path::{Path, PathBuf}; |
| 5 | use std::time::Duration; |
| 6 | |
| 7 | use async_trait::async_trait; |
| 8 | use ignore::WalkBuilder; |
| 9 | use serde::Serialize; |
| 10 | use serde_json::{Value, json}; |
| 11 | use tokio_util::sync::CancellationToken; |
| 12 | |
| 13 | use crate::tools::search::matches_glob; |
| 14 | |
| 15 | use super::file::{PATH_ALIASES, SEARCH_NAME_ALIASES, SEARCH_NAME_PARAMS, apply_param_aliases}; |
| 16 | use super::spec::{ |
| 17 | ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, |
| 18 | optional_str, optional_u64, required_str, |
| 19 | }; |
| 20 | |
| 21 | const FILE_SEARCH_TIMEOUT: Duration = Duration::from_secs(30); |
| 22 | |
| 23 | #[derive(Debug, Clone, Serialize)] |
| 24 | struct FileSearchMatch { |
| 25 | path: String, |
| 26 | name: String, |
| 27 | score: f64, |
| 28 | } |
| 29 | |
| 30 | pub struct FileSearchTool; |
| 31 | |
| 32 | #[async_trait] |
| 33 | impl ToolSpec for FileSearchTool { |
| 34 | fn name(&self) -> &'static str { |
| 35 | "file_search" |
| 36 | } |
| 37 | |
| 38 | fn model_visible(&self) -> bool { |
| 39 | true |
| 40 | } |
| 41 | |
| 42 | fn description(&self) -> &'static str { |
| 43 | "Find workspace files by name using fuzzy matching with score-based ranking. Pass extensions to filter by suffix." |
| 44 | } |
| 45 | |
| 46 | fn input_schema(&self) -> Value { |
| 47 | json!({ |
| 48 | "type": "object", |
| 49 | "properties": { |
| 50 | "query": { |
| 51 | "type": "string", |
| 52 | "description": "Search query (file name or path fragment)." |
| 53 | }, |
| 54 | "path": { |
| 55 | "type": "string", |
| 56 | "description": "Optional base path to search (relative to workspace)." |
| 57 | }, |
| 58 | "limit": { |
| 59 | "type": "integer", |
| 60 | "description": "Maximum number of results to return (default: 20)." |
| 61 | }, |
| 62 | "extensions": { |
| 63 | "type": "array", |
| 64 | "items": { "type": "string" }, |
| 65 | "description": "Optional list of file extensions to include (e.g. [\"rs\", \"md\"])." |
| 66 | }, |
| 67 | "exclude": { |
| 68 | "type": "array", |
| 69 | "items": { "type": "string" }, |
| 70 | "description": "Optional glob patterns to exclude (e.g. [\"target/**\", \"*.lock\"])." |
| 71 | } |
| 72 | }, |
| 73 | "required": ["query"] |
| 74 | }) |
| 75 | } |
| 76 | |
| 77 | fn capabilities(&self) -> Vec<ToolCapability> { |
| 78 | vec![ToolCapability::ReadOnly, ToolCapability::Sandboxable] |
| 79 | } |
| 80 | |
| 81 | fn approval_requirement(&self) -> ApprovalRequirement { |
| 82 | ApprovalRequirement::Auto |
| 83 | } |
| 84 | |
| 85 | async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> { |
| 86 | let mut input = input; |
| 87 | apply_param_aliases(&mut input, PATH_ALIASES, "File search_name")?; |
| 88 | apply_param_aliases(&mut input, SEARCH_NAME_ALIASES, "File search_name")?; |
| 89 | SEARCH_NAME_PARAMS.reject_unknown(&input)?; |
| 90 | |
| 91 | let query = required_str(&input, "query")?.trim(); |
| 92 | if query.is_empty() { |
| 93 | return Err(ToolError::invalid_input("query cannot be empty")); |
| 94 | } |
| 95 | |
| 96 | let limit = optional_u64(&input, "limit", 20)?.clamp(1, 200) as usize; |
| 97 | // S1: enumerating names inside a denied tree is a read of that tree — |
| 98 | // Seatbelt's `deny file-read*` blocks readdir of denied dirs, so a |
| 99 | // denied search root is refused rather than walked. The raw spelling is |
| 100 | // checked before `resolve_path` (F2) so the refusal names the caller's |
| 101 | // path, never a symlink target it might resolve to. |
| 102 | let base_path = match optional_str(&input, "path")? { |
| 103 | Some(path) if !path.trim().is_empty() => { |
| 104 | super::file::enforce_read_denylist(Path::new(path), "file_search")?; |
| 105 | context.resolve_path(path)? |
| 106 | } |
| 107 | _ => context.workspace.clone(), |
| 108 | }; |
| 109 | super::file::enforce_read_denylist(&base_path, "file_search")?; |
| 110 | |
| 111 | let extensions = parse_extensions(&input); |
| 112 | let exclude_patterns = parse_exclude_patterns(&input); |
| 113 | let matches = search_files_async( |
| 114 | query.to_string(), |
| 115 | base_path, |
| 116 | extensions, |
| 117 | exclude_patterns, |
| 118 | limit, |
| 119 | context.cancel_token.clone(), |
| 120 | FILE_SEARCH_TIMEOUT, |
| 121 | context.follow_symlinks, |
| 122 | ) |
| 123 | .await?; |
| 124 | ToolResult::json(&matches).map_err(|e| ToolError::execution_failed(e.to_string())) |
| 125 | } |
| 126 | } |
| 127 | |
| 128 | #[allow(clippy::too_many_arguments)] |
| 129 | async fn search_files_async( |
| 130 | query: String, |
| 131 | base_path: PathBuf, |
| 132 | extensions: Vec<String>, |
| 133 | exclude_patterns: Vec<String>, |
| 134 | limit: usize, |
| 135 | cancel_token: Option<CancellationToken>, |
| 136 | timeout: Duration, |
| 137 | follow_symlinks: bool, |
| 138 | ) -> Result<Vec<FileSearchMatch>, ToolError> { |
| 139 | let worker_cancel_token = cancel_token.clone(); |
| 140 | run_blocking_file_search(timeout, cancel_token, move || { |
| 141 | search_files( |
| 142 | &query, |
| 143 | &base_path, |
| 144 | extensions, |
| 145 | exclude_patterns, |
| 146 | limit, |
| 147 | worker_cancel_token.as_ref(), |
| 148 | follow_symlinks, |
| 149 | ) |
| 150 | }) |
| 151 | .await |
| 152 | } |
| 153 | |
| 154 | async fn run_blocking_file_search<F>( |
| 155 | timeout: Duration, |
| 156 | cancel_token: Option<CancellationToken>, |
| 157 | search: F, |
| 158 | ) -> Result<Vec<FileSearchMatch>, ToolError> |
| 159 | where |
| 160 | F: FnOnce() -> Result<Vec<FileSearchMatch>, ToolError> + Send + 'static, |
| 161 | { |
| 162 | if cancel_token |
| 163 | .as_ref() |
| 164 | .is_some_and(CancellationToken::is_cancelled) |
| 165 | { |
| 166 | return Err(file_search_cancelled()); |
| 167 | } |
| 168 | |
| 169 | let task = tokio::task::spawn_blocking(search); |
| 170 | let result = match cancel_token { |
| 171 | Some(token) => { |
| 172 | tokio::select! { |
| 173 | biased; |
| 174 | () = token.cancelled() => return Err(file_search_cancelled()), |
| 175 | result = tokio::time::timeout(timeout, task) => result, |
| 176 | } |
| 177 | } |
| 178 | None => tokio::time::timeout(timeout, task).await, |
| 179 | }; |
| 180 | |
| 181 | let joined = result.map_err(|_| file_search_timeout(timeout))?; |
| 182 | joined.map_err(|err| { |
| 183 | ToolError::execution_failed(format!( |
| 184 | "file_search worker failed before completion: {err}" |
| 185 | )) |
| 186 | })? |
| 187 | } |
| 188 | |
| 189 | fn file_search_cancelled() -> ToolError { |
| 190 | ToolError::cancelled("file_search cancelled before completion") |
| 191 | } |
| 192 | |
| 193 | fn file_search_timeout(timeout: Duration) -> ToolError { |
| 194 | ToolError::Timeout { |
| 195 | seconds: timeout.as_secs().max(1), |
| 196 | } |
| 197 | } |
| 198 | |
| 199 | fn parse_extensions(input: &Value) -> Vec<String> { |
| 200 | let mut out = Vec::new(); |
| 201 | if let Some(values) = input.get("extensions").and_then(|v| v.as_array()) { |
| 202 | for value in values { |
| 203 | if let Some(ext) = value.as_str() { |
| 204 | let ext = ext.trim().trim_start_matches('.').to_ascii_lowercase(); |
| 205 | if !ext.is_empty() { |
| 206 | out.push(ext); |
| 207 | } |
| 208 | } |
| 209 | } |
| 210 | } |
| 211 | if out.is_empty() |
| 212 | && let Some(value) = input.get("extension").and_then(|v| v.as_str()) |
| 213 | { |
| 214 | let ext = value.trim().trim_start_matches('.').to_ascii_lowercase(); |
| 215 | if !ext.is_empty() { |
| 216 | out.push(ext); |
| 217 | } |
| 218 | } |
| 219 | out |
| 220 | } |
| 221 | |
| 222 | fn parse_exclude_patterns(input: &Value) -> Vec<String> { |
| 223 | if let Some(values) = input.get("exclude").and_then(Value::as_array) { |
| 224 | return values |
| 225 | .iter() |
| 226 | .filter_map(Value::as_str) |
| 227 | .map(str::trim) |
| 228 | .filter(|pattern| !pattern.is_empty()) |
| 229 | .map(ToOwned::to_owned) |
| 230 | .collect(); |
| 231 | } |
| 232 | |
| 233 | [ |
| 234 | "target/**", |
| 235 | "node_modules/**", |
| 236 | ".git/**", |
| 237 | "DerivedData/**", |
| 238 | "dist/**", |
| 239 | "build/**", |
| 240 | "*.lock", |
| 241 | "*.plist", |
| 242 | ] |
| 243 | .into_iter() |
| 244 | .map(ToOwned::to_owned) |
| 245 | .collect() |
| 246 | } |
| 247 | |
| 248 | fn search_files( |
| 249 | query: &str, |
| 250 | base_path: &Path, |
| 251 | extensions: Vec<String>, |
| 252 | exclude_patterns: Vec<String>, |
| 253 | limit: usize, |
| 254 | cancel_token: Option<&CancellationToken>, |
| 255 | follow_symlinks: bool, |
| 256 | ) -> Result<Vec<FileSearchMatch>, ToolError> { |
| 257 | check_cancelled(cancel_token)?; |
| 258 | |
| 259 | if !base_path.exists() { |
| 260 | return Err(ToolError::invalid_input(format!( |
| 261 | "Base path does not exist: {}", |
| 262 | base_path.display() |
| 263 | ))); |
| 264 | } |
| 265 | |
| 266 | let query_norm = query.to_ascii_lowercase(); |
| 267 | let mut results: Vec<FileSearchMatch> = Vec::new(); |
| 268 | |
| 269 | let mut builder = WalkBuilder::new(base_path); |
| 270 | builder |
| 271 | .hidden(false) |
| 272 | .follow_links(follow_symlinks) |
| 273 | .require_git(false); |
| 274 | let walker = builder.build(); |
| 275 | |
| 276 | for entry in walker { |
| 277 | check_cancelled(cancel_token)?; |
| 278 | |
| 279 | let entry = match entry { |
| 280 | Ok(entry) => entry, |
| 281 | Err(_) => continue, |
| 282 | }; |
| 283 | // Sandbox read deny-list (S1). A walk rooted above a denied tree — |
| 284 | // e.g. `path = "~"` — must not enumerate the names inside it |
| 285 | // (`~/.ssh/id_rsa` …), exactly as `search` skips denied files during |
| 286 | // its walk. Skipped silently rather than failing the whole search: a |
| 287 | // name search is not a directed read, and the explicit refusal for one |
| 288 | // lives at the root guard above. |
| 289 | if let Err(denial) = crate::sandbox::read_guard::active().check(entry.path()) { |
| 290 | tracing::debug!( |
| 291 | target: "codewhale::sandbox::read_guard", |
| 292 | requested = %denial.requested.display(), |
| 293 | "sandbox read deny-list skipped an entry during file_search" |
| 294 | ); |
| 295 | continue; |
| 296 | } |
| 297 | if !entry.file_type().is_some_and(|ft| ft.is_file()) { |
| 298 | continue; |
| 299 | } |
| 300 | |
| 301 | let path = entry.path(); |
| 302 | let rel_path = path |
| 303 | .strip_prefix(base_path) |
| 304 | .unwrap_or(path) |
| 305 | .to_string_lossy() |
| 306 | .replace('\\', "/"); |
| 307 | if should_exclude(&rel_path, &exclude_patterns) { |
| 308 | continue; |
| 309 | } |
| 310 | |
| 311 | if !extensions.is_empty() && !extension_matches(path, &extensions) { |
| 312 | continue; |
| 313 | } |
| 314 | |
| 315 | let name = file_name(path); |
| 316 | |
| 317 | let score = match score_match(&query_norm, &rel_path, &name) { |
| 318 | Some(score) => score, |
| 319 | None => continue, |
| 320 | }; |
| 321 | |
| 322 | results.push(FileSearchMatch { |
| 323 | path: rel_path, |
| 324 | name, |
| 325 | score, |
| 326 | }); |
| 327 | } |
| 328 | |
| 329 | results.sort_by(compare_match); |
| 330 | if results.len() > limit { |
| 331 | results.truncate(limit); |
| 332 | } |
| 333 | Ok(results) |
| 334 | } |
| 335 | |
| 336 | fn check_cancelled(cancel_token: Option<&CancellationToken>) -> Result<(), ToolError> { |
| 337 | if cancel_token.is_some_and(CancellationToken::is_cancelled) { |
| 338 | return Err(file_search_cancelled()); |
| 339 | } |
| 340 | Ok(()) |
| 341 | } |
| 342 | |
| 343 | fn should_exclude(rel_path: &str, exclude_patterns: &[String]) -> bool { |
| 344 | exclude_patterns |
| 345 | .iter() |
| 346 | .any(|pattern| matches_glob(rel_path, pattern)) |
| 347 | } |
| 348 | |
| 349 | fn extension_matches(path: &Path, extensions: &[String]) -> bool { |
| 350 | let Some(ext) = path.extension().and_then(|e| e.to_str()) else { |
| 351 | return false; |
| 352 | }; |
| 353 | let ext = ext.to_ascii_lowercase(); |
| 354 | extensions.iter().any(|wanted| wanted == &ext) |
| 355 | } |
| 356 | |
| 357 | fn file_name(path: &Path) -> String { |
| 358 | path.file_name() |
| 359 | .map(|name| name.to_string_lossy().into_owned()) |
| 360 | .unwrap_or_else(|| path.to_string_lossy().to_string()) |
| 361 | } |
| 362 | |
| 363 | fn score_match(query: &str, rel_path: &str, name: &str) -> Option<f64> { |
| 364 | let path_norm = rel_path.to_ascii_lowercase(); |
| 365 | let name_norm = name.to_ascii_lowercase(); |
| 366 | |
| 367 | if name_norm == query { |
| 368 | return Some(1.0); |
| 369 | } |
| 370 | if path_norm == query { |
| 371 | return Some(0.98); |
| 372 | } |
| 373 | |
| 374 | if name_norm.starts_with(query) { |
| 375 | return Some(0.9 + length_bonus(query, &name_norm)); |
| 376 | } |
| 377 | if path_norm.starts_with(query) { |
| 378 | return Some(0.85 + length_bonus(query, &path_norm)); |
| 379 | } |
| 380 | |
| 381 | if name_norm.contains(query) { |
| 382 | return Some(0.75 + length_bonus(query, &name_norm)); |
| 383 | } |
| 384 | if path_norm.contains(query) { |
| 385 | return Some(0.7 + length_bonus(query, &path_norm)); |
| 386 | } |
| 387 | |
| 388 | if let Some(score) = fuzzy_score(query, &name_norm) { |
| 389 | return Some(0.6 + 0.4 * score); |
| 390 | } |
| 391 | if let Some(score) = fuzzy_score(query, &path_norm) { |
| 392 | return Some(0.55 + 0.4 * score); |
| 393 | } |
| 394 | |
| 395 | None |
| 396 | } |
| 397 | |
| 398 | fn length_bonus(query: &str, target: &str) -> f64 { |
| 399 | let q_len = query.chars().count().max(1) as f64; |
| 400 | let t_len = target.chars().count().max(1) as f64; |
| 401 | (q_len / t_len).min(1.0) * 0.08 |
| 402 | } |
| 403 | |
| 404 | fn fuzzy_score(query: &str, target: &str) -> Option<f64> { |
| 405 | let mut positions = Vec::new(); |
| 406 | let mut query_chars = query.chars(); |
| 407 | let mut current = query_chars.next()?; |
| 408 | |
| 409 | for (idx, ch) in target.chars().enumerate() { |
| 410 | if ch == current { |
| 411 | positions.push(idx); |
| 412 | if let Some(next) = query_chars.next() { |
| 413 | current = next; |
| 414 | } else { |
| 415 | break; |
| 416 | } |
| 417 | } |
| 418 | } |
| 419 | |
| 420 | if positions.len() != query.chars().count() { |
| 421 | return None; |
| 422 | } |
| 423 | |
| 424 | let first = *positions.first().unwrap_or(&0) as f64; |
| 425 | let last = *positions.last().unwrap_or(&0) as f64; |
| 426 | let span = (last - first + 1.0).max(1.0); |
| 427 | let query_len = query.chars().count().max(1) as f64; |
| 428 | let target_len = target.chars().count().max(1) as f64; |
| 429 | |
| 430 | let density = (query_len / span).min(1.0); |
| 431 | let coverage = (query_len / target_len).min(1.0); |
| 432 | Some((density * 0.7 + coverage * 0.3).min(1.0)) |
| 433 | } |
| 434 | |
| 435 | fn compare_match(a: &FileSearchMatch, b: &FileSearchMatch) -> Ordering { |
| 436 | b.score |
| 437 | .partial_cmp(&a.score) |
| 438 | .unwrap_or(Ordering::Equal) |
| 439 | .then_with(|| a.path.cmp(&b.path)) |
| 440 | } |
| 441 | |
| 442 | #[cfg(test)] |
| 443 | mod tests { |
| 444 | use super::*; |
| 445 | use tempfile::tempdir; |
| 446 | |
| 447 | #[tokio::test] |
| 448 | async fn test_file_search_basic() { |
| 449 | let tmp = tempdir().expect("tempdir"); |
| 450 | let root = tmp.path(); |
| 451 | std::fs::create_dir_all(root.join("src")).expect("mkdir"); |
| 452 | std::fs::write(root.join("src").join("main.rs"), "fn main() {}\n").expect("write"); |
| 453 | std::fs::write(root.join("README.md"), "docs\n").expect("write"); |
| 454 | |
| 455 | let ctx = ToolContext::new(root.to_path_buf()); |
| 456 | let tool = FileSearchTool; |
| 457 | let result = tool |
| 458 | .execute(json!({"query": "main", "limit": 5}), &ctx) |
| 459 | .await |
| 460 | .expect("execute"); |
| 461 | |
| 462 | assert!(result.success); |
| 463 | assert!(result.content.contains("main.rs")); |
| 464 | } |
| 465 | |
| 466 | #[tokio::test] |
| 467 | async fn test_file_search_respects_gitignore() { |
| 468 | let tmp = tempdir().expect("tempdir"); |
| 469 | let root = tmp.path(); |
| 470 | std::fs::write(root.join(".gitignore"), "ignored.txt\n").expect("write"); |
| 471 | std::fs::write(root.join("ignored.txt"), "nope\n").expect("write"); |
| 472 | std::fs::write(root.join("keep.txt"), "ok\n").expect("write"); |
| 473 | |
| 474 | let ctx = ToolContext::new(root.to_path_buf()); |
| 475 | let tool = FileSearchTool; |
| 476 | let result = tool |
| 477 | .execute(json!({"query": "txt"}), &ctx) |
| 478 | .await |
| 479 | .expect("execute"); |
| 480 | |
| 481 | assert!(result.success); |
| 482 | assert!(!result.content.contains("ignored.txt")); |
| 483 | assert!(result.content.contains("keep.txt")); |
| 484 | } |
| 485 | |
| 486 | #[tokio::test] |
| 487 | async fn test_file_search_extension_filter() { |
| 488 | let tmp = tempdir().expect("tempdir"); |
| 489 | let root = tmp.path(); |
| 490 | std::fs::write(root.join("main.rs"), "fn main() {}\n").expect("write"); |
| 491 | std::fs::write(root.join("notes.md"), "docs\n").expect("write"); |
| 492 | |
| 493 | let ctx = ToolContext::new(root.to_path_buf()); |
| 494 | let tool = FileSearchTool; |
| 495 | let result = tool |
| 496 | .execute(json!({"query": "m", "extensions": ["rs"]}), &ctx) |
| 497 | .await |
| 498 | .expect("execute"); |
| 499 | |
| 500 | assert!(result.success); |
| 501 | assert!(result.content.contains("main.rs")); |
| 502 | assert!(!result.content.contains("notes.md")); |
| 503 | } |
| 504 | |
| 505 | #[tokio::test] |
| 506 | async fn test_file_search_exclude_filter() { |
| 507 | let tmp = tempdir().expect("tempdir"); |
| 508 | let root = tmp.path(); |
| 509 | std::fs::create_dir_all(root.join("fixtures")).expect("mkdir"); |
| 510 | std::fs::write(root.join("fixtures").join("needle.txt"), "no\n").expect("write"); |
| 511 | std::fs::write(root.join("needle.txt"), "yes\n").expect("write"); |
| 512 | |
| 513 | let ctx = ToolContext::new(root.to_path_buf()); |
| 514 | let tool = FileSearchTool; |
| 515 | let result = tool |
| 516 | .execute(json!({"query": "needle", "exclude": ["fixtures/**"]}), &ctx) |
| 517 | .await |
| 518 | .expect("execute"); |
| 519 | |
| 520 | assert!(result.success); |
| 521 | let matches: Value = serde_json::from_str(&result.content).expect("search json"); |
| 522 | assert!( |
| 523 | matches |
| 524 | .as_array() |
| 525 | .expect("matches") |
| 526 | .iter() |
| 527 | .any(|item| item.get("path").and_then(Value::as_str) == Some("needle.txt")) |
| 528 | ); |
| 529 | assert!(!result.content.contains("fixtures/needle.txt")); |
| 530 | } |
| 531 | |
| 532 | #[tokio::test] |
| 533 | async fn test_file_search_default_excludes_build_artifacts() { |
| 534 | let tmp = tempdir().expect("tempdir"); |
| 535 | let root = tmp.path(); |
| 536 | std::fs::create_dir_all(root.join("target")).expect("mkdir"); |
| 537 | std::fs::write(root.join("target").join("needle.txt"), "no\n").expect("write"); |
| 538 | std::fs::write(root.join("needle.txt"), "yes\n").expect("write"); |
| 539 | |
| 540 | let ctx = ToolContext::new(root.to_path_buf()); |
| 541 | let tool = FileSearchTool; |
| 542 | let result = tool |
| 543 | .execute(json!({"query": "needle"}), &ctx) |
| 544 | .await |
| 545 | .expect("execute"); |
| 546 | |
| 547 | assert!(result.success); |
| 548 | let matches: Value = serde_json::from_str(&result.content).expect("search json"); |
| 549 | assert!( |
| 550 | matches |
| 551 | .as_array() |
| 552 | .expect("matches") |
| 553 | .iter() |
| 554 | .any(|item| item.get("path").and_then(Value::as_str) == Some("needle.txt")) |
| 555 | ); |
| 556 | assert!(!result.content.contains("target/needle.txt")); |
| 557 | } |
| 558 | |
| 559 | #[tokio::test] |
| 560 | async fn test_file_search_respects_cancel_token() { |
| 561 | let tmp = tempdir().expect("tempdir"); |
| 562 | let root = tmp.path(); |
| 563 | std::fs::write(root.join("needle.txt"), "yes\n").expect("write"); |
| 564 | let cancel_token = CancellationToken::new(); |
| 565 | cancel_token.cancel(); |
| 566 | let ctx = ToolContext::new(root.to_path_buf()).with_cancel_token(cancel_token); |
| 567 | |
| 568 | let tool = FileSearchTool; |
| 569 | let err = tool |
| 570 | .execute(json!({"query": "needle"}), &ctx) |
| 571 | .await |
| 572 | .expect_err("cancelled file_search should return an error"); |
| 573 | |
| 574 | assert!( |
| 575 | format!("{err:?}").contains("cancelled"), |
| 576 | "unexpected error: {err:?}" |
| 577 | ); |
| 578 | } |
| 579 | |
| 580 | #[tokio::test] |
| 581 | async fn test_file_search_blocking_wrapper_reports_timeout() { |
| 582 | let err = run_blocking_file_search(Duration::from_millis(1), None, || { |
| 583 | std::thread::sleep(Duration::from_millis(50)); |
| 584 | Ok(Vec::new()) |
| 585 | }) |
| 586 | .await |
| 587 | .expect_err("slow file_search worker should time out"); |
| 588 | |
| 589 | assert!( |
| 590 | matches!(err, ToolError::Timeout { seconds: 1 }), |
| 591 | "unexpected error: {err:?}" |
| 592 | ); |
| 593 | } |
| 594 | |
| 595 | #[tokio::test] |
| 596 | #[cfg(unix)] |
| 597 | async fn test_file_search_does_not_follow_symlinked_files() { |
| 598 | let tmp = tempdir().expect("tempdir"); |
| 599 | let root = tmp.path().join("workspace"); |
| 600 | let outside = tmp.path().join("outside"); |
| 601 | std::fs::create_dir_all(&root).expect("mkdir workspace"); |
| 602 | std::fs::create_dir_all(&outside).expect("mkdir outside"); |
| 603 | let outside_file = outside.join("secret.txt"); |
| 604 | std::fs::write(&outside_file, "outside\n").expect("write outside"); |
| 605 | std::os::unix::fs::symlink(&outside_file, root.join("secret.txt")).expect("symlink"); |
| 606 | |
| 607 | let ctx = ToolContext::new(root); |
| 608 | let tool = FileSearchTool; |
| 609 | let result = tool |
| 610 | .execute(json!({"query": "secret"}), &ctx) |
| 611 | .await |
| 612 | .expect("execute"); |
| 613 | |
| 614 | assert!(result.success); |
| 615 | assert!(!result.content.contains("secret.txt")); |
| 616 | } |
| 617 | |
| 618 | /// F1: searching a denied directory is enumeration of it — `file_search` |
| 619 | /// with its root inside a denied tree must refuse, matching the OS layer |
| 620 | /// (Seatbelt `deny file-read*` blocks readdir of denied dirs). |
| 621 | #[tokio::test] |
| 622 | async fn test_file_search_refuses_a_denied_root() { |
| 623 | let holder = tempdir().expect("tempdir"); |
| 624 | let project = holder.path().join("project"); |
| 625 | // Deterministic anchor: the `.env` filename rule denies any path whose |
| 626 | // file name is `.env`, directory or not. |
| 627 | std::fs::create_dir_all(project.join(".env")).expect("mkdir"); |
| 628 | std::fs::write(project.join(".env").join("token"), "x\n").expect("write"); |
| 629 | |
| 630 | let ctx = ToolContext::new(project.clone()); |
| 631 | let tool = FileSearchTool; |
| 632 | let error = tool |
| 633 | .execute( |
| 634 | json!({"query": "token", "path": project.join(".env")}), |
| 635 | &ctx, |
| 636 | ) |
| 637 | .await |
| 638 | .expect_err("a denied search root must be refused, not walked"); |
| 639 | |
| 640 | assert!( |
| 641 | matches!(error, ToolError::PermissionDenied { .. }), |
| 642 | "expected a permission refusal, got: {error:?}" |
| 643 | ); |
| 644 | } |
| 645 | |
| 646 | /// F1: a walk rooted *above* a denied path must not enumerate names inside |
| 647 | /// it — `path = "~"` must not surface `~/.ssh/id_rsa`, and a project root |
| 648 | /// must not surface its `.env` (denied by name under the default list). |
| 649 | #[tokio::test] |
| 650 | async fn test_file_search_skips_denied_entries_during_the_walk() { |
| 651 | let tmp = tempdir().expect("tempdir"); |
| 652 | let root = tmp.path(); |
| 653 | std::fs::write(root.join("needle.txt"), "yes\n").expect("write"); |
| 654 | std::fs::write(root.join(".env"), "SECRET=1\n").expect("write env"); |
| 655 | std::fs::write(root.join(".env.local"), "SECRET=2\n").expect("write env local"); |
| 656 | |
| 657 | let ctx = ToolContext::new(root.to_path_buf()); |
| 658 | let tool = FileSearchTool; |
| 659 | let result = tool |
| 660 | .execute(json!({"query": "env"}), &ctx) |
| 661 | .await |
| 662 | .expect("execute"); |
| 663 | |
| 664 | assert!(result.success); |
| 665 | assert!( |
| 666 | !result.content.contains(".env"), |
| 667 | "denied entries must not be enumerated by a name search: {}", |
| 668 | result.content |
| 669 | ); |
| 670 | |
| 671 | // The innocent sibling is still found — the deny-list filters, it does |
| 672 | // not blank the search. |
| 673 | let result = tool |
| 674 | .execute(json!({"query": "needle"}), &ctx) |
| 675 | .await |
| 676 | .expect("execute"); |
| 677 | assert!(result.success); |
| 678 | assert!(result.content.contains("needle.txt")); |
| 679 | } |
| 680 | } |
| 681 |