mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
Add typed file URIs (#26840)
## Why Codex needs stable `file:` URI identifiers that can cross process and operating-system boundaries without eagerly interpreting them as native paths. Existing fields also need to keep accepting absolute path strings during migration. ## What changed - Add `codex-utils-path-uri` with a validated, immutable `PathUri` wrapper that currently accepts only `file:` URLs. - Expose URI-level `basename`, `parent`, and `join` operations that preserve authorities and percent encoding without guessing the source operating system. - Keep native conversion explicit through `AbsolutePathBuf` and the current host rules. - Serialize as canonical URI text while accepting both URI text and legacy absolute native paths during deserialization. - Add adversarial coverage for Windows-looking and POSIX paths, UNC authorities, encoded metadata characters, non-UTF-8 POSIX paths, URI hierarchy operations, and legacy serde round trips.
This commit is contained in:
committed by
GitHub
Unverified
parent
6042e5810e
commit
ffec7c0933
@@ -2,6 +2,7 @@
|
||||
/codex-rs/core/ @openai/codex-core-agent-team
|
||||
/codex-rs/ext/extension-api/ @openai/codex-core-agent-team
|
||||
/codex-rs/prompts/ @openai/codex-core-agent-team
|
||||
/codex-rs/utils/path-uri/ @openai/codex-core-agent-team
|
||||
|
||||
# Keep macOS AKV signing changes reviewed by Codex maintainers.
|
||||
/.github/actions/setup-akv-pkcs11-codesigning/ @openai/codex-core-agent-team
|
||||
|
||||
Generated
+15
@@ -4152,6 +4152,21 @@ dependencies = [
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-utils-path-uri"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"codex-utils-absolute-path",
|
||||
"pretty_assertions",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"ts-rs",
|
||||
"url",
|
||||
"urlencoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-utils-plugins"
|
||||
version = "0.0.0"
|
||||
|
||||
@@ -83,6 +83,7 @@ members = [
|
||||
"tools",
|
||||
"v8-poc",
|
||||
"utils/absolute-path",
|
||||
"utils/path-uri",
|
||||
"utils/cargo-bin",
|
||||
"git-utils",
|
||||
"utils/cache",
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
load("//:defs.bzl", "codex_rust_crate")
|
||||
|
||||
codex_rust_crate(
|
||||
name = "path-uri",
|
||||
crate_name = "codex_utils_path_uri",
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
[package]
|
||||
name = "codex-utils-path-uri"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
codex-utils-absolute-path = { workspace = true }
|
||||
schemars = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
thiserror = { workspace = true }
|
||||
ts-rs = { workspace = true, features = ["no-serde-warnings"] }
|
||||
url = { workspace = true }
|
||||
urlencoding = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[lib]
|
||||
doctest = false
|
||||
@@ -0,0 +1,327 @@
|
||||
//! Typed, immutable `file:` URIs with cross-platform path inspection.
|
||||
//!
|
||||
//! See [`PathUri`] for scheme, normalization, and serialization behavior.
|
||||
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde::Deserializer;
|
||||
use serde::Serialize;
|
||||
use serde::Serializer;
|
||||
use std::fmt;
|
||||
use std::str::FromStr;
|
||||
use thiserror::Error;
|
||||
use ts_rs::TS;
|
||||
use url::Url;
|
||||
|
||||
pub const FILE_SCHEME: &str = "file";
|
||||
|
||||
/// An immutable, cross-platform representation of a `file:` URI.
|
||||
///
|
||||
/// Only the `file:` scheme is currently accepted. Construction validates the
|
||||
/// URL, and the URI cannot be mutated after construction. [`Self::basename`],
|
||||
/// [`Self::parent`], and [`Self::join`] operate on URI path segments without
|
||||
/// interpreting them using the operating system running Codex.
|
||||
///
|
||||
/// `file:` paths retain their URI spelling so they can be parsed independently
|
||||
/// of the current host. In particular, `/C:/src` remains ambiguous between a
|
||||
/// Windows drive path and a valid POSIX path until [`Self::to_native_path`]
|
||||
/// applies the current host's rules. A local POSIX `file:` URI can also retain
|
||||
/// percent-encoded non-UTF-8 bytes for lossless native round trips.
|
||||
///
|
||||
/// Like [VS Code resources], path operations use `/` URI separators on every
|
||||
/// host. They preserve a URL authority but do not infer Windows drive or UNC
|
||||
/// roots from path text. Native path normalization, filesystem aliases,
|
||||
/// symlinks, case sensitivity, and Unicode normalization are not resolved.
|
||||
///
|
||||
/// Serde represents a `PathUri` as its canonical URI string. Deserialization
|
||||
/// also accepts an absolute native path for compatibility with fields that
|
||||
/// previously used [`AbsolutePathBuf`]; relative paths are rejected. Valid
|
||||
/// `file:` strings round-trip through their canonical URL form, including
|
||||
/// encoded non-UTF-8 path bytes, but conversion to a native path remains
|
||||
/// host-dependent as described by [RFC 8089].
|
||||
///
|
||||
/// [RFC 8089]: https://www.rfc-editor.org/rfc/rfc8089.html
|
||||
/// [VS Code resources]: https://github.com/microsoft/vscode/blob/main/src/vs/base/common/resources.ts
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash, TS)]
|
||||
#[ts(type = "string")]
|
||||
pub struct PathUri(Url);
|
||||
|
||||
impl PathUri {
|
||||
/// Parses and validates a `file:` URI.
|
||||
pub fn parse(uri: &str) -> Result<Self, PathUriParseError> {
|
||||
Url::parse(uri)?.try_into()
|
||||
}
|
||||
|
||||
/// Converts an absolute path on the current host to a `file:` URI.
|
||||
///
|
||||
/// On Unix, [`AbsolutePathBuf`]'s absolute-path invariant is sufficient for
|
||||
/// `url` to represent the path. On Windows, conversion can still fail for
|
||||
/// absolute paths whose prefix has no `file:` URI representation, including
|
||||
/// `\\.\` device paths and generic `\\?\` verbatim namespaces. Those cases
|
||||
/// return [`PathUriParseError::InvalidFileUriPath`].
|
||||
pub fn from_file_path(path: &AbsolutePathBuf) -> Result<Self, PathUriParseError> {
|
||||
let url = Url::from_file_path(path.as_path())
|
||||
.map_err(|()| PathUriParseError::InvalidFileUriPath)?;
|
||||
Self::try_from(url)
|
||||
}
|
||||
|
||||
/// Returns the percent-encoded URI path.
|
||||
///
|
||||
/// The URL authority is not included. For example,
|
||||
/// `file://server/share/file.rs` has the path `/share/file.rs`.
|
||||
pub fn encoded_path(&self) -> &str {
|
||||
self.0.path()
|
||||
}
|
||||
|
||||
/// Returns the decoded final URI path segment, or `None` for the URI root.
|
||||
///
|
||||
/// If the segment contains non-UTF-8 encoded bytes, its percent-encoded
|
||||
/// spelling is returned instead.
|
||||
pub fn basename(&self) -> Option<String> {
|
||||
self.0
|
||||
.path_segments()?
|
||||
.rfind(|segment| !segment.is_empty())
|
||||
.map(decode_uri_path)
|
||||
}
|
||||
|
||||
/// Returns the parent URI, or `None` for the URI root.
|
||||
pub fn parent(&self) -> Option<Self> {
|
||||
if self.encoded_path() == "/" {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut url = self.0.clone();
|
||||
{
|
||||
let mut segments = match url.path_segments_mut() {
|
||||
Ok(segments) => segments,
|
||||
Err(()) => unreachable!("validated file URLs support hierarchical path segments"),
|
||||
};
|
||||
segments.pop_if_empty().pop();
|
||||
}
|
||||
Some(Self(url))
|
||||
}
|
||||
|
||||
/// Lexically joins a relative URI path onto this URI.
|
||||
///
|
||||
/// Empty and `.` segments are ignored, while `..` removes one segment
|
||||
/// without escaping the URI root. Literal `%`, `?`, and `#` characters are
|
||||
/// percent-encoded as filename text. Paths containing a null character are
|
||||
/// rejected because they cannot be safely converted to native paths.
|
||||
pub fn join(&self, path: &str) -> Result<Self, PathUriParseError> {
|
||||
if path.starts_with('/') {
|
||||
return Err(PathUriParseError::JoinPathMustBeRelative(path.to_string()));
|
||||
}
|
||||
if path.contains('\0') {
|
||||
return Err(PathUriParseError::InvalidFileUriPath);
|
||||
}
|
||||
if path.is_empty() {
|
||||
return Ok(self.clone());
|
||||
}
|
||||
|
||||
let mut url = self.0.clone();
|
||||
{
|
||||
let Ok(mut segments) = url.path_segments_mut() else {
|
||||
unreachable!("validated file URLs support hierarchical path segments");
|
||||
};
|
||||
segments.pop_if_empty();
|
||||
for component in path.split('/') {
|
||||
match component {
|
||||
"" | "." => {}
|
||||
".." => {
|
||||
segments.pop();
|
||||
}
|
||||
component => {
|
||||
segments.push(component);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Self::try_from(url)
|
||||
}
|
||||
|
||||
/// Converts this file URI to a path using the current host's path rules.
|
||||
///
|
||||
/// Conversion should succeed when the URI was created from an
|
||||
/// [`AbsolutePathBuf`] on the current host. It may fail when the URI came
|
||||
/// from a different operating system and its `file:` URI form cannot be
|
||||
/// represented using the current host's path rules, such as a UNC authority
|
||||
/// on POSIX or a POSIX root on Windows. Because a `file:` URI does not record
|
||||
/// its source operating system, callers should only use this method when the
|
||||
/// URI is known to identify a path on the current host.
|
||||
pub fn to_native_path(&self) -> Result<AbsolutePathBuf, PathUriParseError> {
|
||||
let path = self
|
||||
.0
|
||||
.to_file_path()
|
||||
.map_err(|()| PathUriParseError::InvalidFileUriPath)?;
|
||||
AbsolutePathBuf::from_absolute_path_checked(path)
|
||||
.map_err(|_| PathUriParseError::InvalidFileUriPath)
|
||||
}
|
||||
|
||||
/// Returns a clone of the canonical URL.
|
||||
pub fn to_url(&self) -> Url {
|
||||
self.0.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<Url> for PathUri {
|
||||
type Error = PathUriParseError;
|
||||
|
||||
fn try_from(url: Url) -> Result<Self, Self::Error> {
|
||||
if url.scheme() != FILE_SCHEME {
|
||||
return Err(PathUriParseError::UnsupportedScheme(
|
||||
url.scheme().to_string(),
|
||||
));
|
||||
}
|
||||
validate_file_url(&url)?;
|
||||
let url = without_localhost_authority(url);
|
||||
Ok(Self(url))
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<String> for PathUri {
|
||||
type Error = PathUriParseError;
|
||||
|
||||
fn try_from(uri: String) -> Result<Self, Self::Error> {
|
||||
Self::parse(&uri)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for PathUri {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let value = String::deserialize(deserializer)?;
|
||||
let unsupported_scheme = match Url::parse(&value) {
|
||||
Ok(url) => match Self::try_from(url) {
|
||||
Ok(uri) => return Ok(uri),
|
||||
// `Url` parses a Windows drive prefix such as `C:\` as the
|
||||
// scheme `c`. Give any unsupported URI one chance to satisfy
|
||||
// the native absolute-path invariant before reporting it.
|
||||
Err(error @ PathUriParseError::UnsupportedScheme(_)) => Some(error),
|
||||
Err(error) => return Err(serde::de::Error::custom(error)),
|
||||
},
|
||||
Err(url::ParseError::RelativeUrlWithoutBase) => None,
|
||||
Err(error) => {
|
||||
return Err(serde::de::Error::custom(PathUriParseError::InvalidUri(
|
||||
error,
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
let path = AbsolutePathBuf::from_absolute_path_checked(value).map_err(|path_error| {
|
||||
serde::de::Error::custom(
|
||||
unsupported_scheme
|
||||
.map_or_else(|| path_error.to_string(), |error| error.to_string()),
|
||||
)
|
||||
})?;
|
||||
Self::from_file_path(&path).map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for PathUri {
|
||||
type Err = PathUriParseError;
|
||||
|
||||
fn from_str(uri: &str) -> Result<Self, Self::Err> {
|
||||
Self::parse(uri)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for PathUri {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
self.0.fmt(f)
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for PathUri {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
serializer.serialize_str(self.0.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for PathUri {
|
||||
fn schema_name() -> String {
|
||||
"PathUri".to_string()
|
||||
}
|
||||
|
||||
fn json_schema(generator: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema {
|
||||
String::json_schema(generator)
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes the local `localhost` alias while retaining non-local UNC authority.
|
||||
fn without_localhost_authority(mut url: Url) -> Url {
|
||||
if url.host_str() == Some("localhost") {
|
||||
let Ok(()) = url.set_host(None) else {
|
||||
unreachable!("validated file URLs can remove a localhost authority");
|
||||
};
|
||||
}
|
||||
url
|
||||
}
|
||||
|
||||
/// Percent-decodes a URI path when it is valid UTF-8.
|
||||
///
|
||||
/// `file:` URLs may contain encoded non-UTF-8 bytes. In that case the encoded
|
||||
/// spelling remains available for lexical inspection while the original `Url`
|
||||
/// is retained for lossless native conversion.
|
||||
fn decode_uri_path(path: &str) -> String {
|
||||
urlencoding::decode(path)
|
||||
.map(std::borrow::Cow::into_owned)
|
||||
.unwrap_or_else(|_| path.to_string())
|
||||
}
|
||||
|
||||
/// Rejects URI metadata that has no defined meaning for `file:` URIs.
|
||||
fn validate_common_known_uri(url: &Url) -> Result<(), PathUriParseError> {
|
||||
if !url.username().is_empty() || url.password().is_some() {
|
||||
return Err(PathUriParseError::CredentialsNotAllowed);
|
||||
}
|
||||
if url.port().is_some() {
|
||||
return Err(PathUriParseError::PortNotAllowed);
|
||||
}
|
||||
if url.query().is_some() {
|
||||
return Err(PathUriParseError::QueryNotAllowed);
|
||||
}
|
||||
if url.fragment().is_some() {
|
||||
return Err(PathUriParseError::FragmentNotAllowed);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Applies the common URI checks plus `file:` path-byte restrictions.
|
||||
fn validate_file_url(url: &Url) -> Result<(), PathUriParseError> {
|
||||
validate_common_known_uri(url)?;
|
||||
// `Url` accepts `%00`, but native path APIs use null as a terminator and
|
||||
// `Url::to_file_path` cannot represent a decoded null byte.
|
||||
if urlencoding::decode_binary(url.path().as_bytes()).contains(&0) {
|
||||
return Err(PathUriParseError::InvalidFileUriPath);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Error, PartialEq, Eq)]
|
||||
pub enum PathUriParseError {
|
||||
#[error("invalid URI: {0}")]
|
||||
InvalidUri(#[from] url::ParseError),
|
||||
#[error("unsupported path URI scheme `{0}`")]
|
||||
UnsupportedScheme(String),
|
||||
#[error("file URI contains an invalid absolute path")]
|
||||
InvalidFileUriPath,
|
||||
#[error("credentials are not allowed in path URIs")]
|
||||
CredentialsNotAllowed,
|
||||
#[error("ports are not allowed in path URIs")]
|
||||
PortNotAllowed,
|
||||
#[error("query parameters are not allowed in path URIs")]
|
||||
QueryNotAllowed,
|
||||
#[error("fragments are not allowed in path URIs")]
|
||||
FragmentNotAllowed,
|
||||
#[error("path `{0}` must be relative when joining a path URI")]
|
||||
JoinPathMustBeRelative(String),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,343 @@
|
||||
use super::*;
|
||||
use codex_utils_absolute_path::AbsolutePathBufGuard;
|
||||
use pretty_assertions::assert_eq;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
#[cfg(unix)]
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn file_uri_round_trips_an_absolute_path() {
|
||||
let path = AbsolutePathBuf::current_dir()
|
||||
.expect("current directory")
|
||||
.join("a path/file.rs");
|
||||
|
||||
let uri = PathUri::from_file_path(&path).expect("path should convert to a file URI");
|
||||
|
||||
let uri_string = uri.to_string();
|
||||
assert!(uri_string.starts_with("file:"));
|
||||
assert!(uri_string.ends_with("/a%20path/file.rs"));
|
||||
assert_eq!(
|
||||
PathUri::parse(&uri_string).expect("serialized URI should parse"),
|
||||
uri
|
||||
);
|
||||
assert_eq!(
|
||||
uri.to_native_path()
|
||||
.expect("local file URI should convert to a native path"),
|
||||
path
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_uri_parses_a_windows_path_on_any_host() {
|
||||
let uri = PathUri::parse("file:///C:/Users/Alice%20Smith/src/main.rs")
|
||||
.expect("Windows file URI should parse on every host");
|
||||
|
||||
assert_eq!(uri.encoded_path(), "/C:/Users/Alice%20Smith/src/main.rs");
|
||||
assert_eq!(uri.basename(), Some("main.rs".to_string()));
|
||||
assert_eq!(
|
||||
uri.to_string(),
|
||||
"file:///C:/Users/Alice%20Smith/src/main.rs"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn file_uri_rejects_windows_prefixes_without_a_uri_representation() {
|
||||
for native_path in [
|
||||
r"\\.\COM1",
|
||||
r"\\?\Volume{00000000-0000-0000-0000-000000000000}\file.rs",
|
||||
] {
|
||||
let path = AbsolutePathBuf::from_absolute_path_checked(native_path)
|
||||
.expect("Windows namespace path should be absolute");
|
||||
|
||||
assert_eq!(
|
||||
PathUri::from_file_path(&path),
|
||||
Err(PathUriParseError::InvalidFileUriPath),
|
||||
"converting {native_path}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_uri_parses_a_posix_path_on_any_host() {
|
||||
let uri = PathUri::parse("file:///home/alice/src/main.rs")
|
||||
.expect("POSIX file URI should parse on every host");
|
||||
|
||||
assert_eq!(uri.encoded_path(), "/home/alice/src/main.rs");
|
||||
assert_eq!(uri.basename(), Some("main.rs".to_string()));
|
||||
assert_eq!(uri.to_string(), "file:///home/alice/src/main.rs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_uri_preserves_paths_that_resemble_windows_paths() {
|
||||
for (input, expected_path) in [("file:///C:/Project", "/C:/Project"), ("file:///C:", "/C:")] {
|
||||
let uri = PathUri::parse(input).expect("file URI should parse");
|
||||
let reparsed = PathUri::parse(&uri.to_string()).expect("file URI should reparse");
|
||||
assert_eq!(uri.encoded_path(), expected_path);
|
||||
assert_eq!(reparsed, uri);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn file_uri_accepts_non_utf8_posix_paths() {
|
||||
let path = PathBuf::from(std::ffi::OsString::from_vec(b"/tmp/non-utf8-\xff".to_vec()));
|
||||
let path = AbsolutePathBuf::from_absolute_path_checked(path).expect("absolute POSIX path");
|
||||
|
||||
let uri = PathUri::from_file_path(&path).expect("non-UTF-8 path should convert to a file URI");
|
||||
assert_eq!(
|
||||
uri.to_native_path()
|
||||
.expect("URI should convert to native path"),
|
||||
path
|
||||
);
|
||||
assert_eq!(
|
||||
PathUri::parse(&uri.to_string()).expect("non-UTF-8 URI should reparse"),
|
||||
uri
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_uri_round_trips_literal_percent_characters() {
|
||||
let uri = PathUri::parse("file:///tmp/100%25/file").expect("file URI should parse");
|
||||
|
||||
assert_eq!(uri.to_string(), "file:///tmp/100%25/file");
|
||||
assert_eq!(uri.encoded_path(), "/tmp/100%25/file");
|
||||
assert_eq!(uri.basename(), Some("file".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(windows)]
|
||||
fn file_uri_round_trips_windows_unc_paths() {
|
||||
let path = AbsolutePathBuf::from_absolute_path_checked(r"\\server\share\src\main.rs")
|
||||
.expect("absolute UNC path");
|
||||
let uri = PathUri::from_file_path(&path).expect("UNC path should convert to a file URI");
|
||||
|
||||
assert_eq!(uri.encoded_path(), "/share/src/main.rs");
|
||||
assert_eq!(uri.to_native_path().expect("UNC URI should convert"), path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_uri_retains_unc_authority() {
|
||||
let uri = PathUri::parse("file://server/share/src/main.rs").expect("valid file URI");
|
||||
|
||||
assert_eq!(uri.encoded_path(), "/share/src/main.rs");
|
||||
assert_eq!(uri.to_string(), "file://server/share/src/main.rs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_uri_spelling_aliases_have_one_canonical_form() {
|
||||
for input in [
|
||||
"FILE:///workspace/src",
|
||||
"file:/workspace/src",
|
||||
"file://localhost/workspace/src",
|
||||
"file://LOCALHOST/workspace/src",
|
||||
] {
|
||||
let uri = PathUri::parse(input).expect("file URI alias should parse");
|
||||
assert_eq!(uri.to_string(), "file:///workspace/src", "parsing {input}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_schemes_are_rejected_at_construction() {
|
||||
for (input, expected_scheme) in [
|
||||
("codex-env:///devbox/workspace", "codex-env"),
|
||||
("artifact://store/object-1", "artifact"),
|
||||
("http://example.com/file", "http"),
|
||||
("https://example.com/file", "https"),
|
||||
("ssh://host/workspace", "ssh"),
|
||||
("vscode-remote://ssh-remote+host/workspace", "vscode-remote"),
|
||||
("untitled:Untitled-1", "untitled"),
|
||||
] {
|
||||
let error = PathUri::parse(input).expect_err("unsupported schemes should be rejected");
|
||||
|
||||
assert!(
|
||||
matches!(
|
||||
error,
|
||||
PathUriParseError::UnsupportedScheme(scheme) if scheme == expected_scheme
|
||||
),
|
||||
"parsing {input}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_uri_serializes_as_a_string() {
|
||||
let uri: PathUri = "file:///workspace/src/lib.rs"
|
||||
.parse()
|
||||
.expect("valid file URI");
|
||||
|
||||
let json = serde_json::to_string(&uri).expect("URI should serialize");
|
||||
let deserialized: PathUri = serde_json::from_str(&json).expect("URI should deserialize");
|
||||
|
||||
assert_eq!(json, r#""file:///workspace/src/lib.rs""#);
|
||||
assert_eq!(deserialized, uri);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_uri_deserializes_legacy_absolute_paths() {
|
||||
let path = AbsolutePathBuf::current_dir()
|
||||
.expect("current directory")
|
||||
.join("workspace/src");
|
||||
let json = serde_json::to_string(&path).expect("absolute path should serialize");
|
||||
let uri: PathUri = serde_json::from_str(&json).expect("legacy absolute path should parse");
|
||||
|
||||
assert_eq!(
|
||||
uri,
|
||||
PathUri::from_file_path(&path).expect("expected file URI")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_uri_rejects_legacy_relative_paths_with_absolute_path_guard() {
|
||||
let base = AbsolutePathBuf::current_dir().expect("current directory");
|
||||
let _guard = AbsolutePathBufGuard::new(base.as_path());
|
||||
let error = serde_json::from_str::<PathUri>(r#""src/lib.rs""#)
|
||||
.expect_err("legacy relative path should be rejected");
|
||||
|
||||
assert!(error.to_string().contains("path is not absolute"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_scheme_is_rejected_during_deserialization() {
|
||||
let error = serde_json::from_str::<PathUri>(r#""artifact://store/object-1""#)
|
||||
.expect_err("unsupported scheme should fail deserialization");
|
||||
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("unsupported path URI scheme `artifact`")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_path_uris_reject_queries_and_fragments() {
|
||||
let query_error =
|
||||
PathUri::parse("file:///tmp/file.rs?version=1").expect_err("query should be rejected");
|
||||
let fragment_error =
|
||||
PathUri::parse("file:///tmp/file.rs#L1").expect_err("fragment should be rejected");
|
||||
|
||||
assert!(matches!(query_error, PathUriParseError::QueryNotAllowed));
|
||||
assert!(matches!(
|
||||
fragment_error,
|
||||
PathUriParseError::FragmentNotAllowed
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_uris_reject_encoded_null_bytes() {
|
||||
assert!(PathUri::parse("file:///tmp/%00").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoded_filename_characters_round_trip_without_becoming_uri_metadata() {
|
||||
let uri = PathUri::parse("file:///tmp/a%3Fb%23c%25d")
|
||||
.expect("encoded filename characters should parse");
|
||||
|
||||
assert_eq!(uri.to_string(), "file:///tmp/a%3Fb%23c%25d");
|
||||
assert_eq!(uri.encoded_path(), "/tmp/a%3Fb%23c%25d");
|
||||
assert_eq!(uri.basename(), Some("a?b#c%d".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_encoded_separator_remains_filename_text() {
|
||||
let uri = PathUri::parse("file:///tmp/a%252Fb")
|
||||
.expect("double-encoded separator should parse as filename text");
|
||||
|
||||
assert_eq!(uri.to_string(), "file:///tmp/a%252Fb");
|
||||
assert_eq!(uri.encoded_path(), "/tmp/a%252Fb");
|
||||
assert_eq!(uri.basename(), Some("a%2Fb".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basename_uses_decoded_uri_segments() {
|
||||
for (input, expected) in [
|
||||
("file:///", None),
|
||||
("file:///workspace/src/lib.rs", Some("lib.rs")),
|
||||
("file:///workspace/a%20file.rs", Some("a file.rs")),
|
||||
("file:///C:/", Some("C:")),
|
||||
("file://server/share", Some("share")),
|
||||
] {
|
||||
let uri = PathUri::parse(input).expect("valid file URI");
|
||||
assert_eq!(
|
||||
uri.basename(),
|
||||
expected.map(str::to_string),
|
||||
"basename for {input}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_uses_uri_hierarchy_and_preserves_authority() {
|
||||
for (input, expected) in [
|
||||
(
|
||||
"file:///workspace/src/lib.rs",
|
||||
Some("file:///workspace/src"),
|
||||
),
|
||||
("file:///workspace", Some("file:///")),
|
||||
("file:///", None),
|
||||
("file:///C:/Users", Some("file:///C:")),
|
||||
("file:///C:/", Some("file:///")),
|
||||
(
|
||||
"file://server/share/src/main.rs",
|
||||
Some("file://server/share/src"),
|
||||
),
|
||||
("file://server/share", Some("file://server/")),
|
||||
] {
|
||||
let uri = PathUri::parse(input).expect("valid file URI");
|
||||
let expected = expected.map(|value| PathUri::parse(value).expect("valid expected URI"));
|
||||
assert_eq!(uri.parent(), expected, "parent for {input}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_normalizes_relative_uri_segments() {
|
||||
for (base, relative, expected) in [
|
||||
(
|
||||
"file:///workspace/src",
|
||||
"../tests/test.rs",
|
||||
"file:///workspace/tests/test.rs",
|
||||
),
|
||||
("file:///", "../../etc", "file:///etc"),
|
||||
("file:///C:/Users", "../Windows", "file:///C:/Windows"),
|
||||
(
|
||||
"file://server/share/src",
|
||||
"../tests",
|
||||
"file://server/share/tests",
|
||||
),
|
||||
(
|
||||
"file:///workspace",
|
||||
"a?b#c%d",
|
||||
"file:///workspace/a%3Fb%23c%25d",
|
||||
),
|
||||
("file:///workspace/", "", "file:///workspace/"),
|
||||
] {
|
||||
let base = PathUri::parse(base).expect("valid base URI");
|
||||
let expected = PathUri::parse(expected).expect("valid expected URI");
|
||||
assert_eq!(base.join(relative), Ok(expected), "joining {relative}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_rejects_absolute_and_null_paths() {
|
||||
let base = PathUri::parse("file:///workspace").expect("valid base URI");
|
||||
|
||||
assert!(matches!(
|
||||
base.join("/src"),
|
||||
Err(PathUriParseError::JoinPathMustBeRelative(path)) if path == "/src"
|
||||
));
|
||||
assert!(matches!(
|
||||
base.join("src\0file"),
|
||||
Err(PathUriParseError::InvalidFileUriPath)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_url_returns_the_validated_url() {
|
||||
let uri = PathUri::parse("file://localhost/workspace/a%20file.rs").expect("valid file URI");
|
||||
|
||||
assert_eq!(
|
||||
uri.to_url(),
|
||||
Url::parse("file:///workspace/a%20file.rs").expect("valid URL")
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user