diff --git a/codex-rs/utils/path-uri/src/lib.rs b/codex-rs/utils/path-uri/src/lib.rs index 28ac14e54f..cf0160cb97 100644 --- a/codex-rs/utils/path-uri/src/lib.rs +++ b/codex-rs/utils/path-uri/src/lib.rs @@ -432,7 +432,8 @@ impl PathUri { .path_segments() .and_then(|mut segments| segments.find(|segment| !segment.is_empty())) .is_some_and(|segment| { - matches!(segment.as_bytes(), [drive, b':'] if drive.eq_ignore_ascii_case(&path_bytes[0])) + is_windows_drive_uri_segment(segment) + && segment.as_bytes()[0].eq_ignore_ascii_case(&path_bytes[0]) }); if !same_drive { return Err(PathUriParseError::InvalidFileUriPath { @@ -497,9 +498,13 @@ impl PathUri { /// The URI's inferred path convention must match the current host. Conversion should succeed /// when the URI was created from an [`AbsolutePathBuf`] on the current host, including fallback /// URIs created by [`Self::from_abs_path`]. Foreign conventions are rejected rather than being - /// projected onto a syntactically valid but unrelated host path. + /// projected onto a syntactically valid but unrelated host path. Encoded Windows path + /// separators are rejected before native conversion can reinterpret URI segment boundaries. pub fn to_abs_path(&self) -> io::Result { - if self.infer_path_convention() != Some(PathConvention::native()) { + if self.infer_path_convention() != Some(PathConvention::native()) + || (PathConvention::native() == PathConvention::Windows + && containment_path_segments(&self.0, PathConvention::Windows).is_none()) + { return Err(io::Error::new( io::ErrorKind::InvalidInput, PathUriParseError::InvalidFileUriPath { @@ -700,7 +705,10 @@ fn decode_bad_path_uri(url: &Url) -> Option> { } fn is_windows_drive_uri_segment(segment: &str) -> bool { - matches!(segment.as_bytes(), [drive, b':'] if drive.is_ascii_alphabetic()) + matches!( + segment.as_bytes(), + [drive, b':'] | [drive, b'%', b'3', b'A' | b'a'] if drive.is_ascii_alphabetic() + ) } fn containment_path_segments(url: &Url, convention: PathConvention) -> Option> { diff --git a/codex-rs/utils/path-uri/src/tests.rs b/codex-rs/utils/path-uri/src/tests.rs index c5863fc1aa..ecbd87d1e5 100644 --- a/codex-rs/utils/path-uri/src/tests.rs +++ b/codex-rs/utils/path-uri/src/tests.rs @@ -79,6 +79,29 @@ fn non_native_uri_io_conversion_is_invalid_input() { } } +#[test] +fn windows_uri_native_conversion_rejects_encoded_separators() { + for uri in [ + "file:///C%3A/plugins/demo/..%5Coutside.json", + "file:///C%3a/plugins/demo/..%5coutside.json", + "file:///C:/plugins/demo/..%2Foutside.json", + "file://server/share/plugins/demo/..%5Coutside.json", + ] { + let uri = PathUri::parse(uri).expect("valid Windows file URI"); + + assert_eq!(uri.infer_path_convention(), Some(PathConvention::Windows)); + assert!(containment_path_segments(&uri.0, PathConvention::Windows).is_none()); + + #[cfg(windows)] + assert_eq!( + uri.to_abs_path() + .expect_err("encoded Windows separators must not reach native conversion") + .kind(), + io::ErrorKind::InvalidInput + ); + } +} + #[test] fn file_uri_parses_a_windows_path_on_any_host() { let uri = PathUri::parse("file:///C:/Users/Alice%20Smith/src/main.rs") @@ -142,6 +165,14 @@ fn infers_path_conventions_from_uri_shape() { ("file:///home/alice/src", Some(PathConvention::Posix)), ("file:///C:/Users/Alice/src", Some(PathConvention::Windows)), ("file:///d:", Some(PathConvention::Windows)), + ( + "file:///c%3A/Users/Alice/src", + Some(PathConvention::Windows), + ), + ( + "file:///D%3a/Users/Alice/src", + Some(PathConvention::Windows), + ), ("file://server/share/src", Some(PathConvention::Windows)), // Opaque fallback for POSIX bytes `/tmp/null-\0-\xff-byte`. ( @@ -208,6 +239,10 @@ fn inferred_native_path_string_uses_the_inferred_convention() { "file:///C:/Users/Alice%20Smith/main.rs", r"C:\Users\Alice Smith\main.rs", ), + ( + "file:///c%3A/Users/Alice/src/main.rs", + r"C:\Users\Alice\src\main.rs", + ), ("file://server/share/main.rs", r"\\server\share\main.rs"), ("file://server/", "file://server/"), ("file:///%00/bad/path/YQ", "file:///%00/bad/path/YQ"), @@ -994,6 +1029,8 @@ fn join_resolves_windows_same_drive_relative_path() { for (base, path, expected) in [ ("file:///C:/base", r"C:tmp", "file:///C:/base/tmp"), ("file:///C:/base", r"c:tmp", "file:///C:/base/tmp"), + ("file:///C%3A/base", r"C:tmp", "file:///C%3A/base/tmp"), + ("file:///C%3a/base", r"c:tmp", "file:///C%3a/base/tmp"), ("file:///C:/base/dir", r"C:..\tmp", "file:///C:/base/tmp"), ("file:///C:/base", "C:", "file:///C:/base"), ] {