From ffec7c093365eacb2b5ef58dafd53abaeea72e03 Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Mon, 8 Jun 2026 16:33:41 -0700 Subject: [PATCH] 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. --- .github/CODEOWNERS | 1 + codex-rs/Cargo.lock | 15 ++ codex-rs/Cargo.toml | 1 + codex-rs/utils/path-uri/BUILD.bazel | 6 + codex-rs/utils/path-uri/Cargo.toml | 24 ++ codex-rs/utils/path-uri/src/lib.rs | 327 +++++++++++++++++++++++++ codex-rs/utils/path-uri/src/tests.rs | 343 +++++++++++++++++++++++++++ 7 files changed, 717 insertions(+) create mode 100644 codex-rs/utils/path-uri/BUILD.bazel create mode 100644 codex-rs/utils/path-uri/Cargo.toml create mode 100644 codex-rs/utils/path-uri/src/lib.rs create mode 100644 codex-rs/utils/path-uri/src/tests.rs diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index d29c06e6f..48674d396 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -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 diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 8b62cb521..3d8086c27 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -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" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 3bb23ba80..c72b7c784 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -83,6 +83,7 @@ members = [ "tools", "v8-poc", "utils/absolute-path", + "utils/path-uri", "utils/cargo-bin", "git-utils", "utils/cache", diff --git a/codex-rs/utils/path-uri/BUILD.bazel b/codex-rs/utils/path-uri/BUILD.bazel new file mode 100644 index 000000000..7bfd2c454 --- /dev/null +++ b/codex-rs/utils/path-uri/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "path-uri", + crate_name = "codex_utils_path_uri", +) diff --git a/codex-rs/utils/path-uri/Cargo.toml b/codex-rs/utils/path-uri/Cargo.toml new file mode 100644 index 000000000..1cf70708e --- /dev/null +++ b/codex-rs/utils/path-uri/Cargo.toml @@ -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 diff --git a/codex-rs/utils/path-uri/src/lib.rs b/codex-rs/utils/path-uri/src/lib.rs new file mode 100644 index 000000000..a788fdc9b --- /dev/null +++ b/codex-rs/utils/path-uri/src/lib.rs @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 for PathUri { + type Error = PathUriParseError; + + fn try_from(url: Url) -> Result { + 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 for PathUri { + type Error = PathUriParseError; + + fn try_from(uri: String) -> Result { + Self::parse(&uri) + } +} + +impl<'de> Deserialize<'de> for PathUri { + fn deserialize(deserializer: D) -> Result + 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::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(&self, serializer: S) -> Result + 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; diff --git a/codex-rs/utils/path-uri/src/tests.rs b/codex-rs/utils/path-uri/src/tests.rs new file mode 100644 index 000000000..6a237a8c1 --- /dev/null +++ b/codex-rs/utils/path-uri/src/tests.rs @@ -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::(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::(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") + ); +}