mirror of
https://github.com/openai/codex.git
synced 2026-10-18 17:18:52 +00:00
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:
committed by
copyberry
parent
c0ad3ab014
commit
f344a80a3b
@@ -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,
|
||||
@@ -93,84 +97,126 @@ fn cur_project_cwd(project_storage: &Path, external_agent_home: &Path) -> Option
|
||||
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)]
|
||||
|
||||
@@ -175,6 +175,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");
|
||||
@@ -333,10 +421,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('/', "-")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user