Make Windows path URI comparisons ASCII-case-insensitive (#37129)

## What changed

- Make `PathUri` equality and hashing ignore ASCII case for inferred Windows
  drive and UNC paths while preserving case-sensitive POSIX behavior.
- Apply the same convention-aware comparison to `starts_with` and
  `relative_path_from`, including equivalent percent-encoded path spellings.
- Continue to fail closed for encoded path separators, opaque fallback URIs,
  and non-ASCII case differences.

## Testing

- Add unit coverage for equality and hash consistency, Windows containment and
  relative paths, POSIX case sensitivity, encoded separators, and non-ASCII
  case differences.

GitOrigin-RevId: cd828874686a9cd242a36262ee3e7a9a172bd5b7
This commit is contained in:
iceweasel-oai
2026-08-05 17:55:57 +00:00
committed by copyberry
parent 778b869829
commit 4cb8676d3a
5 changed files with 155 additions and 25 deletions
+4 -3
View File
@@ -18,9 +18,10 @@
*
* Like [VS Code resources], path operations use `/` URI separators on every
* host. Lexical path operations preserve a URL authority without interpreting
* Windows drive or UNC roots from path text. Native path normalization,
* filesystem aliases, symlinks, case sensitivity, and Unicode normalization
* are not resolved.
* Windows drive or UNC roots from path text. Windows path equality and hashing
* ignore ASCII case, while POSIX paths remain case-sensitive. Native path
* normalization, filesystem aliases, symlinks, and Unicode normalization are
* not resolved.
*
* Serde represents a `PathUri` as its canonical URI string. Deserialization
* accepts only valid `file:` URI strings. These strings round-trip through
+94 -22
View File
@@ -10,7 +10,10 @@ use serde::Deserialize;
use serde::Deserializer;
use serde::Serialize;
use serde::Serializer;
use std::borrow::Cow;
use std::fmt;
use std::hash::Hash;
use std::hash::Hasher;
use std::io;
use std::path::Path;
use std::path::PathBuf;
@@ -45,19 +48,53 @@ const BAD_PATH_URI_PREFIX: &str = "file:///%00/bad/path/";
///
/// Like [VS Code resources], path operations use `/` URI separators on every
/// host. Lexical path operations preserve a URL authority without interpreting
/// Windows drive or UNC roots from path text. Native path normalization,
/// filesystem aliases, symlinks, case sensitivity, and Unicode normalization
/// are not resolved.
/// Windows drive or UNC roots from path text. Windows path equality and hashing
/// ignore ASCII case, while POSIX paths remain case-sensitive. Native path
/// normalization, filesystem aliases, symlinks, and Unicode normalization are
/// not resolved.
///
/// Serde represents a `PathUri` as its canonical URI string. Deserialization
/// accepts only valid `file:` URI strings. These strings round-trip through
/// their canonical URL form, including encoded non-UTF-8 path bytes.
///
/// [VS Code resources]: https://github.com/microsoft/vscode/blob/main/src/vs/base/common/resources.ts
#[derive(Clone, Debug, PartialEq, Eq, Hash, TS)]
#[derive(Clone, Debug, TS)]
#[ts(type = "string")]
pub struct PathUri(Url);
impl PartialEq for PathUri {
fn eq(&self, other: &Self) -> bool {
if self.0 == other.0 {
return true;
}
let (Some(path), Some(other_path)) = (
self.windows_identity_path_bytes(),
other.windows_identity_path_bytes(),
) else {
return false;
};
self.0.host_str() == other.0.host_str() && path.eq_ignore_ascii_case(&other_path)
}
}
impl Eq for PathUri {}
impl Hash for PathUri {
fn hash<H: Hasher>(&self, state: &mut H) {
// Preserve URL hashing for POSIX paths; Windows paths must hash the
// same decoded, ASCII-folded identity that `PartialEq` compares.
let Some(path) = self.windows_identity_path_bytes() else {
self.0.hash(state);
return;
};
self.0.host_str().hash(state);
path.len().hash(state);
for byte in path.as_ref() {
byte.to_ascii_lowercase().hash(state);
}
}
}
impl PathUri {
/// Parses and validates a `file:` URI.
pub fn parse(uri: &str) -> Result<Self, PathUriParseError> {
@@ -136,6 +173,22 @@ impl PathUri {
self.0.path()
}
fn windows_identity_path_bytes(&self) -> Option<Cow<'_, [u8]>> {
if self.infer_path_convention() != Some(PathConvention::Windows)
|| self.opaque_fallback_bytes().is_some()
|| self.0.path_segments()?.any(|segment| {
urlencoding::decode_binary(segment.as_bytes())
.iter()
.any(|byte| matches!(byte, b'/' | b'\\'))
})
{
return None;
}
// Decode equivalent URI spellings here; comparisons and hashing apply
// ASCII case folding to these shared Windows identity bytes.
Some(urlencoding::decode_binary(self.0.path().as_bytes()))
}
fn opaque_fallback_bytes(&self) -> Option<Vec<u8>> {
decode_bad_path_uri(&self.0)
}
@@ -251,10 +304,11 @@ impl PathUri {
/// Returns true when this URI is lexically equal to or below `base`.
///
/// Containment is computed using URI authority and path-segment boundaries,
/// without consulting the host filesystem. Percent-encoded native path
/// separators fail closed because native path conversion may interpret them
/// as segment boundaries. Opaque fallback URIs created by
/// [`Self::from_abs_path`] only contain themselves.
/// without consulting the host filesystem. Windows path segments are
/// compared ASCII-case-insensitively; POSIX path segments remain case-sensitive.
/// Percent-encoded native path separators fail closed because native path
/// conversion may interpret them as segment boundaries. Opaque fallback
/// URIs created by [`Self::from_abs_path`] only contain themselves.
pub fn starts_with(&self, base: &Self) -> bool {
if self == base {
return true;
@@ -266,29 +320,27 @@ impl PathUri {
return false;
}
let Some(path_segments) = containment_path_segments(
&self.0,
self.infer_path_convention()
.unwrap_or(PathConvention::Posix),
) else {
let convention = self.infer_path_convention();
if convention != base.infer_path_convention() {
return false;
}
let convention = convention.unwrap_or(PathConvention::Posix);
let Some(path_segments) = containment_path_segments(&self.0, convention) else {
return false;
};
let Some(base_segments) = containment_path_segments(
&base.0,
base.infer_path_convention()
.unwrap_or(PathConvention::Posix),
) else {
let Some(base_segments) = containment_path_segments(&base.0, convention) else {
return false;
};
path_segments.starts_with(&base_segments)
native_path_segments_start_with(&path_segments, &base_segments, convention)
}
/// Returns the decoded relative path from `base` to this URI.
///
/// The result uses the separators of the inferred path convention,
/// independently of the current host. Both URIs must use the same inferred
/// path convention and authority, and this URI must be lexically equal to
/// or below `base`. Percent-encoded native path separators fail closed.
/// path convention and authority, and this URI must be equal to or below
/// `base` under that convention's case sensitivity. Percent-encoded native
/// path separators fail closed.
/// Opaque fallback URIs created by [`Self::from_abs_path`] are only relative
/// to themselves.
pub fn relative_path_from(&self, base: &Self) -> Option<String> {
@@ -306,7 +358,10 @@ impl PathUri {
let convention = self.infer_path_convention()?;
let path_segments = containment_path_segments(&self.0, convention)?;
let base_segments = containment_path_segments(&base.0, convention)?;
let relative_segments = path_segments.strip_prefix(base_segments.as_slice())?;
if !native_path_segments_start_with(&path_segments, &base_segments, convention) {
return None;
}
let relative_segments = &path_segments[base_segments.len()..];
let separator = match convention {
PathConvention::Posix => "/",
PathConvention::Windows => "\\",
@@ -648,6 +703,23 @@ fn containment_path_segments(url: &Url, convention: PathConvention) -> Option<Ve
.then_some(segments)
}
fn native_path_segments_start_with(
path_segments: &[&str],
base_segments: &[&str],
convention: PathConvention,
) -> bool {
match convention {
PathConvention::Posix => path_segments.starts_with(base_segments),
PathConvention::Windows => {
path_segments.len() >= base_segments.len()
&& path_segments.iter().zip(base_segments).all(|(path, base)| {
path.eq_ignore_ascii_case(base)
|| decode_uri_path(path).eq_ignore_ascii_case(&decode_uri_path(base))
})
}
}
}
fn infer_opaque_path_convention(path_bytes: &[u8]) -> Option<PathConvention> {
if path_bytes.starts_with(b"/") {
return Some(PathConvention::Posix);
+57
View File
@@ -80,6 +80,35 @@ fn file_uri_normalizes_windows_drive_letter_case() {
);
}
#[test]
fn path_uri_equality_and_hashing_follow_path_convention() {
for (left, right, expected) in [
("file:///C:/Users/Alice", "file:///c:/users/ALICE", true),
(
"file://SERVER/SHARE/Project",
"file://server/share/project",
true,
),
("file:///home/Alice", "file:///home/alice", false),
("file:///C:/plugins/Lj", "file:///C:/plugins/LJ", false),
("file:///C:/plugins/%41", "file:///C:/plugins/a", true),
("file:///C:/plugins/a%2Fb", "file:///C:/plugins/a/b", false),
("file:///%00/bad/path/YQ", "file:///%00/bad/path/yQ", false),
] {
let left = PathUri::parse(left).expect("valid left URI");
let right = PathUri::parse(right).expect("valid right URI");
assert_eq!(
(
left == right,
std::collections::HashSet::from([left]).contains(&right),
),
(expected, expected),
"comparing {right}"
);
}
}
#[test]
fn infers_path_conventions_from_uri_shape() {
for (uri, expected) in [
@@ -187,11 +216,21 @@ fn relative_path_from_is_host_independent() {
"file:///C:/Users/Alice/project",
Some(r"src\main.rs"),
),
(
"file:///C:/USERS/%C3%84/PROJECT/src/main.rs",
"file:///c:/users/%C3%A4/project",
None,
),
(
"file://server/share/project/src/main.rs",
"file://server/share/project",
Some(r"src\main.rs"),
),
(
"file://SERVER/SHARE/PROJECT/src/main.rs",
"file://server/share/project",
Some(r"src\main.rs"),
),
(
"file:///home/alice/project",
"file:///home/alice/project/",
@@ -202,6 +241,7 @@ fn relative_path_from_is_host_independent() {
"file:///home/alice/project",
None,
),
("file:///HOME/alice/project", "file:///home", None),
(
"file://other/share/project/main.rs",
"file://server/share/project",
@@ -1008,6 +1048,17 @@ fn starts_with_uses_uri_segment_boundaries() {
"file:///C:/plugins/foo",
true,
),
("file:///C:/project/secret", "file:///%63%3A/project", false),
(
"file:///C:/PLUGINS/%C3%84/assets/icon.svg",
"file:///c:/plugins/%C3%A4",
false,
),
(
"file:///C:/plugins/Lj/assets/icon.svg",
"file:///C:/plugins/LJ",
false,
),
(
"file:///C:/plugins/foo2/assets/icon.svg",
"file:///C:/plugins/foo",
@@ -1018,6 +1069,12 @@ fn starts_with_uses_uri_segment_boundaries() {
"file://server/share/plugins/foo",
true,
),
(
"file://SERVER/SHARE/PLUGINS/FOO/icon.svg",
"file://server/share/plugins/foo",
true,
),
("file:///WORKSPACE/plugin", "file:///workspace", false),
(
"file://other/share/plugins/foo/icon.svg",
"file://server/share/plugins/foo",