mirror of
https://github.com/openai/codex.git
synced 2026-09-08 15:50:34 +00:00
path-uri: render native paths across platforms
This commit is contained in:
@@ -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())
|
||||
|
||||
@@ -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!({
|
||||
|
||||
@@ -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.
|
||||
|
||||
238
codex-rs/utils/path-uri/src/native_path_string.rs
Normal file
238
codex-rs/utils/path-uri/src/native_path_string.rs
Normal 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;
|
||||
157
codex-rs/utils/path-uri/src/native_path_string_tests.rs
Normal file
157
codex-rs/utils/path-uri/src/native_path_string_tests.rs
Normal 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""#
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user