From 740e11ecb87532e839952e2cd55ec9382903d115 Mon Sep 17 00:00:00 2001 From: Adam Perry Date: Fri, 12 Jun 2026 08:15:20 +0000 Subject: [PATCH] path-uri: render native paths across platforms --- codex-rs/exec-server/src/environment.rs | 25 ++ codex-rs/exec-server/src/protocol.rs | 39 +++ codex-rs/utils/path-uri/src/lib.rs | 6 + .../utils/path-uri/src/native_path_string.rs | 238 ++++++++++++++++++ .../path-uri/src/native_path_string_tests.rs | 157 ++++++++++++ 5 files changed, 465 insertions(+) create mode 100644 codex-rs/utils/path-uri/src/native_path_string.rs create mode 100644 codex-rs/utils/path-uri/src/native_path_string_tests.rs diff --git a/codex-rs/exec-server/src/environment.rs b/codex-rs/exec-server/src/environment.rs index bc100b9422..f60ecceece 100644 --- a/codex-rs/exec-server/src/environment.rs +++ b/codex-rs/exec-server/src/environment.rs @@ -26,6 +26,7 @@ use crate::protocol::ShellInfo; use crate::remote_file_system::RemoteFileSystem; use crate::remote_process::RemoteProcess; use codex_shell_command::shell_detect::DetectedShell; +use codex_utils_path_uri::PathConvention; pub const CODEX_EXEC_SERVER_URL_ENV_VAR: &str = "CODEX_EXEC_SERVER_URL"; @@ -473,6 +474,7 @@ impl EnvironmentInfo { pub(crate) fn local() -> Self { Self { shell: codex_shell_command::shell_detect::default_user_shell().into(), + path_convention: PathConvention::native(), } } } @@ -494,10 +496,14 @@ mod tests { use super::EnvironmentManager; use super::LOCAL_ENVIRONMENT_ID; use super::REMOTE_ENVIRONMENT_ID; + use crate::EnvironmentInfo; use crate::ExecServerRuntimePaths; use crate::ProcessId; + use crate::ShellInfo; use crate::environment_provider::EnvironmentDefault; use crate::environment_provider::EnvironmentProviderSnapshot; + use codex_utils_path_uri::PathConvention; + use codex_utils_path_uri::PathUri; use pretty_assertions::assert_eq; fn test_runtime_paths() -> ExecServerRuntimePaths { @@ -512,6 +518,25 @@ mod tests { assert!(manager.try_local_environment().is_none()); } + #[test] + fn environment_info_renders_paths_using_its_convention() { + let info = EnvironmentInfo { + shell: ShellInfo { + name: "powershell".to_string(), + path: "powershell.exe".to_string(), + }, + path_convention: PathConvention::Windows, + }; + let path = PathUri::parse("file:///C:/workspace/src/main.rs").expect("valid file URI"); + + assert_eq!( + info.render_path(&path) + .expect("Windows path should render") + .into_string(), + r"C:\workspace\src\main.rs" + ); + } + #[tokio::test] async fn create_local_environment_does_not_connect() { let environment = Environment::create(/*exec_server_url*/ None, test_runtime_paths()) diff --git a/codex-rs/exec-server/src/protocol.rs b/codex-rs/exec-server/src/protocol.rs index 8b9819b64d..b97ea76449 100644 --- a/codex-rs/exec-server/src/protocol.rs +++ b/codex-rs/exec-server/src/protocol.rs @@ -4,6 +4,9 @@ use std::path::PathBuf; use crate::FileSystemSandboxContext; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use codex_protocol::config_types::ShellEnvironmentPolicyInherit; +use codex_utils_path_uri::NativePathString; +use codex_utils_path_uri::NativePathStringError; +use codex_utils_path_uri::PathConvention; use codex_utils_path_uri::PathUri; use serde::Deserialize; use serde::Serialize; @@ -69,6 +72,18 @@ pub struct InitializeResponse { #[serde(rename_all = "camelCase")] pub struct EnvironmentInfo { pub shell: ShellInfo, + /// Native path syntax used by this environment. + pub path_convention: PathConvention, +} + +impl EnvironmentInfo { + /// Renders a path using this environment's native path syntax. + /// + /// TODO(anp): Once `PathUri` carries an environment identifier, resolve the + /// environment from that identifier and verify it matches this metadata. + pub fn render_path(&self, path: &PathUri) -> Result { + NativePathString::from_path_uri(path, self.path_convention) + } } /// Shell detected for an execution/filesystem environment. @@ -448,8 +463,11 @@ mod base64_bytes { #[cfg(test)] mod tests { + use super::EnvironmentInfo; use super::FsReadFileParams; use super::HttpRequestParams; + use super::ShellInfo; + use codex_utils_path_uri::PathConvention; use codex_utils_path_uri::PathUri; use pretty_assertions::assert_eq; @@ -478,6 +496,27 @@ mod tests { ); } + #[test] + fn environment_info_serializes_path_convention() { + let current = EnvironmentInfo { + shell: ShellInfo { + name: "powershell".to_string(), + path: "powershell.exe".to_string(), + }, + path_convention: PathConvention::Windows, + }; + assert_eq!( + serde_json::to_value(current).expect("environment info should serialize"), + serde_json::json!({ + "shell": { + "name": "powershell", + "path": "powershell.exe", + }, + "pathConvention": "windows", + }) + ); + } + #[test] fn http_request_timeout_treats_omitted_and_null_as_no_timeout() { let omitted: HttpRequestParams = serde_json::from_value(serde_json::json!({ diff --git a/codex-rs/utils/path-uri/src/lib.rs b/codex-rs/utils/path-uri/src/lib.rs index 444af1ea28..95d6652429 100644 --- a/codex-rs/utils/path-uri/src/lib.rs +++ b/codex-rs/utils/path-uri/src/lib.rs @@ -16,6 +16,12 @@ use thiserror::Error; use ts_rs::TS; use url::Url; +mod native_path_string; + +pub use native_path_string::NativePathString; +pub use native_path_string::NativePathStringError; +pub use native_path_string::PathConvention; + pub const FILE_SCHEME: &str = "file"; /// An immutable, cross-platform representation of a `file:` URI. diff --git a/codex-rs/utils/path-uri/src/native_path_string.rs b/codex-rs/utils/path-uri/src/native_path_string.rs new file mode 100644 index 0000000000..07554b632c --- /dev/null +++ b/codex-rs/utils/path-uri/src/native_path_string.rs @@ -0,0 +1,238 @@ +use crate::PathUri; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use serde::Serializer; +use std::fmt; +use thiserror::Error; +use ts_rs::TS; + +/// Path syntax used to render a [`PathUri`] as an operating-system path. +/// +/// This describes path grammar rather than a specific operating system because +/// Linux and macOS share the POSIX representation relevant here. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(rename_all = "snake_case")] +pub enum PathConvention { + Posix, + Windows, +} + +impl PathConvention { + /// Returns the path convention used by the current process. + #[cfg(windows)] + pub const fn native() -> Self { + Self::Windows + } + + /// Returns the path convention used by the current process. + #[cfg(unix)] + pub const fn native() -> Self { + Self::Posix + } +} + +#[cfg(not(any(windows, unix)))] +compile_error!("PathConvention::native() requires a Windows or Unix target"); + +impl fmt::Display for PathConvention { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Posix => f.write_str("POSIX"), + Self::Windows => f.write_str("Windows"), + } + } +} + +/// A UTF-8 path rendered using an explicitly selected native path convention. +/// +/// "Native" refers to the supplied [`PathConvention`], which may be foreign to +/// the operating system running this process. The inner string is private so +/// path-producing code must render through [`Self::from_path_uri`] rather than +/// accidentally applying the current host's path rules. +#[derive(Clone, Debug, PartialEq, Eq, Hash, TS)] +#[ts(type = "string")] +pub struct NativePathString(String); + +impl NativePathString { + /// Renders a path URI using the requested native path convention. + /// + /// TODO(anp): Once `PathUri` carries an environment identifier, resolve the path + /// convention from that identifier instead of requiring it explicitly. + pub fn from_path_uri( + path: &PathUri, + convention: PathConvention, + ) -> Result { + let value = match convention { + PathConvention::Posix => render_posix_path(path)?, + PathConvention::Windows => render_windows_path(path)?, + }; + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn into_string(self) -> String { + self.0 + } +} + +impl fmt::Display for NativePathString { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl Serialize for NativePathString { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&self.0) + } +} + +impl JsonSchema for NativePathString { + fn schema_name() -> String { + "NativePathString".to_string() + } + + fn json_schema(generator: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema { + String::json_schema(generator) + } +} + +fn render_posix_path(path: &PathUri) -> Result { + let url = path.to_url(); + if url.host_str().is_some() { + return Err(incompatible_convention(path, PathConvention::Posix)); + } + + let mut rendered = String::new(); + for segment in path_segments(&url) { + rendered.push('/'); + rendered.push_str(&decode_native_segment( + path, + segment, + PathConvention::Posix, + )?); + } + Ok(rendered) +} + +fn render_windows_path(path: &PathUri) -> Result { + let url = path.to_url(); + let mut segments = path_segments(&url); + let mut rendered = String::new(); + if let Some(host) = url.host_str() { + let Some(share) = segments.next() else { + return Err(incompatible_convention(path, PathConvention::Windows)); + }; + let share = decode_native_segment(path, share, PathConvention::Windows)?; + if share.is_empty() { + return Err(incompatible_convention(path, PathConvention::Windows)); + } + validate_windows_component(path, &share)?; + rendered.push_str(r"\\"); + rendered.push_str(host); + rendered.push('\\'); + rendered.push_str(&share); + } else { + let Some(drive) = segments.next() else { + return Err(incompatible_convention(path, PathConvention::Windows)); + }; + let drive = decode_native_segment(path, drive, PathConvention::Windows)?; + let bytes = drive.as_bytes(); + if bytes.len() != 2 || !bytes[0].is_ascii_alphabetic() || bytes[1] != b':' { + return Err(incompatible_convention(path, PathConvention::Windows)); + } + rendered.push_str(&drive); + } + + for segment in segments { + let segment = decode_native_segment(path, segment, PathConvention::Windows)?; + if !segment.is_empty() { + validate_windows_component(path, &segment)?; + } + rendered.push('\\'); + rendered.push_str(&segment); + } + if rendered.len() == 2 && rendered.as_bytes()[1] == b':' { + rendered.push('\\'); + } + Ok(rendered) +} + +fn path_segments(url: &url::Url) -> std::str::Split<'_, char> { + url.path_segments() + .unwrap_or_else(|| unreachable!("validated file URLs have path segments")) +} + +fn decode_native_segment( + path: &PathUri, + segment: &str, + convention: PathConvention, +) -> Result { + let bytes = urlencoding::decode_binary(segment.as_bytes()); + let contains_separator = + bytes.contains(&b'/') || (convention == PathConvention::Windows && bytes.contains(&b'\\')); + if contains_separator { + return Err(NativePathStringError::EncodedSeparator { + path: path.to_string(), + convention, + }); + } + std::str::from_utf8(&bytes) + .map(str::to_string) + .map_err(|_| NativePathStringError::NonUtf8 { + path: path.to_string(), + }) +} + +fn validate_windows_component( + path: &PathUri, + component: &str, +) -> Result<(), NativePathStringError> { + let contains_invalid_character = component + .chars() + .any(|character| character <= '\u{1f}' || r#"<>:"/\|?*"#.contains(character)); + if contains_invalid_character || component.ends_with([' ', '.']) { + return Err(NativePathStringError::InvalidWindowsComponent { + path: path.to_string(), + component: component.to_string(), + }); + } + Ok(()) +} + +fn incompatible_convention(path: &PathUri, convention: PathConvention) -> NativePathStringError { + NativePathStringError::IncompatibleConvention { + path: path.to_string(), + convention, + } +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum NativePathStringError { + #[error("path URI `{path}` cannot be rendered using {convention} path syntax")] + IncompatibleConvention { + path: String, + convention: PathConvention, + }, + #[error("path URI `{path}` contains path bytes that are not valid UTF-8")] + NonUtf8 { path: String }, + #[error("path URI `{path}` contains a percent-encoded separator for {convention} path syntax")] + EncodedSeparator { + path: String, + convention: PathConvention, + }, + #[error("path URI `{path}` contains invalid Windows path component `{component}`")] + InvalidWindowsComponent { path: String, component: String }, +} + +#[cfg(test)] +#[path = "native_path_string_tests.rs"] +mod tests; diff --git a/codex-rs/utils/path-uri/src/native_path_string_tests.rs b/codex-rs/utils/path-uri/src/native_path_string_tests.rs new file mode 100644 index 0000000000..6741f57ad3 --- /dev/null +++ b/codex-rs/utils/path-uri/src/native_path_string_tests.rs @@ -0,0 +1,157 @@ +use super::*; +use crate::PathUri; +use pretty_assertions::assert_eq; + +#[test] +fn renders_posix_paths_on_every_host() { + for (uri, expected) in [ + ("file:///", "/"), + ("file:///home/alice/a%20file.rs", "/home/alice/a file.rs"), + ("file:///tmp/", "/tmp/"), + ("file:///C:/Project", "/C:/Project"), + ("file:///tmp/%E2%98%83", "/tmp/☃"), + ("file:///tmp/a%5Cb", "/tmp/a\\b"), + ] { + let path = PathUri::parse(uri).expect("valid file URI"); + assert_eq!( + NativePathString::from_path_uri(&path, PathConvention::Posix) + .map(NativePathString::into_string), + Ok(expected.to_string()), + "rendering {uri}" + ); + } +} + +#[test] +fn renders_windows_drive_paths_on_every_host() { + for (uri, expected) in [ + ( + "file:///C:/Users/Alice%20Smith/src/main.rs", + r"C:\Users\Alice Smith\src\main.rs", + ), + ("file:///C:/", "C:\\"), + ("file:///C:", "C:\\"), + ("file:///d:/snowman/%E2%98%83", r"d:\snowman\☃"), + ("file:///C:/tmp/", "C:\\tmp\\"), + ("file:///C:/test%20with%20%25/path", r"C:\test with %\path"), + ( + "file:///C:/test%20with%20%2525/c%23code", + r"C:\test with %25\c#code", + ), + ( + "file:///C:/Source/Z%C3%BCrich%20or%20Zurich%20(%CB%88zj%CA%8A%C9%99r%C9%AAk,/Code/resources/app/plugins/c%23/plugin.json", + r"C:\Source\Zürich or Zurich (ˈzjʊərɪk,\Code\resources\app\plugins\c#\plugin.json", + ), + ( + "file:///C:/Users/Abd-al-Haseeb%27s_Dell/Studio/w3mage/wp-content/database.ht.sqlite", + r"C:\Users\Abd-al-Haseeb's_Dell\Studio\w3mage\wp-content\database.ht.sqlite", + ), + ("file:///C:/project/%25A0.txt", r"C:\project\%A0.txt"), + ("file:///C:/project/%252e.txt", r"C:\project\%2e.txt"), + ] { + let path = PathUri::parse(uri).expect("valid file URI"); + assert_eq!( + NativePathString::from_path_uri(&path, PathConvention::Windows) + .map(NativePathString::into_string), + Ok(expected.to_string()), + "rendering {uri}" + ); + } +} + +#[test] +fn renders_windows_unc_paths_on_every_host() { + for (uri, expected) in [ + ( + "file://server/share/src/main.rs", + r"\\server\share\src\main.rs", + ), + ("file://server/share/", "\\\\server\\share\\"), + ("file://shares/files/c%23/p.cs", r"\\shares\files\c#\p.cs"), + ( + "file://monacotools1/certificates/SSL/", + "\\\\monacotools1\\certificates\\SSL\\", + ), + ] { + let path = PathUri::parse(uri).expect("valid file URI"); + assert_eq!( + NativePathString::from_path_uri(&path, PathConvention::Windows) + .map(NativePathString::into_string), + Ok(expected.to_string()), + "rendering {uri}" + ); + } +} + +#[test] +fn rejects_paths_incompatible_with_the_convention() { + for (uri, convention) in [ + ("file://server/share/file.rs", PathConvention::Posix), + ("file:///home/alice/file.rs", PathConvention::Windows), + ("file://server/", PathConvention::Windows), + ("file:///_:/path", PathConvention::Windows), + ] { + let path = PathUri::parse(uri).expect("valid file URI"); + assert!(matches!( + NativePathString::from_path_uri(&path, convention), + Err(NativePathStringError::IncompatibleConvention { .. }) + )); + } +} + +#[test] +fn rejects_non_utf8_paths() { + for uri in ["file:///tmp/non-utf8-%FF", "file:///tmp/non-utf8-%A0"] { + let path = PathUri::parse(uri).expect("valid file URI"); + + assert!(matches!( + NativePathString::from_path_uri(&path, PathConvention::Posix), + Err(NativePathStringError::NonUtf8 { .. }) + )); + } +} + +#[test] +fn rejects_encoded_separators() { + for (uri, convention) in [ + ("file:///tmp/a%2Fb", PathConvention::Posix), + ("file:///C:/a%2Fb", PathConvention::Windows), + ("file:///C:/a%5Cb", PathConvention::Windows), + ] { + let path = PathUri::parse(uri).expect("valid file URI"); + assert!(matches!( + NativePathString::from_path_uri(&path, convention), + Err(NativePathStringError::EncodedSeparator { .. }) + )); + } +} + +#[test] +fn rejects_invalid_windows_components() { + for uri in [ + "file:///C:/a%3Fb", + "file:///C:/a%2Ab", + "file:///C:/trailing.", + "file:///C:/trailing%20", + "file:///C:/control-%01", + "file://server/sh%3Fare/file.rs", + ] { + let path = PathUri::parse(uri).expect("valid file URI"); + assert!(matches!( + NativePathString::from_path_uri(&path, PathConvention::Windows), + Err(NativePathStringError::InvalidWindowsComponent { .. }) + )); + } +} + +#[test] +fn serializes_as_a_string() { + let path = PathUri::parse("file:///workspace/src/lib.rs").expect("valid file URI"); + let rendered = NativePathString::from_path_uri(&path, PathConvention::Posix) + .expect("POSIX URI should render"); + + assert_eq!( + serde_json::to_string(&rendered).expect("rendered path should serialize"), + r#""/workspace/src/lib.rs""# + ); +}