Skip to content

Commit dc34649

Browse files
fix(runtime): accept other Windows spellings of a workspace path
validate_workspace_boundary compared paths lexically, so a location inside the workspace spelled with different letter case (a working directory typed as c:\users\... for C:\Users\...) or through the \\.\ device namespace was reported as escaping the workspace. When the lexical comparison fails, compare the path's canonical form before reporting an escape. A path whose canonical form is outside the workspace stays rejected.
1 parent dbf5389 commit dc34649

1 file changed

Lines changed: 75 additions & 11 deletions

File tree

‎rust/crates/runtime/src/file_ops.rs‎

Lines changed: 75 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -40,19 +40,32 @@ fn is_binary_file(path: &Path) -> io::Result<bool> {
4040
/// the workspace boundary (e.g. via `../` traversal or symlink).
4141
#[allow(dead_code)]
4242
fn validate_workspace_boundary(resolved: &Path, workspace_root: &Path) -> io::Result<()> {
43-
let resolved = normalize_for_comparison(resolved);
4443
let workspace_root = normalize_for_comparison(workspace_root);
45-
if !resolved.starts_with(&workspace_root) {
46-
return Err(io::Error::new(
47-
io::ErrorKind::PermissionDenied,
48-
format!(
49-
"path {} escapes workspace boundary {}",
50-
resolved.display(),
51-
workspace_root.display()
52-
),
53-
));
44+
let comparable = normalize_for_comparison(resolved);
45+
if comparable.starts_with(&workspace_root) || resolves_within(resolved, &workspace_root) {
46+
return Ok(());
5447
}
55-
Ok(())
48+
Err(io::Error::new(
49+
io::ErrorKind::PermissionDenied,
50+
format!(
51+
"path {} escapes workspace boundary {}",
52+
comparable.display(),
53+
workspace_root.display()
54+
),
55+
))
56+
}
57+
58+
/// Whether `resolved` names a location inside `workspace_root` once the
59+
/// filesystem resolves it. Windows accepts several spellings of one location
60+
/// that [`Path::starts_with`] treats as different: letter case (a working
61+
/// directory typed as `c:\users\...` for `C:\Users\...`), the `\\.\` device
62+
/// namespace, and 8.3 short names. Only consulted after the lexical comparison
63+
/// fails, so it can accept such a spelling but never widens what already
64+
/// compared inside, and a path whose canonical form is outside stays rejected.
65+
fn resolves_within(resolved: &Path, workspace_root: &Path) -> bool {
66+
resolved
67+
.canonicalize()
68+
.is_ok_and(|canonical| normalize_for_comparison(&canonical).starts_with(workspace_root))
5669
}
5770

5871
/// Text payload returned by file-reading operations.
@@ -958,6 +971,57 @@ mod tests {
958971
.expect("equivalent Windows path representations should be accepted");
959972
}
960973

974+
#[test]
975+
#[cfg(unix)]
976+
fn accepts_a_path_that_resolves_inside_the_workspace() {
977+
let workspace = temp_path("boundary-alias-workspace");
978+
let aliases = temp_path("boundary-alias-links");
979+
std::fs::create_dir_all(workspace.join("src")).expect("workspace dir should be created");
980+
std::fs::create_dir_all(&aliases).expect("alias dir should be created");
981+
let alias = aliases.join("src");
982+
std::os::unix::fs::symlink(workspace.join("src"), &alias).expect("symlink should create");
983+
let root = workspace
984+
.canonicalize()
985+
.expect("workspace should canonicalize");
986+
987+
super::validate_workspace_boundary(&alias, &root)
988+
.expect("a spelling that resolves inside the workspace should be accepted");
989+
super::validate_workspace_boundary(&aliases, &root)
990+
.expect_err("a path that resolves outside the workspace must stay rejected");
991+
992+
let _ = std::fs::remove_dir_all(&workspace);
993+
let _ = std::fs::remove_dir_all(&aliases);
994+
}
995+
996+
// Windows resolves one location from spellings that compare unequal as
997+
// paths, letter case and the `\\.\` device namespace among them.
998+
#[test]
999+
#[cfg(windows)]
1000+
fn accepts_other_windows_spellings_of_a_workspace_path() {
1001+
let workspace = temp_path("Boundary-Case-Workspace");
1002+
std::fs::create_dir_all(workspace.join("src")).expect("workspace dir should be created");
1003+
let root = workspace
1004+
.canonicalize()
1005+
.expect("workspace should canonicalize");
1006+
1007+
let lower = PathBuf::from(workspace.join("src").to_string_lossy().to_lowercase());
1008+
super::validate_workspace_boundary(&lower, &root)
1009+
.expect("a lower-cased spelling of a workspace path should be accepted");
1010+
1011+
let device = PathBuf::from(format!(r"\\.\{}", workspace.join("src").display()));
1012+
super::validate_workspace_boundary(&device, &root)
1013+
.expect("a device-namespace spelling of a workspace path should be accepted");
1014+
1015+
let parent = workspace
1016+
.parent()
1017+
.expect("workspace has a parent")
1018+
.to_path_buf();
1019+
super::validate_workspace_boundary(&parent, &root)
1020+
.expect_err("the workspace's parent must stay rejected");
1021+
1022+
let _ = std::fs::remove_dir_all(&workspace);
1023+
}
1024+
9611025
#[test]
9621026
#[cfg(unix)]
9631027
fn workspace_write_rejects_parent_symlink_escape_regression_3007_class() {

0 commit comments

Comments
 (0)