diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst index 9b0b5678b3..8bab8a2234 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst differ diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst index f4cdfa1bf8..d0f47dfbae 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst differ diff --git a/codex-rs/app-server-protocol/schema/typescript/PathUri.ts b/codex-rs/app-server-protocol/schema/typescript/PathUri.ts index b3923cfdb9..ba11296ea0 100644 --- a/codex-rs/app-server-protocol/schema/typescript/PathUri.ts +++ b/codex-rs/app-server-protocol/schema/typescript/PathUri.ts @@ -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 diff --git a/codex-rs/utils/path-uri/src/lib.rs b/codex-rs/utils/path-uri/src/lib.rs index e4bb02ce33..51590b568e 100644 --- a/codex-rs/utils/path-uri/src/lib.rs +++ b/codex-rs/utils/path-uri/src/lib.rs @@ -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(&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 { @@ -136,6 +173,22 @@ impl PathUri { self.0.path() } + fn windows_identity_path_bytes(&self) -> Option> { + 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> { 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 { @@ -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 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 { if path_bytes.starts_with(b"/") { return Some(PathConvention::Posix); diff --git a/codex-rs/utils/path-uri/src/tests.rs b/codex-rs/utils/path-uri/src/tests.rs index 941f434bc2..7117a0c3bc 100644 --- a/codex-rs/utils/path-uri/src/tests.rs +++ b/codex-rs/utils/path-uri/src/tests.rs @@ -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",