diff --git a/codex-rs/utils/path-uri/src/api_path_string.rs b/codex-rs/utils/path-uri/src/api_path_string.rs index 84222c6eb2..6876f56e28 100644 --- a/codex-rs/utils/path-uri/src/api_path_string.rs +++ b/codex-rs/utils/path-uri/src/api_path_string.rs @@ -56,6 +56,12 @@ impl fmt::Display for PathConvention { pub struct ApiPathString(String); impl ApiPathString { + /// Wraps an API path string without interpreting it using the current + /// host's path rules. + pub fn new(path: impl Into) -> Self { + Self(path.into()) + } + /// Converts an absolute path to a [`PathUri`] before rendering it using the /// requested native path convention. pub fn from_abs_path( @@ -156,6 +162,198 @@ fn parse_windows_path(path: &str) -> Option { None } +pub(crate) fn resolve_native_path( + base: &PathUri, + path: &str, + convention: PathConvention, +) -> Result { + if path.is_empty() { + return Ok(base.clone()); + } + + match convention { + PathConvention::Posix => { + if path.starts_with('/') { + return parse_resolved_posix_path(path); + } + reject_opaque_base(base)?; + let base = render_posix_path(base)?; + parse_resolved_posix_path(&format!("{}/{path}", base.trim_end_matches('/'))) + } + PathConvention::Windows => resolve_windows_path(base, path), + } +} + +fn resolve_windows_path(base: &PathUri, path: &str) -> Result { + if is_absolute_windows_path(path) { + return parse_resolved_windows_path(path); + } + + reject_opaque_base(base)?; + let base = render_windows_path(base)?; + let path = if path.starts_with(['\\', '/']) { + let root_end = windows_root_end(&base); + format!( + "{}\\{}", + &base[..root_end], + path.trim_start_matches(['\\', '/']) + ) + } else { + format!("{}\\{path}", base.trim_end_matches(['\\', '/'])) + }; + parse_resolved_windows_path(&path) +} + +fn parse_resolved_posix_path(path: &str) -> Result { + let Some(path) = path.strip_prefix('/') else { + return Err(invalid_native_path(path, PathConvention::Posix)); + }; + if path.contains('\0') { + return Err(invalid_native_path(path, PathConvention::Posix)); + } + build_resolved_path_uri( + /*host*/ None, + path.split('/'), + /*protected_segments*/ 0, + path, + PathConvention::Posix, + ) +} + +fn parse_resolved_windows_path(path: &str) -> Result { + if path.contains('\0') { + return Err(invalid_native_path(path, PathConvention::Windows)); + } + + if let Some(rest) = path.strip_prefix(r"\\").or_else(|| path.strip_prefix("//")) { + let mut segments = rest.split(is_windows_separator_char); + let Some(host) = segments.next().filter(|host| !host.is_empty()) else { + return Err(invalid_native_path(path, PathConvention::Windows)); + }; + let Some(share) = segments.next().filter(|share| !share.is_empty()) else { + return Err(invalid_native_path(path, PathConvention::Windows)); + }; + return build_resolved_path_uri( + Some(host), + std::iter::once(share).chain(segments), + /*protected_segments*/ 1, + path, + PathConvention::Windows, + ); + } + + let bytes = path.as_bytes(); + if !matches!( + bytes, + [drive, b':', separator, ..] + if drive.is_ascii_alphabetic() && is_windows_separator_byte(*separator) + ) { + return Err(invalid_native_path(path, PathConvention::Windows)); + } + build_resolved_path_uri( + /*host*/ None, + std::iter::once(&path[..2]).chain(path[3..].split(is_windows_separator_char)), + /*protected_segments*/ 1, + path, + PathConvention::Windows, + ) +} + +fn build_resolved_path_uri<'a>( + host: Option<&str>, + segments: impl IntoIterator, + protected_segments: usize, + native_path: &str, + convention: PathConvention, +) -> Result { + let segments = segments.into_iter().collect::>(); + let preserve_trailing_separator = segments + .last() + .is_some_and(|segment| matches!(*segment, "" | "." | "..")); + let mut normalized_segments = Vec::with_capacity(segments.len()); + for segment in segments { + match segment { + "" | "." => {} + ".." => { + if normalized_segments.len() > protected_segments { + normalized_segments.pop(); + } + } + _ => { + let is_drive = convention == PathConvention::Windows + && host.is_none() + && normalized_segments.is_empty() + && is_windows_drive(segment); + if convention == PathConvention::Windows + && !is_drive + && !is_valid_windows_component(segment) + { + return Err(invalid_native_path(native_path, convention)); + } + normalized_segments.push(segment); + } + } + } + if normalized_segments.len() < protected_segments { + return Err(invalid_native_path(native_path, convention)); + } + if preserve_trailing_separator { + normalized_segments.push(""); + } + + path_uri_from_segments(host, normalized_segments.into_iter()) + .ok_or_else(|| invalid_native_path(native_path, convention)) +} + +fn is_absolute_windows_path(path: &str) -> bool { + path.strip_prefix(r"\\") + .or_else(|| path.strip_prefix("//")) + .is_some() + || matches!( + path.as_bytes(), + [drive, b':', separator, ..] + if drive.is_ascii_alphabetic() && is_windows_separator_byte(*separator) + ) +} + +fn windows_root_end(path: &str) -> usize { + if path.starts_with(r"\\") { + path.match_indices('\\') + .nth(3) + .map_or(path.len(), |(index, _)| index) + } else { + 2 + } +} + +fn is_windows_drive(component: &str) -> bool { + let bytes = component.as_bytes(); + bytes.len() == 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' +} + +fn is_valid_windows_component(component: &str) -> bool { + !component + .chars() + .any(|character| character <= '\u{1f}' || r#"<>:"/\|?*"#.contains(character)) + && !component.ends_with([' ', '.']) +} + +fn reject_opaque_base(path: &PathUri) -> Result<(), ApiPathStringError> { + if path.opaque_fallback_bytes().is_some() { + return Err(ApiPathStringError::OpaqueFallback { + path: path.to_string(), + }); + } + Ok(()) +} + +fn invalid_native_path(path: &str, convention: PathConvention) -> ApiPathStringError { + ApiPathStringError::InvalidNativePath { + path: path.to_string(), + convention, + } +} + fn path_uri_from_segments<'a>( host: Option<&str>, segments: impl Iterator, diff --git a/codex-rs/utils/path-uri/src/api_path_string_tests.rs b/codex-rs/utils/path-uri/src/api_path_string_tests.rs index c123568889..73f4056665 100644 --- a/codex-rs/utils/path-uri/src/api_path_string_tests.rs +++ b/codex-rs/utils/path-uri/src/api_path_string_tests.rs @@ -418,3 +418,78 @@ fn serializes_and_deserializes_as_a_string() { rendered ); } + +#[test] +fn resolves_posix_paths_without_host_path_rules() { + let base = PathUri::parse("file:///workspace/src").expect("base URI"); + + for (path, expected) in [ + ("", "file:///workspace/src"), + ("../README.md", "file:///workspace/README.md"), + ("../../../README.md", "file:///README.md"), + ("/tmp/output", "file:///tmp/output"), + ( + r"generated\output", + "file:///workspace/src/generated%5Coutput", + ), + ] { + assert_eq!( + base.resolve_native(path, PathConvention::Posix), + Ok(PathUri::parse(expected).expect("resolved URI")), + "resolving {path}" + ); + } +} + +#[test] +fn resolves_windows_drive_and_unc_paths_without_host_path_rules() { + let drive = PathUri::parse("file:///C:/workspace/src").expect("drive URI"); + let share = PathUri::parse("file://server/share/workspace/src").expect("share URI"); + + for (base, path, expected) in [ + (&drive, "", "file:///C:/workspace/src"), + (&drive, r"..\README.md", "file:///C:/workspace/README.md"), + (&drive, r"\logs\output.txt", "file:///C:/logs/output.txt"), + (&drive, r"D:\other\file.txt", "file:///D:/other/file.txt"), + ( + &drive, + r"\\other\share\file.txt", + "file://other/share/file.txt", + ), + ( + &share, + r"..\..\..\README.md", + "file://server/share/README.md", + ), + ( + &share, + r"\logs\output.txt", + "file://server/share/logs/output.txt", + ), + ] { + assert_eq!( + base.resolve_native(path, PathConvention::Windows), + Ok(PathUri::parse(expected).expect("resolved URI")), + "resolving {path}" + ); + } +} + +#[test] +fn relative_resolution_rejects_incompatible_and_opaque_bases() { + let posix = PathUri::parse("file:///workspace").expect("POSIX URI"); + let opaque = PathUri::parse("file:///%00/bad/path/YQ").expect("opaque fallback URI"); + + assert!(matches!( + posix.resolve_native("child", PathConvention::Windows), + Err(ApiPathStringError::IncompatibleConvention { .. }) + )); + assert!(matches!( + opaque.resolve_native("child", PathConvention::Posix), + Err(ApiPathStringError::OpaqueFallback { .. }) + )); + assert_eq!( + opaque.resolve_native("/tmp", PathConvention::Posix), + Ok(PathUri::parse("file:///tmp").expect("absolute URI")) + ); +} diff --git a/codex-rs/utils/path-uri/src/lib.rs b/codex-rs/utils/path-uri/src/lib.rs index 468d577c1a..75030542fc 100644 --- a/codex-rs/utils/path-uri/src/lib.rs +++ b/codex-rs/utils/path-uri/src/lib.rs @@ -206,6 +206,20 @@ impl PathUri { Self::try_from(url) } + /// Resolves a native absolute or relative path against this URI using the + /// supplied path convention. + /// + /// This is host-independent: Windows paths can be resolved on POSIX and + /// vice versa. Relative navigation is lexical and cannot escape the drive, + /// UNC share, or POSIX root represented by this URI. + pub fn resolve_native( + &self, + path: &str, + convention: PathConvention, + ) -> Result { + api_path_string::resolve_native_path(self, path, convention) + } + /// Converts this file URI to a path using the current host's path rules. /// /// Conversion should succeed when the URI was created from an