mirror of
https://github.com/openai/codex.git
synced 2026-10-24 18:18:41 +00:00
path-uri: parse and resolve paths by explicit convention
This commit is contained in:
@@ -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<String>) -> 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<PathUri> {
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_native_path(
|
||||
base: &PathUri,
|
||||
path: &str,
|
||||
convention: PathConvention,
|
||||
) -> Result<PathUri, ApiPathStringError> {
|
||||
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<PathUri, ApiPathStringError> {
|
||||
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<PathUri, ApiPathStringError> {
|
||||
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<PathUri, ApiPathStringError> {
|
||||
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<Item = &'a str>,
|
||||
protected_segments: usize,
|
||||
native_path: &str,
|
||||
convention: PathConvention,
|
||||
) -> Result<PathUri, ApiPathStringError> {
|
||||
let segments = segments.into_iter().collect::<Vec<_>>();
|
||||
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<Item = &'a str>,
|
||||
|
||||
@@ -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"))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<Self, ApiPathStringError> {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user