path-uri: render native paths across platforms

This commit is contained in:
Adam Perry
2026-06-12 08:15:20 +00:00
parent bf667c7003
commit 740e11ecb8
5 changed files with 465 additions and 0 deletions

View File

@@ -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())

View File

@@ -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, NativePathStringError> {
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!({

View File

@@ -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.

View File

@@ -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<Self, NativePathStringError> {
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<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
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<String, NativePathStringError> {
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<String, NativePathStringError> {
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<String, NativePathStringError> {
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;

View File

@@ -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""#
);
}