Skip to content

Commit 64feb55

Browse files
committed
Fix file search symlink escape (#8667)
1 parent 66b7c67 commit 64feb55

File tree

3 files changed

+91
-6
lines changed

3 files changed

+91
-6
lines changed

codex-rs/Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

codex-rs/file-search/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ path = "src/lib.rs"
1515
[dependencies]
1616
anyhow = { workspace = true }
1717
clap = { workspace = true, features = ["derive"] }
18+
dunce = { workspace = true }
1819
ignore = { workspace = true }
1920
nucleo-matcher = { workspace = true }
2021
serde = { workspace = true, features = ["derive"] }
@@ -23,3 +24,4 @@ tokio = { workspace = true, features = ["full"] }
2324

2425
[dev-dependencies]
2526
pretty_assertions = { workspace = true }
27+
tempfile = { workspace = true }

codex-rs/file-search/src/lib.rs

Lines changed: 87 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ use std::cmp::Reverse;
1212
use std::collections::BinaryHeap;
1313
use std::num::NonZero;
1414
use std::path::Path;
15+
use std::path::PathBuf;
1516
use std::sync::Arc;
1617
use std::sync::atomic::AtomicBool;
1718
use std::sync::atomic::AtomicUsize;
@@ -143,6 +144,7 @@ pub fn run(
143144
respect_gitignore: bool,
144145
) -> anyhow::Result<FileSearchResults> {
145146
let pattern = create_pattern(pattern_text);
147+
let canonical_root = canonicalize_path(search_directory);
146148
// Create one BestMatchesList per worker thread so that each worker can
147149
// operate independently. The results across threads will be merged when
148150
// the traversal is complete.
@@ -199,6 +201,7 @@ pub fn run(
199201
let index = index_counter.fetch_add(1, Ordering::Relaxed);
200202
let best_list_ptr = best_matchers_per_worker[index].get();
201203
let best_list = unsafe { &mut *best_list_ptr };
204+
let canonical_root = canonical_root.clone();
202205

203206
// Each worker keeps a local counter so we only read the atomic flag
204207
// every N entries which is cheaper than checking on every file.
@@ -207,7 +210,19 @@ pub fn run(
207210

208211
let cancel = cancel_flag.clone();
209212

210-
Box::new(move |entry| {
213+
Box::new(move |entry_result| {
214+
let entry = match entry_result {
215+
Ok(entry) => entry,
216+
Err(_) => return ignore::WalkState::Continue,
217+
};
218+
if symlink_outside_root(&entry, &canonical_root) {
219+
return if entry.file_type().is_some_and(|ft| ft.is_dir()) {
220+
ignore::WalkState::Skip
221+
} else {
222+
ignore::WalkState::Continue
223+
};
224+
}
225+
211226
if let Some(path) = get_file_path(&entry, search_directory) {
212227
best_list.insert(path);
213228
}
@@ -222,13 +237,9 @@ pub fn run(
222237
});
223238

224239
fn get_file_path<'a>(
225-
entry_result: &'a Result<ignore::DirEntry, ignore::Error>,
240+
entry: &'a ignore::DirEntry,
226241
search_directory: &std::path::Path,
227242
) -> Option<&'a str> {
228-
let entry = match entry_result {
229-
Ok(e) => e,
230-
Err(_) => return None,
231-
};
232243
if entry.file_type().is_some_and(|ft| ft.is_dir()) {
233244
return None;
234245
}
@@ -408,6 +419,21 @@ fn create_pattern(pattern: &str) -> Pattern {
408419
)
409420
}
410421

422+
fn canonicalize_path(path: &Path) -> PathBuf {
423+
dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
424+
}
425+
426+
fn symlink_outside_root(entry: &ignore::DirEntry, canonical_root: &Path) -> bool {
427+
if !entry.path_is_symlink() {
428+
return false;
429+
}
430+
let resolved = dunce::canonicalize(entry.path()).ok();
431+
let Some(resolved) = resolved else {
432+
return true;
433+
};
434+
!resolved.starts_with(canonical_root)
435+
}
436+
411437
#[cfg(test)]
412438
mod tests {
413439
use super::*;
@@ -453,4 +479,59 @@ mod tests {
453479
fn file_name_from_path_falls_back_to_full_path() {
454480
assert_eq!(file_name_from_path(""), "");
455481
}
482+
483+
#[cfg(unix)]
484+
#[test]
485+
fn file_search_skips_symlink_outside_root() -> anyhow::Result<()> {
486+
use std::fs;
487+
use std::os::unix::fs::symlink;
488+
use std::sync::Arc;
489+
use std::sync::atomic::AtomicBool;
490+
491+
let temp = tempfile::tempdir()?;
492+
let root = temp.path().join("root");
493+
let outside = temp.path().join("outside");
494+
fs::create_dir(&root)?;
495+
fs::create_dir(&outside)?;
496+
fs::write(root.join("inside.txt"), "inside")?;
497+
fs::write(outside.join("outside.txt"), "outside")?;
498+
symlink(&outside, root.join("link"))?;
499+
500+
let limit = NonZero::new(10).unwrap();
501+
let threads = NonZero::new(1).unwrap();
502+
503+
let results_inside = run(
504+
"inside",
505+
limit,
506+
&root,
507+
Vec::new(),
508+
threads,
509+
Arc::new(AtomicBool::new(false)),
510+
false,
511+
true,
512+
)?;
513+
let paths_inside: Vec<String> =
514+
results_inside.matches.into_iter().map(|m| m.path).collect();
515+
assert_eq!(paths_inside, vec!["inside.txt".to_string()]);
516+
517+
let results_outside = run(
518+
"outside",
519+
limit,
520+
&root,
521+
Vec::new(),
522+
threads,
523+
Arc::new(AtomicBool::new(false)),
524+
false,
525+
true,
526+
)?;
527+
let paths_outside: Vec<String> = results_outside
528+
.matches
529+
.into_iter()
530+
.map(|m| m.path)
531+
.collect();
532+
assert_eq!(paths_outside, Vec::<String>::new());
533+
534+
Ok(())
535+
}
456536
}
537+

0 commit comments

Comments
 (0)