Skip to content

Commit d535582

Browse files
divybotlittledivy
andauthored
fix(permissions): treat Windows \\?\ verbatim paths as equivalent (#35096)
On Windows, requesting a path through its `\\?\` verbatim (extended-length) form — e.g. `\\?\C:\foo` — was permission-checked as a *distinct* path from the regular `C:\foo`. As a result, a directory already granted read/write access would unexpectedly trigger a new permission prompt: ``` > deno repl --allow-read=. > Array.from(Deno.readDirSync(Deno.cwd())) // no prompt [] > Array.from(Deno.readDirSync('\\\\?\\' + Deno.cwd())) // unexpected prompt ✅ Granted read access to "\\?\C:\Users\Student\Empty". [] ``` This also breaks node polyfills that hand `\\?\`-prefixed paths to fs ops (e.g. `_fs_chmod.ts`, see wojpawlik/deno2node#39). ### Cause The path descriptors in `deno_permissions` build their comparison key with `deno_path_util::normalize_path`, which walks `path.components()`. Rust parses `\\?\C:\foo` as a `Component::Prefix(VerbatimDisk)`, and `normalize_path` preserves that verbatim prefix, so `\\?\C:\foo` and `C:\foo` never compare equal. The existing `is_windows_device_path` branch only handled `\\.\` device-namespace paths. ### Fix Strip the verbatim prefix via `dunce::simplified` before normalization, at the three path-descriptor construction sites (`PathQueryDescriptor::new`, `PathQueryDescriptor::new_known_absolute`, `PathDescriptor::new_known_cwd`). `dunce::simplified` only strips the prefix when the regular form is genuinely equivalent — it refuses paths containing `.`/`..` components (taken literally in verbatim mode), reserved DOS device names (`CON`, `NUL`, `COM1`, …), or paths that exceed the legacy length limit. Those forms are *not* the same file as their stripped counterpart, so this conservative behavior avoids over-granting. On non-Windows platforms the helper is a no-op, so behavior there is unchanged. A `#[cfg(windows)]` unit test (`check_path_verbatim_prefix`) covers both directions (grant regular → request verbatim, and grant verbatim → request regular) plus a negative case. Closes #18597 Closes denoland/divybot#553 --------- Co-authored-by: divybot <divybot@users.noreply.github.com> Co-authored-by: Divy Srivastava <me@littledivy.com>
1 parent ee9e5c7 commit d535582

4 files changed

Lines changed: 69 additions & 0 deletions

File tree

Cargo.lock

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

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,7 @@ dprint-plugin-json = "=0.21.3"
352352
dprint-plugin-jupyter = "=0.2.2"
353353
dprint-plugin-markdown = "=0.20.0"
354354
dprint-plugin-typescript = "=0.96.1"
355+
dunce = "1.0.5"
355356
env_logger = { version = "=0.11.6", default-features = false, features = ["regex"] }
356357
imara-diff = "=0.2.0"
357358
libsui = "0.12.6"

runtime/permissions/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ which.workspace = true
3737
unicode-normalization = { workspace = true }
3838

3939
[target.'cfg(windows)'.dependencies]
40+
dunce.workspace = true
4041
windows-sys = { workspace = true, features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Console", "Win32_UI_Input_KeyboardAndMouse"] }
4142

4243
[target.'cfg(unix)'.dependencies]

runtime/permissions/lib.rs

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1345,6 +1345,34 @@ fn comparison_path(path: &Path) -> PathBuf {
13451345
path.to_path_buf()
13461346
}
13471347

1348+
/// On Windows, strips a `\\?\` verbatim (extended-length) prefix from a path
1349+
/// when it can be losslessly represented without it, so that the permission
1350+
/// system treats `\\?\C:\foo` and `C:\foo` as the same path. The two forms
1351+
/// refer to the same file, so a grant for one must apply to the other (see
1352+
/// denoland/deno#18597).
1353+
///
1354+
/// `dunce::simplified` only strips the prefix when it is actually safe to do
1355+
/// so: it leaves verbatim paths that contain `.`/`..` components (taken
1356+
/// literally in verbatim mode), reserved device names (e.g. `CON`), or that
1357+
/// are too long to be expressed as a regular path, because those forms are
1358+
/// *not* equivalent to their stripped counterparts.
1359+
#[cfg(windows)]
1360+
#[inline]
1361+
fn strip_verbatim_prefix(path: Cow<'_, Path>) -> Cow<'_, Path> {
1362+
let simplified = dunce::simplified(path.as_ref());
1363+
if simplified.as_os_str().len() == path.as_os_str().len() {
1364+
path
1365+
} else {
1366+
Cow::Owned(simplified.to_path_buf())
1367+
}
1368+
}
1369+
1370+
#[cfg(not(windows))]
1371+
#[inline]
1372+
fn strip_verbatim_prefix(path: Cow<'_, Path>) -> Cow<'_, Path> {
1373+
path
1374+
}
1375+
13481376
impl<'a> PathQueryDescriptor<'a> {
13491377
pub fn new(
13501378
sys: &impl sys_traits::EnvCurrentDir,
@@ -1354,6 +1382,8 @@ impl<'a> PathQueryDescriptor<'a> {
13541382
if path_bytes.is_empty() {
13551383
return Err(PathResolveError::EmptyPath);
13561384
}
1385+
let path = strip_verbatim_prefix(path);
1386+
let path_bytes = path.as_os_str().as_encoded_bytes();
13571387
let is_windows_device_path = cfg!(windows)
13581388
&& path_bytes.starts_with(br"\\.\")
13591389
&& !path_bytes.contains(&b':');
@@ -1382,6 +1412,7 @@ impl<'a> PathQueryDescriptor<'a> {
13821412
}
13831413

13841414
pub fn new_known_absolute(path: Cow<'a, Path>) -> Self {
1415+
let path = strip_verbatim_prefix(path);
13851416
let path_bytes = path.as_os_str().as_encoded_bytes();
13861417
let is_windows_device_path = cfg!(windows)
13871418
&& path_bytes.starts_with(br"\\.\")
@@ -1546,6 +1577,7 @@ impl PathDescriptor {
15461577
}
15471578

15481579
pub fn new_known_cwd(path: Cow<'_, Path>, cwd: &Path) -> Self {
1580+
let path = strip_verbatim_prefix(path);
15491581
let path_bytes = path.as_os_str().as_encoded_bytes();
15501582
let is_windows_device_path = cfg!(windows)
15511583
&& path_bytes.starts_with(br"\\.\")
@@ -9822,6 +9854,40 @@ mod tests {
98229854
);
98239855
}
98249856

9857+
#[test]
9858+
#[cfg(windows)]
9859+
fn path_descriptor_verbatim_prefix_equivalent() {
9860+
// A `\\?\` verbatim (extended-length) path and its regular form refer to
9861+
// the same file, so the permission system must treat them as equal
9862+
// (denoland/deno#18597).
9863+
let regular = PathDescriptor::new_known_absolute(Cow::Borrowed(Path::new(
9864+
"C:\\Users\\Admin",
9865+
)));
9866+
let verbatim = PathDescriptor::new_known_absolute(Cow::Borrowed(
9867+
Path::new("\\\\?\\C:\\Users\\Admin"),
9868+
));
9869+
assert_eq!(regular, verbatim);
9870+
// The stored path is the simplified form, not the verbatim one.
9871+
assert_eq!(verbatim.path, PathBuf::from("C:\\Users\\Admin"));
9872+
9873+
// A `\\?\` query is contained by a grant made with the regular path...
9874+
let query = PathQueryDescriptor::new_known_absolute(Cow::Borrowed(
9875+
Path::new("\\\\?\\C:\\Users\\Admin\\file.txt"),
9876+
));
9877+
assert!(query.starts_with(&regular));
9878+
// ...and a regular query is contained by a grant made with a `\\?\` path.
9879+
let query = PathQueryDescriptor::new_known_absolute(Cow::Borrowed(
9880+
Path::new("C:\\Users\\Admin\\file.txt"),
9881+
));
9882+
assert!(query.starts_with(&verbatim));
9883+
9884+
// An unrelated verbatim path is not contained.
9885+
let query = PathQueryDescriptor::new_known_absolute(Cow::Borrowed(
9886+
Path::new("\\\\?\\C:\\Other\\file.txt"),
9887+
));
9888+
assert!(!query.starts_with(&regular));
9889+
}
9890+
98259891
#[test]
98269892
fn test_is_allow_all_edge_cases() {
98279893
let parser = TestPermissionDescriptorParser;

0 commit comments

Comments
 (0)