mirror of
https://github.com/openai/codex.git
synced 2026-11-14 21:40:39 +00:00
Resolve pasted Windows paths with wslpath in WSL (#61419)
## Why Pasted Windows drive paths were mapped unconditionally to `/mnt/<drive>`, even when that mount was unavailable or WSL used a different mapping. ## What changed Use `/bin/wslpath -u` with a one-second deadline and require an absolute result. Fall back to `/mnt/<drive>` only when the helper is missing and `/proc/self/mountinfo` confirms the mount exists. Reject fallback paths that traverse above the drive root and return no path when conversion fails. Share the bounded subprocess output helper with tmux probing. ## Testing Update coverage for quoted and unquoted Windows paths with spaces. Add fallback tests for a mounted drive, a missing drive mount, and traversal above the drive root. GitOrigin-RevId: f6a05045b2108722a686e9e5b766fc8cb233bea0
This commit is contained in:
+137
-139
@@ -272,24 +272,24 @@ pub fn normalize_pasted_path(pasted: &str) -> Option<PathBuf> {
|
||||
// TODO: We'll improve the implementation/unit tests over time, as appropriate.
|
||||
// Possibly use typed-path: https://github.com/openai/codex/pull/2567/commits/3cc92b78e0a1f94e857cf4674d3a9db918ed352e
|
||||
//
|
||||
// Detect unquoted Windows paths and bypass POSIX shlex which
|
||||
// treats backslashes as escapes (e.g., C:\Users\Alice\file.png).
|
||||
// Also handles UNC paths (\\server\share\path).
|
||||
if let Some(path) = normalize_windows_path(unquoted) {
|
||||
return Some(path);
|
||||
}
|
||||
|
||||
// shell-escaped single path → unescaped
|
||||
let parts: Vec<String> = shlex::Shlex::new(pasted).collect();
|
||||
if parts.len() == 1 {
|
||||
let part = parts.into_iter().next()?;
|
||||
if let Some(path) = normalize_windows_path(&part) {
|
||||
return Some(path);
|
||||
// Bypass POSIX shlex for Windows paths, where backslashes are separators.
|
||||
let path = if is_windows_drive_path(unquoted) || unquoted.starts_with("\\\\") {
|
||||
unquoted.to_owned()
|
||||
} else {
|
||||
let mut parts = shlex::Shlex::new(pasted);
|
||||
let part = parts.next()?;
|
||||
if parts.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
return Some(PathBuf::from(part));
|
||||
part
|
||||
};
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
if is_windows_drive_path(&path) && is_probably_wsl() {
|
||||
return convert_windows_path_to_wsl(&path);
|
||||
}
|
||||
|
||||
None
|
||||
Some(PathBuf::from(path))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -310,60 +310,84 @@ pub(crate) fn is_probably_wsl() -> bool {
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn convert_windows_path_to_wsl(input: &str) -> Option<PathBuf> {
|
||||
if input.starts_with("\\\\") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let drive_letter = input.chars().next()?.to_ascii_lowercase();
|
||||
if !drive_letter.is_ascii_lowercase() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if input.get(1..2) != Some(":") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut result = PathBuf::from(format!("/mnt/{drive_letter}"));
|
||||
for component in input
|
||||
.get(2..)?
|
||||
.trim_start_matches(['\\', '/'])
|
||||
.split(['\\', '/'])
|
||||
.filter(|component| !component.is_empty())
|
||||
{
|
||||
result.push(component);
|
||||
}
|
||||
|
||||
Some(result)
|
||||
convert_windows_path(input, Path::new("/bin/wslpath"), || {
|
||||
std::fs::read("/proc/self/mountinfo")
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_windows_path(input: &str) -> Option<PathBuf> {
|
||||
// Drive letter path: C:\ or C:/
|
||||
let drive = input
|
||||
.chars()
|
||||
.next()
|
||||
.map(|c| c.is_ascii_alphabetic())
|
||||
.unwrap_or(false)
|
||||
&& input.get(1..2) == Some(":")
|
||||
&& input
|
||||
.get(2..3)
|
||||
.map(|s| s == "\\" || s == "/")
|
||||
.unwrap_or(false);
|
||||
// UNC path: \\server\share
|
||||
let unc = input.starts_with("\\\\");
|
||||
if !drive && !unc {
|
||||
#[cfg(target_os = "linux")]
|
||||
fn convert_windows_path(
|
||||
input: &str,
|
||||
wslpath: &Path,
|
||||
read_mountinfo: impl FnOnce() -> std::io::Result<Vec<u8>>,
|
||||
) -> Option<PathBuf> {
|
||||
use std::ffi::OsStr;
|
||||
use std::io::ErrorKind;
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
use std::process::Command;
|
||||
use std::process::Stdio;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
if !is_windows_drive_path(input) {
|
||||
return None;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if is_probably_wsl()
|
||||
&& let Some(converted) = convert_windows_path_to_wsl(input)
|
||||
{
|
||||
return Some(converted);
|
||||
match std::fs::metadata(wslpath) {
|
||||
Ok(_) => {}
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => {
|
||||
return windows_path_on_default_mount(input, &read_mountinfo().ok()?);
|
||||
}
|
||||
Err(_) => return None,
|
||||
}
|
||||
|
||||
Some(PathBuf::from(input))
|
||||
// Use the WSL-installed helper without looking in the working directory or PATH.
|
||||
let mut command = Command::new(wslpath);
|
||||
command
|
||||
.args(["-u", input])
|
||||
// wslpath reads its working directory even for absolute paths.
|
||||
.current_dir("/")
|
||||
.stdin(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
let output = crate::command_output::command_stdout_before_deadline(
|
||||
&mut command,
|
||||
Instant::now() + Duration::from_secs(/*secs*/ 1),
|
||||
)?;
|
||||
let path = Path::new(OsStr::from_bytes(output.strip_suffix(b"\n")?));
|
||||
path.is_absolute().then(|| path.to_path_buf())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn windows_path_on_default_mount(input: &str, mountinfo: &[u8]) -> Option<PathBuf> {
|
||||
let drive = char::from(input.as_bytes()[0].to_ascii_lowercase());
|
||||
let mount_point = format!("/mnt/{drive}");
|
||||
// Field five is the mount point in this process's mount namespace.
|
||||
// An ordinary directory at the same path is insufficient.
|
||||
if !mountinfo
|
||||
.split(|byte| *byte == b'\n')
|
||||
.any(|line| line.split(|byte| *byte == b' ').nth(4) == Some(mount_point.as_bytes()))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut path = PathBuf::from(&mount_point);
|
||||
for component in input[2..].split(['\\', '/']) {
|
||||
match component {
|
||||
"" | "." => {}
|
||||
".." => {
|
||||
if path == Path::new(&mount_point) {
|
||||
return None;
|
||||
}
|
||||
path.pop();
|
||||
}
|
||||
component => path.push(component),
|
||||
}
|
||||
}
|
||||
Some(path)
|
||||
}
|
||||
|
||||
fn is_windows_drive_path(input: &str) -> bool {
|
||||
matches!(input.as_bytes(), [drive, b':', b'\\' | b'/', ..] if drive.is_ascii_alphabetic())
|
||||
}
|
||||
|
||||
/// Infer an image format for the provided path based on its extension.
|
||||
@@ -396,6 +420,7 @@ mod pasted_search_query_tests {
|
||||
#[cfg(test)]
|
||||
mod pasted_paths_tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[test]
|
||||
@@ -405,23 +430,6 @@ mod pasted_paths_tests {
|
||||
assert_eq!(result, PathBuf::from("/tmp/example.png"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_file_url_windows() {
|
||||
let input = r"C:\Temp\example.png";
|
||||
let result = normalize_pasted_path(input).expect("should parse file URL");
|
||||
#[cfg(target_os = "linux")]
|
||||
let expected = if is_probably_wsl()
|
||||
&& let Some(converted) = convert_windows_path_to_wsl(input)
|
||||
{
|
||||
converted
|
||||
} else {
|
||||
PathBuf::from(r"C:\Temp\example.png")
|
||||
};
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let expected = PathBuf::from(r"C:\Temp\example.png");
|
||||
assert_eq!(result, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_shell_escaped_single_path() {
|
||||
let input = "/home/user/My\\ File.png";
|
||||
@@ -476,58 +484,64 @@ mod pasted_paths_tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_single_quoted_windows_path() {
|
||||
let input = r"'C:\\Users\\Alice\\My File.jpeg'";
|
||||
let unquoted = r"C:\\Users\\Alice\\My File.jpeg";
|
||||
let result =
|
||||
normalize_pasted_path(input).expect("should trim single quotes on windows path");
|
||||
fn normalize_windows_paths_with_spaces() {
|
||||
let unquoted = r"C:\Users\Alice\My File.jpeg";
|
||||
let expected = Some(PathBuf::from(unquoted));
|
||||
#[cfg(target_os = "linux")]
|
||||
let expected = if is_probably_wsl()
|
||||
&& let Some(converted) = convert_windows_path_to_wsl(unquoted)
|
||||
{
|
||||
converted
|
||||
let expected = if is_probably_wsl() {
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
|
||||
// A container on a WSL kernel may lack the distro's wslpath symlink.
|
||||
let Ok(mut output) = std::process::Command::new("/bin/wslpath")
|
||||
.args(["-u", unquoted])
|
||||
.current_dir("/")
|
||||
.output()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
output.status.success().then(|| {
|
||||
assert_eq!(output.stdout.pop(), Some(b'\n'));
|
||||
PathBuf::from(std::ffi::OsString::from_vec(output.stdout))
|
||||
})
|
||||
} else {
|
||||
PathBuf::from(unquoted)
|
||||
expected
|
||||
};
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let expected = PathBuf::from(unquoted);
|
||||
assert_eq!(result, expected);
|
||||
|
||||
for input in [
|
||||
unquoted,
|
||||
r"'C:\Users\Alice\My File.jpeg'",
|
||||
r#""C:\Users\Alice\My File.jpeg""#,
|
||||
] {
|
||||
assert_eq!(normalize_pasted_path(input), expected, "{input}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn normalize_double_quoted_windows_path() {
|
||||
let input = r#""C:\\Users\\Alice\\My File.jpeg""#;
|
||||
let unquoted = r"C:\\Users\\Alice\\My File.jpeg";
|
||||
let result =
|
||||
normalize_pasted_path(input).expect("should trim double quotes on windows path");
|
||||
#[cfg(target_os = "linux")]
|
||||
let expected = if is_probably_wsl()
|
||||
&& let Some(converted) = convert_windows_path_to_wsl(unquoted)
|
||||
{
|
||||
converted
|
||||
} else {
|
||||
PathBuf::from(unquoted)
|
||||
};
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let expected = PathBuf::from(unquoted);
|
||||
assert_eq!(result, expected);
|
||||
}
|
||||
fn windows_path_fallback_requires_missing_wslpath_and_default_mount() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let missing_wslpath = directory.path().join("wslpath");
|
||||
let mountinfo = b"24 23 0:21 / /mnt/c rw - 9p none rw\n\
|
||||
25 23 0:22 / /mnt/d/Users rw - 9p none rw\n";
|
||||
|
||||
#[test]
|
||||
fn normalize_unquoted_windows_path_with_spaces() {
|
||||
let input = r"C:\\Users\\Alice\\My Pictures\\example image.png";
|
||||
let result = normalize_pasted_path(input).expect("should accept unquoted windows path");
|
||||
#[cfg(target_os = "linux")]
|
||||
let expected = if is_probably_wsl()
|
||||
&& let Some(converted) = convert_windows_path_to_wsl(input)
|
||||
{
|
||||
converted
|
||||
} else {
|
||||
PathBuf::from(r"C:\\Users\\Alice\\My Pictures\\example image.png")
|
||||
};
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let expected = PathBuf::from(r"C:\\Users\\Alice\\My Pictures\\example image.png");
|
||||
assert_eq!(result, expected);
|
||||
assert_eq!(
|
||||
convert_windows_path(r"C:\Users\Alice/My File.jpeg", &missing_wslpath, || {
|
||||
Ok(mountinfo.to_vec())
|
||||
}),
|
||||
Some(PathBuf::from("/mnt/c/Users/Alice/My File.jpeg"))
|
||||
);
|
||||
assert_eq!(
|
||||
convert_windows_path(r"D:\Users\Alice\My File.jpeg", &missing_wslpath, || {
|
||||
Ok(mountinfo.to_vec())
|
||||
}),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
convert_windows_path(r"C:\..\etc\image.png", &missing_wslpath, || {
|
||||
Ok(mountinfo.to_vec())
|
||||
}),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -555,20 +569,4 @@ mod pasted_paths_tests {
|
||||
EncodedImageFormat::Other
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn normalize_windows_path_in_wsl() {
|
||||
// This test only runs on actual WSL systems
|
||||
if !is_probably_wsl() {
|
||||
// Skip test if not on WSL
|
||||
return;
|
||||
}
|
||||
let input = r"C:\\Users\\Alice\\Pictures\\example image.png";
|
||||
let result = normalize_pasted_path(input).expect("should convert windows path on wsl");
|
||||
assert_eq!(
|
||||
result,
|
||||
PathBuf::from("/mnt/c/Users/Alice/Pictures/example image.png")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
//! Capture short subprocess output without waiting on a pipe held open by descendants.
|
||||
|
||||
use std::io::Read;
|
||||
use std::io::Seek;
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
const POLL_INTERVAL: Duration = Duration::from_millis(/*millis*/ 10);
|
||||
const MAX_OUTPUT_BYTES: u64 = 4096;
|
||||
|
||||
pub(crate) fn command_stdout_before_deadline(
|
||||
command: &mut Command,
|
||||
deadline: Instant,
|
||||
) -> Option<Vec<u8>> {
|
||||
if Instant::now() >= deadline {
|
||||
return None;
|
||||
}
|
||||
let mut stdout = tempfile::tempfile().ok()?;
|
||||
command.stdout(stdout.try_clone().ok()?);
|
||||
let mut child = command.spawn().ok()?;
|
||||
loop {
|
||||
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => {
|
||||
if !status.success() {
|
||||
return None;
|
||||
}
|
||||
stdout.rewind().ok()?;
|
||||
let mut output = Vec::new();
|
||||
stdout
|
||||
.take(MAX_OUTPUT_BYTES)
|
||||
.read_to_end(&mut output)
|
||||
.ok()?;
|
||||
return Some(output);
|
||||
}
|
||||
Ok(None) if !remaining.is_zero() => {
|
||||
std::thread::sleep(remaining.min(POLL_INTERVAL));
|
||||
}
|
||||
Ok(None) | Err(_) => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -130,6 +130,7 @@ mod clipboard_paste;
|
||||
mod clock_format;
|
||||
mod collaboration_modes;
|
||||
mod color;
|
||||
mod command_output;
|
||||
mod config_panel;
|
||||
mod config_update;
|
||||
mod copy_input_guard;
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
//! Mouse capture is suppressed only when the containing session explicitly reports mouse off.
|
||||
//! Missing tmux or an inconclusive probe preserves the normal input policy.
|
||||
|
||||
use std::io::Read;
|
||||
use std::io::Seek;
|
||||
use std::process::Command;
|
||||
use std::process::Stdio;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
@@ -15,9 +13,9 @@ use std::time::Instant;
|
||||
|
||||
use itertools::Itertools;
|
||||
|
||||
use crate::command_output::command_stdout_before_deadline;
|
||||
|
||||
const PROBE_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 1);
|
||||
const PROBE_POLL_INTERVAL: Duration = Duration::from_millis(/*millis*/ 10);
|
||||
const MAX_PROBE_OUTPUT_BYTES: u64 = 4096;
|
||||
static PROBE_IN_FLIGHT: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Capture policy for the current terminal setup, shared by fullscreen and overlays.
|
||||
@@ -77,40 +75,6 @@ pub(super) fn options() -> Options {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn command_stdout_before_deadline(command: &mut Command, deadline: Instant) -> Option<Vec<u8>> {
|
||||
if Instant::now() >= deadline {
|
||||
return None;
|
||||
}
|
||||
let mut stdout = tempfile::tempfile().ok()?;
|
||||
command.stdout(stdout.try_clone().ok()?);
|
||||
let mut child = command.spawn().ok()?;
|
||||
loop {
|
||||
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => {
|
||||
if !status.success() {
|
||||
return None;
|
||||
}
|
||||
stdout.rewind().ok()?;
|
||||
let mut output = Vec::new();
|
||||
stdout
|
||||
.take(MAX_PROBE_OUTPUT_BYTES)
|
||||
.read_to_end(&mut output)
|
||||
.ok()?;
|
||||
return Some(output);
|
||||
}
|
||||
Ok(None) if !remaining.is_zero() => {
|
||||
std::thread::sleep(remaining.min(PROBE_POLL_INTERVAL));
|
||||
}
|
||||
Ok(None) | Err(_) => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const OPTIONS_FORMAT: &str = concat!(
|
||||
"#{extended-keys-format}\t#{mouse}\t#{allow-passthrough}\t",
|
||||
"#{client_termname}\t#{client_termtype}\t#{client_termfeatures}",
|
||||
|
||||
Reference in New Issue
Block a user