Skip to content

Commit 42b8f28

Browse files
Fixed resume matching to respect case insensitivity when using WSL mount points (#8000)
This fixes #7995
1 parent 14d80c3 commit 42b8f28

File tree

6 files changed

+133
-5
lines changed

6 files changed

+133
-5
lines changed

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ If you don’t have the tool:
7575

7676
- Tests should use pretty_assertions::assert_eq for clearer diffs. Import this at the top of the test module if it isn't already.
7777
- Prefer deep equals comparisons whenever possible. Perform `assert_eq!()` on entire objects, rather than individual fields.
78+
- Avoid mutating process environment in tests; prefer passing environment-derived flags or dependencies from above.
7879

7980
### Integration tests (core)
8081

codex-rs/core/src/config/service.rs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use crate::config_loader::ConfigLayerStack;
77
use crate::config_loader::LoaderOverrides;
88
use crate::config_loader::load_config_layers_state;
99
use crate::config_loader::merge_toml_values;
10+
use crate::path_utils;
1011
use codex_app_server_protocol::Config as ApiConfig;
1112
use codex_app_server_protocol::ConfigBatchWriteParams;
1213
use codex_app_server_protocol::ConfigLayerMetadata;
@@ -470,9 +471,10 @@ fn validate_config(value: &TomlValue) -> Result<(), toml::de::Error> {
470471
}
471472

472473
fn paths_match(expected: &Path, provided: &Path) -> bool {
473-
if let (Ok(expanded_expected), Ok(expanded_provided)) =
474-
(expected.canonicalize(), provided.canonicalize())
475-
{
474+
if let (Ok(expanded_expected), Ok(expanded_provided)) = (
475+
path_utils::normalize_for_path_comparison(expected),
476+
path_utils::normalize_for_path_comparison(provided),
477+
) {
476478
return expanded_expected == expanded_provided;
477479
}
478480

codex-rs/core/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ mod mcp_tool_call;
4141
mod message_history;
4242
mod model_provider_info;
4343
pub mod parse_command;
44+
pub mod path_utils;
4445
pub mod powershell;
4546
pub mod sandboxing;
4647
mod stream_events_utils;

codex-rs/core/src/path_utils.rs

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
use std::path::Path;
2+
use std::path::PathBuf;
3+
4+
use crate::env;
5+
6+
pub fn normalize_for_path_comparison(path: &Path) -> std::io::Result<PathBuf> {
7+
let canonical = path.canonicalize()?;
8+
Ok(normalize_for_wsl(canonical))
9+
}
10+
11+
fn normalize_for_wsl(path: PathBuf) -> PathBuf {
12+
normalize_for_wsl_with_flag(path, env::is_wsl())
13+
}
14+
15+
fn normalize_for_wsl_with_flag(path: PathBuf, is_wsl: bool) -> PathBuf {
16+
if !is_wsl {
17+
return path;
18+
}
19+
20+
if !is_wsl_case_insensitive_path(&path) {
21+
return path;
22+
}
23+
24+
lower_ascii_path(path)
25+
}
26+
27+
fn is_wsl_case_insensitive_path(path: &Path) -> bool {
28+
#[cfg(target_os = "linux")]
29+
{
30+
use std::os::unix::ffi::OsStrExt;
31+
use std::path::Component;
32+
33+
let mut components = path.components();
34+
let Some(Component::RootDir) = components.next() else {
35+
return false;
36+
};
37+
let Some(Component::Normal(mnt)) = components.next() else {
38+
return false;
39+
};
40+
if !ascii_eq_ignore_case(mnt.as_bytes(), b"mnt") {
41+
return false;
42+
}
43+
let Some(Component::Normal(drive)) = components.next() else {
44+
return false;
45+
};
46+
let drive_bytes = drive.as_bytes();
47+
drive_bytes.len() == 1 && drive_bytes[0].is_ascii_alphabetic()
48+
}
49+
#[cfg(not(target_os = "linux"))]
50+
{
51+
let _ = path;
52+
false
53+
}
54+
}
55+
56+
#[cfg(target_os = "linux")]
57+
fn ascii_eq_ignore_case(left: &[u8], right: &[u8]) -> bool {
58+
left.len() == right.len()
59+
&& left
60+
.iter()
61+
.zip(right)
62+
.all(|(lhs, rhs)| lhs.to_ascii_lowercase() == *rhs)
63+
}
64+
65+
#[cfg(target_os = "linux")]
66+
fn lower_ascii_path(path: PathBuf) -> PathBuf {
67+
use std::ffi::OsString;
68+
use std::os::unix::ffi::OsStrExt;
69+
use std::os::unix::ffi::OsStringExt;
70+
71+
// WSL mounts Windows drives under /mnt/<drive>, which are case-insensitive.
72+
let bytes = path.as_os_str().as_bytes();
73+
let mut lowered = Vec::with_capacity(bytes.len());
74+
for byte in bytes {
75+
lowered.push(byte.to_ascii_lowercase());
76+
}
77+
PathBuf::from(OsString::from_vec(lowered))
78+
}
79+
80+
#[cfg(not(target_os = "linux"))]
81+
fn lower_ascii_path(path: PathBuf) -> PathBuf {
82+
path
83+
}
84+
85+
#[cfg(test)]
86+
mod tests {
87+
#[cfg(target_os = "linux")]
88+
mod wsl {
89+
use super::super::normalize_for_wsl_with_flag;
90+
use pretty_assertions::assert_eq;
91+
use std::path::PathBuf;
92+
93+
#[test]
94+
fn wsl_mnt_drive_paths_lowercase() {
95+
let normalized = normalize_for_wsl_with_flag(PathBuf::from("/mnt/C/Users/Dev"), true);
96+
97+
assert_eq!(normalized, PathBuf::from("/mnt/c/users/dev"));
98+
}
99+
100+
#[test]
101+
fn wsl_non_drive_paths_unchanged() {
102+
let path = PathBuf::from("/mnt/cc/Users/Dev");
103+
let normalized = normalize_for_wsl_with_flag(path.clone(), true);
104+
105+
assert_eq!(normalized, path);
106+
}
107+
108+
#[test]
109+
fn wsl_non_mnt_paths_unchanged() {
110+
let path = PathBuf::from("/home/Dev");
111+
let normalized = normalize_for_wsl_with_flag(path.clone(), true);
112+
113+
assert_eq!(normalized, path);
114+
}
115+
}
116+
}

codex-rs/tui/src/resume_picker.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ use codex_core::ConversationsPage;
1010
use codex_core::Cursor;
1111
use codex_core::INTERACTIVE_SESSION_SOURCES;
1212
use codex_core::RolloutRecorder;
13+
use codex_core::path_utils;
1314
use codex_protocol::items::TurnItem;
1415
use color_eyre::eyre::Result;
1516
use crossterm::event::KeyCode;
@@ -670,7 +671,10 @@ fn extract_session_meta_from_head(head: &[serde_json::Value]) -> (Option<PathBuf
670671
}
671672

672673
fn paths_match(a: &Path, b: &Path) -> bool {
673-
if let (Ok(ca), Ok(cb)) = (a.canonicalize(), b.canonicalize()) {
674+
if let (Ok(ca), Ok(cb)) = (
675+
path_utils::normalize_for_path_comparison(a),
676+
path_utils::normalize_for_path_comparison(b),
677+
) {
674678
return ca == cb;
675679
}
676680
a == b

codex-rs/tui2/src/resume_picker.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ use codex_core::ConversationsPage;
1010
use codex_core::Cursor;
1111
use codex_core::INTERACTIVE_SESSION_SOURCES;
1212
use codex_core::RolloutRecorder;
13+
use codex_core::path_utils;
1314
use codex_protocol::items::TurnItem;
1415
use color_eyre::eyre::Result;
1516
use crossterm::event::KeyCode;
@@ -670,7 +671,10 @@ fn extract_session_meta_from_head(head: &[serde_json::Value]) -> (Option<PathBuf
670671
}
671672

672673
fn paths_match(a: &Path, b: &Path) -> bool {
673-
if let (Ok(ca), Ok(cb)) = (a.canonicalize(), b.canonicalize()) {
674+
if let (Ok(ca), Ok(cb)) = (
675+
path_utils::normalize_for_path_comparison(a),
676+
path_utils::normalize_for_path_comparison(b),
677+
) {
674678
return ca == cb;
675679
}
676680
a == b

0 commit comments

Comments
 (0)