Root cause
Other / insufficient public mechanism detail
fix: use case-insensitive path matching for permissions on Windows (#33073)
How AI contributed: Flawed AI-written codePublished Jun 16, 2026 in
denoland/deno
Deno's permission layer compares requested paths against allow and deny rules through comparison_path in runtime/permissions/lib.rs, which the AI-written change made case-insensitive on Windows only. On macOS the comparison remained byte-for-byte, but APFS treats Unicode-normalization variants such as the fi ligature or the German sharp s as the same file, so a path written differently bypassed its deny rule for read, write, run or ffi. The fix normalizes and case-folds macOS paths before comparing them.
comparison_path in runtime/permissions/lib.rs lowercased paths under Windows and returned an unchanged comparison elsewhere, so on macOS a path in a different Unicode normalization form never equaled the denied path for the same file. The fix adds a macOS branch that NFKD-normalizes and case-folds before comparing.
Root cause
fix: use case-insensitive path matching for permissions on Windows (#33073)
Fix
fix(permissions): normalize paths for macOS filesystem comparison (#33451)
Fix by Bartek Iwańczuk · no AI marker found
Case facts
@@ -1292,22 +1292,36 @@ impl< #[derive(Clone, Debug)] pub struct PathQueryDescriptor<'a> { path: Cow<'a, Path>,+ /// Lowercased on Windows for case-insensitive comparison; same as `path` on+ /// other platforms. Used by PartialEq, starts_with, etc.+ cmp_path: PathBuf, /// Custom requested display name when differs from resolved. requested: Option<String>, is_windows_device_path: bool, } impl PartialEq for PathQueryDescriptor<'_> { fn eq(&self, other: &Self) -> bool {- self.path == other.path+ self.cmp_path == other.cmp_path } } impl Eq for PathQueryDescriptor<'_> {} impl PartialEq<PathDescriptor> for PathQueryDescriptor<'_> { fn eq(&self, other: &PathDescriptor) -> bool {- self.path == other.path+ self.cmp_path == other.cmp_path+ }+}++/// On Windows, returns a lowercased copy of the path for case-insensitive+/// comparison, matching NTFS behavior. On other platforms, returns a clone.+#[inline]+fn comparison_path(path: &Path) -> PathBuf {+ if cfg!(windows) {+ PathBuf::from(path.to_string_lossy().to_ascii_lowercase())+ } else {+ path.to_path_buf() } } @@ -1338,8 +1352,10 @@ impl<'a> PathQueryDescriptor<'a> { Some(path.to_string_lossy().into_owned()), ) };+ let cmp_path = comparison_path(&path); Ok(Self { path,+ cmp_path, requested, is_windows_device_path, })@@ -1357,8 +1373,10 @@ impl<'a> PathQueryDescriptor<'a> { } else { normalize_path(path) };+ let cmp_path = comparison_path(&path); Self { path,+ cmp_path, requested: None, is_windows_device_path, }@@ -1372,7 +1390,7 @@ impl<'a> PathQueryDescriptor<'a> { } pub fn starts_with(&self, base: &PathDescriptor) -> bool {- self.path.starts_with(&base.path)+ self.cmp_path.starts_with(&base.cmp_path) } pub fn display_name(&self) -> Cow<'_, str> {@@ -1385,6 +1403,7 @@ impl<'a> PathQueryDescriptor<'a> { pub fn as_descriptor(&self) -> PathDescriptor { PathDescriptor { path: self.path.to_path_buf(),+ cmp_path: self.cmp_path.clone(), requested: self.requested.clone(), is_windows_device_path: self.is_windows_device_path, }@@ -1393,6 +1412,7 @@ impl<'a> PathQueryDescriptor<'a> { pub fn into_descriptor(self) -> PathDescriptor { PathDescriptor { path: self.path.into_owned(),+ cmp_path: self.cmp_path, requested: self.requested, is_windows_device_path: self.is_windows_device_path, }@@ -1475,22 +1495,25 @@ impl QueryDescriptor for ReadQueryDescriptor<'_> { #[derive(Clone, Debug)] pub struct PathDescriptor { path: PathBuf,@@ -0,0 +1,44 @@+// Test that path permissions are case-insensitive on Windows.+// A deny-read rule should block access regardless of the path casing.+const dir = Deno.makeTempDirSync();+const file = dir + "\\secret.txt";+Deno.writeTextFileSync(file, "secret");++const lowerDir = dir.toLowerCase();+const lowerFile = lowerDir + "\\secret.txt";++// Write the test script to a separate temp dir (not under the denied path).+const scriptDir = Deno.makeTempDirSync();+const scriptFile = scriptDir + "\\test_read.ts";+Deno.writeTextFileSync(+ scriptFile,+ `try {+ Deno.readTextFileSync(${JSON.stringify(lowerFile)});+ console.log("BYPASSED");+} catch (e) {+ console.log("BLOCKED:" + e.constructor.name);+}+`,+);++// Spawn a subprocess with --deny-read on the original-case path,+// then run the script which reads via the lowered-case path.+const result = new Deno.Command(Deno.execPath(), {+ args: [+ "run",+ "--allow-read",+ `--deny-read=${dir}`,+ scriptFile,+ ],+}).outputSync();++const stdout = new TextDecoder().decode(result.stdout).trim();+const stderr = new TextDecoder().decode(result.stderr).trim();+if (stdout) {+ console.log(stdout);+} else {+ console.log(stderr || "NO OUTPUT");+}++Deno.removeSync(dir, { recursive: true });+Deno.removeSync(scriptDir, { recursive: true });@@ -0,0 +1,5 @@+{+ "if": "windows",+ "args": "run -A main.ts",+ "output": "[WILDCARD]BLOCKED:NotCapable\n"+}@@ -1314,15 +1314,35 @@ impl PartialEq<PathDescriptor> for PathQueryDescriptor<'_> { } } -/// On Windows, returns a lowercased copy of the path for case-insensitive-/// comparison, matching NTFS behavior. On other platforms, returns a clone.+/// Returns a normalized copy of the path for filesystem-aware comparison.+///+/// - Windows (NTFS): ASCII-lowercased for case-insensitive matching.+/// - macOS (APFS/HFS+): NFKD-normalized and Unicode-case-folded, because+/// APFS resolves Unicode-equivalent and case-differing paths to the same+/// inode (e.g. `ß`/`ss`, `fi`/`fi`, NFC/NFD forms, upper/lower).+/// - Other platforms: returns a clone (case-sensitive, no normalization). #[inline]+#[cfg(windows)] fn comparison_path(path: &Path) -> PathBuf {- if cfg!(windows) {- PathBuf::from(path.to_string_lossy().to_ascii_lowercase())- } else {- path.to_path_buf()- }+ PathBuf::from(path.to_string_lossy().to_ascii_lowercase())+}++#[inline]+#[cfg(target_os = "macos")]+fn comparison_path(path: &Path) -> PathBuf {+ use unicode_normalization::UnicodeNormalization;+ // NFKD handles canonical decomposition (NFC->NFD) and compatibility+ // decomposition (ligatures like fi->fi). Uppercase then lowercase+ // approximates Unicode case folding (maps ss->SS->ss, etc.).+ let s = path.to_string_lossy();+ let normalized: String = s.nfkd().collect();+ PathBuf::from(normalized.to_uppercase().to_lowercase())+}++#[inline]+#[cfg(not(any(windows, target_os = "macos")))]+fn comparison_path(path: &Path) -> PathBuf {+ path.to_path_buf() } impl<'a> PathQueryDescriptor<'a> {@@ -5601,6 +5621,118 @@ mod tests { } } + #[test]+ #[cfg(target_os = "macos")]+ fn check_paths_macos_unicode_normalization() {+ set_prompter(Box::new(TestPrompter));+ let parser = TestPermissionDescriptorParser;++ // Deny a path containing ß — on APFS this is the same file as one with "ss"+ let perms = Permissions::from_options(+ &parser,+ &PermissionsOptions {+ allow_read: Some(svec!["/data"]),+ deny_read: Some(svec!["/data/file_\u{00df}.txt"]),+ allow_write: Some(svec!["/data"]),+ deny_write: Some(svec!["/data/file_\u{00df}.txt"]),+ ..Default::default()+ },+ )+ .unwrap();+ let perms = PermissionsContainer::new(Arc::new(parser), perms);++ // Access via "ss" form must also be denied (APFS treats ß and ss as same file)+ assert!(+ perms+ .check_open(+ Cow::Borrowed(Path::new("/data/file_ss.txt")),+ OpenAccessKind::Read,+ Some("api")+ )+ .is_err(),+ "deny-read bypass via ß/ss equivalence"+ );+ assert!(+ perms+ .check_open(+ Cow::Borrowed(Path::new("/data/file_ss.txt")),+ OpenAccessKind::Write,+ Some("api")+ )+ .is_err(),+ "deny-write bypass via ß/ss equivalence"+ );++ // NFC é (U+00E9) vs NFD e + combining accent (U+0065 U+0301)@@ -34,6 +34,9 @@ thiserror.workspace = true url.workspace = true which.workspace = true +[target.'cfg(target_os = "macos")'.dependencies]+unicode-normalization = { workspace = true }+ [target.'cfg(windows)'.dependencies] winapi = { workspace = true, features = ["commapi", "knownfolders", "mswsock", "objbase", "psapi", "shlobj", "tlhelp32", "winbase", "winerror", "winuser", "winsock2", "processenv", "wincon", "wincontypes"] } windows-sys = { workspace = true, features = ["Win32_Storage_FileSystem"] }@@ -100,6 +100,9 @@ tokio-metrics.workspace = true twox-hash.workspace = true uuid.workspace = true +[target.'cfg(target_os = "macos")'.dependencies]+unicode-normalization.workspace = true+ [target.'cfg(windows)'.dependencies] winapi = { workspace = true, features = ["commapi", "handleapi", "knownfolders", "mswsock", "objbase", "processthreadsapi", "psapi", "shlobj", "tlhelp32", "winbase", "winerror", "winnt", "winuser", "winsock2"] } ntapi = "0.4.0"AI-assisted change 42ae1226bd1fa216af876ed397e35e7f75d4511e19fbcf662d4e13df21c4164a · Fix 567ed86ab6291781ad76e0bfad772328e7b6ee2355ae786d7f13c9f501a1adc6