mirror of
https://github.com/openai/codex.git
synced 2026-10-20 08:19:50 +00:00
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:
BIN
Binary file not shown.
BIN
Binary file not shown.
+4
-3
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user