Bound Cursor project path resolution (#37747)

## Why

Resolving the working directory encoded in a Cursor project name could recursively scan large directory trees.

## What changed

- Probe a bounded set of path candidates using common filename separators instead of walking the directory tree.
- Stop after 128 probes and reject ambiguous matches or unsafe encoded components.
- Parse Windows drive prefixes separately from the encoded path.

## Testing

Added coverage for common separators, ambiguous leaf and ancestor paths, and Windows drive prefixes.

GitOrigin-RevId: 34b2a2bbe2d302fac3d2614a5e36dd3fa20cf2e5
This commit is contained in:
stefanstokic-oai
2026-08-10 13:58:26 -07:00
committed by won
parent 62cf8b8768
commit 7287b4d4c1
3 changed files with 209 additions and 83 deletions
@@ -372,23 +372,13 @@ async fn external_agent_config_secondary_source_imports_session_and_plugin_end_t
{
let codex_home = TempDir::new()?;
let source_home = secondary_external_agent_home(codex_home.path());
let project_root = codex_home.path().join("workspace with.dots_and-dashes");
let project_root = codex_home.path().join("my-project");
std::fs::create_dir_all(&project_root)?;
let encoded_project = project_root
.to_string_lossy()
.trim_start_matches(['/', '\\'])
.chars()
.map(|character| {
if character.is_ascii_alphanumeric() {
character
} else {
'-'
}
})
.collect::<String>();
#[cfg(windows)]
let encoded_project = encoded_project.replacen("--", "-", /*count*/ 1);
.replace([':', '/', '\\'], "-");
let session_path = source_home
.join("projects")
.join(encoded_project)
@@ -8,6 +8,10 @@ use std::io;
use std::path::Path;
use std::path::PathBuf;
const MAX_CUR_PROJECT_PATH_PROBES: usize = 128;
const CUR_PROJECT_SEPARATORS: [&str; 11] =
["-", "_", ".", " ", "--", "..", "__", " ", "+", "@", "&"];
pub fn detect_recent_cur_sessions(
external_agent_home: &Path,
codex_home: &Path,
@@ -84,84 +88,126 @@ fn cur_project_cwd(project_storage: &Path) -> Option<PathBuf> {
decode_cur_project_path(encoded)
}
#[cfg(not(windows))]
fn decode_cur_project_path(encoded: &str) -> Option<PathBuf> {
let root = Path::new("/");
let mut matches = Vec::new();
collect_cur_project_paths(encoded, root, root, /*depth*/ 0, &mut matches);
if let Some(encoded) = encoded.strip_prefix('-') {
collect_cur_project_paths(encoded, root, root, /*depth*/ 0, &mut matches);
#[cfg(not(windows))]
let mut path = PathBuf::from("/");
#[cfg(windows)]
let (encoded, mut path) = {
let (drive, encoded) = decode_cur_windows_project_drive(encoded)?;
(encoded, PathBuf::from(format!("{drive}:\\")))
};
let encoded = encoded.strip_prefix('-').unwrap_or(encoded);
for component in encoded.split('-') {
if component.is_empty()
|| matches!(component, "." | "..")
|| component.contains(['/', '\\', ':'])
{
return None;
}
path.push(component);
}
unique_path(matches)
let mut matched_path = None;
let mut probes = 0;
let mut inspect = |candidate: PathBuf| {
if probes >= MAX_CUR_PROJECT_PATH_PROBES {
return None;
}
probes += 1;
if candidate.is_dir() {
if matched_path
.as_ref()
.is_some_and(|matched_path| matched_path != &candidate)
{
return None;
}
matched_path = Some(candidate);
}
Some(())
};
inspect(path.clone())?;
for suffix_length in 2..=4 {
let mut parent = path.as_path();
let mut suffix = Vec::with_capacity(suffix_length);
for _ in 0..suffix_length {
let Some(component) = parent.file_name().and_then(|name| name.to_str()) else {
break;
};
suffix.push(component);
let Some(ancestor) = parent.parent() else {
break;
};
parent = ancestor;
}
if suffix.len() != suffix_length {
break;
}
suffix.reverse();
for separator in CUR_PROJECT_SEPARATORS {
inspect(parent.join(suffix.join(separator)))?;
}
}
let mut ancestor = path.parent();
while let Some(right) = ancestor {
let Some(right_name) = right.file_name().and_then(|name| name.to_str()) else {
break;
};
let Some(left) = right.parent() else {
break;
};
let Some(left_name) = left.file_name().and_then(|name| name.to_str()) else {
break;
};
let Some(prefix) = left.parent() else {
break;
};
let Ok(trailing) = path.strip_prefix(right) else {
return None;
};
for separator in CUR_PROJECT_SEPARATORS {
let merged_prefix = prefix.join(format!("{left_name}{separator}{right_name}"));
if probes >= MAX_CUR_PROJECT_PATH_PROBES {
return None;
}
probes += 1;
if !merged_prefix.is_dir() {
continue;
}
if probes >= MAX_CUR_PROJECT_PATH_PROBES {
return None;
}
probes += 1;
let candidate = merged_prefix.join(trailing);
if !candidate.is_dir()
|| matched_path
.as_ref()
.is_some_and(|matched_path| matched_path != &candidate)
{
return None;
}
matched_path = Some(candidate);
}
ancestor = Some(left);
}
matched_path
}
#[cfg(windows)]
fn decode_cur_project_path(encoded: &str) -> Option<PathBuf> {
#[cfg(any(windows, test))]
fn decode_cur_windows_project_drive(encoded: &str) -> Option<(char, &str)> {
let drive = encoded.as_bytes().first().copied()?;
if !drive.is_ascii_alphabetic() || encoded.as_bytes().get(1) != Some(&b'-') {
return None;
}
let encoded = encoded.get(2..)?;
let base = PathBuf::from(format!("{}:\\", char::from(drive)));
let mut matches = Vec::new();
collect_cur_project_paths(encoded, &base, &base, /*depth*/ 0, &mut matches);
unique_path(matches)
}
fn collect_cur_project_paths(
encoded: &str,
base: &Path,
root: &Path,
depth: usize,
matches: &mut Vec<PathBuf>,
) {
if encoded.is_empty() || depth > 32 || matches.len() > 1 {
return;
}
let Ok(entries) = fs::read_dir(base) else {
return;
};
for entry in entries.flatten() {
if matches.len() > 1 {
break;
}
let candidate = entry.path();
if !candidate.is_dir() {
continue;
}
let Ok(candidate_from_root) = candidate.strip_prefix(root) else {
continue;
};
let candidate_slug = cur_project_path_slug(candidate_from_root);
if candidate_slug == encoded {
if !matches.contains(&candidate) {
matches.push(candidate);
}
} else if encoded
.strip_prefix(&candidate_slug)
.is_some_and(|remaining| remaining.starts_with('-'))
{
collect_cur_project_paths(encoded, &candidate, root, depth + 1, matches);
}
}
}
fn cur_project_path_slug(path: &Path) -> String {
path.to_string_lossy()
.trim_start_matches(['/', '\\'])
.chars()
.map(|character| {
if character.is_ascii_alphanumeric() {
character
} else {
'-'
}
})
.collect()
}
fn unique_path(mut matches: Vec<PathBuf>) -> Option<PathBuf> {
(matches.len() == 1).then(|| matches.swap_remove(0))
Some((char::from(drive), encoded.get(2..)?))
}
#[cfg(test)]
@@ -138,6 +138,94 @@ fn rejects_ambiguous_encoded_project_cwd() {
);
}
#[test]
fn resolves_cur_project_names_with_common_separators() {
for (project_name, encoded_name) in [
("project", "project"),
("my-project", "my-project"),
("my--project", "my-project"),
("my project", "my-project"),
("my.project", "my-project"),
("my..project", "my-project"),
("my_project", "my-project"),
("my+project", "my-project"),
("my@project", "my-project"),
("my&project", "my-project"),
("my-awesome-project", "my-awesome-project"),
("my-awesome-cool-project", "my-awesome-cool-project"),
] {
let root = TempDir::new().expect("tempdir");
let project = root.path().join(project_name);
fs::create_dir_all(&project).expect("project root");
let encoded = format!("{}-{encoded_name}", encode_project_path(root.path()));
assert_eq!(decode_cur_project_path(&encoded), Some(project));
}
}
#[test]
fn rejects_ambiguous_cur_project_without_a_direct_match() {
let root = TempDir::new().expect("tempdir");
for project_name in ["my-project", "my project", "my+project"] {
fs::create_dir_all(root.path().join(project_name)).expect("project root");
}
let encoded = format!("{}-my-project", encode_project_path(root.path()));
assert_eq!(decode_cur_project_path(&encoded), None);
}
#[test]
fn rejects_ambiguous_cur_project_with_punctuated_ancestor() {
let root = TempDir::new().expect("tempdir");
let punctuated_ancestor = root.path().join("a-b").join("c");
let punctuated_leaf = root.path().join("a").join("b-c");
fs::create_dir_all(&punctuated_ancestor).expect("punctuated ancestor project");
fs::create_dir_all(&punctuated_leaf).expect("punctuated leaf project");
let encoded = encode_project_path(&punctuated_ancestor);
assert_eq!(encoded, encode_project_path(&punctuated_leaf));
assert_eq!(decode_cur_project_path(&encoded), None);
}
#[test]
fn rejects_ambiguous_cur_project_with_multiple_punctuated_ancestors() {
for (first, second) in [
(&["a-b", "c-d", "e"][..], &["a", "b", "c", "d-e"][..]),
(&["a-b", "c-d"][..], &["a", "b", "c", "d"][..]),
] {
let root = TempDir::new().expect("tempdir");
let first = first
.iter()
.fold(root.path().to_path_buf(), |path, component| {
path.join(component)
});
let second = second
.iter()
.fold(root.path().to_path_buf(), |path, component| {
path.join(component)
});
fs::create_dir_all(&first).expect("first project");
fs::create_dir_all(&second).expect("second project");
let encoded = encode_project_path(&first);
assert_eq!(encoded, encode_project_path(&second));
assert_eq!(decode_cur_project_path(&encoded), None);
}
}
#[test]
fn parses_windows_cursor_fixture_project_directory() {
assert_eq!(
decode_cur_windows_project_drive("C--Users-fixture-Cursor"),
Some(('C', "-Users-fixture-Cursor"))
);
assert_eq!(
decode_cur_windows_project_drive("C-Users-fixture-Cursor"),
Some(('C', "Users-fixture-Cursor"))
);
assert_eq!(decode_cur_windows_project_drive("1-Users-fixture"), None);
}
#[test]
fn ignores_cur_sessions_older_than_import_window() {
let root = TempDir::new().expect("tempdir");
@@ -296,10 +384,12 @@ fn set_modified_at(path: &Path, modified_at: SystemTime) {
#[cfg(windows)]
fn encode_project_path(path: &Path) -> String {
cur_project_path_slug(path).replacen("--", "-", 1)
path.to_string_lossy().replace([':', '\\', '/'], "-")
}
#[cfg(not(windows))]
fn encode_project_path(path: &Path) -> String {
cur_project_path_slug(path)
path.to_string_lossy()
.trim_start_matches('/')
.replace('/', "-")
}