Skip to main content

codex_utils_path/
lib.rs

1//! Path normalization, symlink resolution, and atomic writes shared across Codex crates.
2
3pub(crate) mod env;
4pub use env::is_wsl;
5
6use codex_utils_absolute_path::AbsolutePathBuf;
7use std::collections::HashSet;
8use std::io;
9use std::path::Path;
10use std::path::PathBuf;
11use tempfile::NamedTempFile;
12
13pub fn normalize_for_path_comparison(path: impl AsRef<Path>) -> std::io::Result<PathBuf> {
14    let canonical = path.as_ref().canonicalize()?;
15    Ok(normalize_for_wsl(canonical))
16}
17
18/// Compare paths after applying Codex's filesystem normalization.
19///
20/// If either path cannot be normalized, this falls back to direct path equality.
21pub fn paths_match_after_normalization(left: impl AsRef<Path>, right: impl AsRef<Path>) -> bool {
22    if let (Ok(left), Ok(right)) = (
23        normalize_for_path_comparison(left.as_ref()),
24        normalize_for_path_comparison(right.as_ref()),
25    ) {
26        return left == right;
27    }
28    left.as_ref() == right.as_ref()
29}
30
31pub fn normalize_for_native_workdir(path: impl AsRef<Path>) -> PathBuf {
32    normalize_for_native_workdir_with_flag(path.as_ref().to_path_buf(), cfg!(windows))
33}
34
35pub struct SymlinkWritePaths {
36    pub read_path: Option<PathBuf>,
37    pub write_path: PathBuf,
38}
39
40/// Resolve the final filesystem target for `path` while retaining a safe write path.
41///
42/// This follows symlink chains (including relative symlink targets) until it reaches a
43/// non-symlink path. If the chain cycles or any metadata/link resolution fails, it
44/// returns `read_path: None` and uses the original absolute path as `write_path`.
45/// There is no fixed max-resolution count; cycles are detected via a visited set.
46pub fn resolve_symlink_write_paths(path: &Path) -> io::Result<SymlinkWritePaths> {
47    let root = AbsolutePathBuf::from_absolute_path(path)
48        .map(AbsolutePathBuf::into_path_buf)
49        .unwrap_or_else(|_| path.to_path_buf());
50    let mut current = root.clone();
51    let mut visited = HashSet::new();
52
53    // Follow symlink chains while guarding against cycles.
54    loop {
55        let meta = match std::fs::symlink_metadata(&current) {
56            Ok(meta) => meta,
57            Err(err) if err.kind() == io::ErrorKind::NotFound => {
58                return Ok(SymlinkWritePaths {
59                    read_path: Some(current.clone()),
60                    write_path: current,
61                });
62            }
63            Err(_) => {
64                return Ok(SymlinkWritePaths {
65                    read_path: None,
66                    write_path: root,
67                });
68            }
69        };
70
71        if !meta.file_type().is_symlink() {
72            return Ok(SymlinkWritePaths {
73                read_path: Some(current.clone()),
74                write_path: current,
75            });
76        }
77
78        // If we've already seen this path, the chain cycles.
79        if !visited.insert(current.clone()) {
80            return Ok(SymlinkWritePaths {
81                read_path: None,
82                write_path: root,
83            });
84        }
85
86        let target = match std::fs::read_link(&current) {
87            Ok(target) => target,
88            Err(_) => {
89                return Ok(SymlinkWritePaths {
90                    read_path: None,
91                    write_path: root,
92                });
93            }
94        };
95
96        let next = if target.is_absolute() {
97            AbsolutePathBuf::from_absolute_path(&target)
98        } else if let Some(parent) = current.parent() {
99            Ok(AbsolutePathBuf::resolve_path_against_base(&target, parent))
100        } else {
101            return Ok(SymlinkWritePaths {
102                read_path: None,
103                write_path: root,
104            });
105        };
106
107        let next = match next {
108            Ok(path) => path.into_path_buf(),
109            Err(_) => {
110                return Ok(SymlinkWritePaths {
111                    read_path: None,
112                    write_path: root,
113                });
114            }
115        };
116
117        current = next;
118    }
119}
120
121pub fn write_atomically(write_path: &Path, contents: &str) -> io::Result<()> {
122    let parent = write_path.parent().ok_or_else(|| {
123        io::Error::new(
124            io::ErrorKind::InvalidInput,
125            format!("path {} has no parent directory", write_path.display()),
126        )
127    })?;
128    std::fs::create_dir_all(parent)?;
129    let tmp = NamedTempFile::new_in(parent)?;
130    std::fs::write(tmp.path(), contents)?;
131    tmp.persist(write_path)?;
132    Ok(())
133}
134
135fn normalize_for_wsl(path: PathBuf) -> PathBuf {
136    normalize_for_wsl_with_flag(path, env::is_wsl())
137}
138
139fn normalize_for_native_workdir_with_flag(path: PathBuf, is_windows: bool) -> PathBuf {
140    if is_windows {
141        dunce::simplified(&path).to_path_buf()
142    } else {
143        path
144    }
145}
146
147fn normalize_for_wsl_with_flag(path: PathBuf, is_wsl: bool) -> PathBuf {
148    if !is_wsl {
149        return path;
150    }
151
152    if !is_wsl_case_insensitive_path(&path) {
153        return path;
154    }
155
156    lower_ascii_path(path)
157}
158
159fn is_wsl_case_insensitive_path(path: &Path) -> bool {
160    #[cfg(target_os = "linux")]
161    {
162        use std::os::unix::ffi::OsStrExt;
163        use std::path::Component;
164
165        let mut components = path.components();
166        let Some(Component::RootDir) = components.next() else {
167            return false;
168        };
169        let Some(Component::Normal(mnt)) = components.next() else {
170            return false;
171        };
172        if !ascii_eq_ignore_case(mnt.as_bytes(), b"mnt") {
173            return false;
174        }
175        let Some(Component::Normal(drive)) = components.next() else {
176            return false;
177        };
178        let drive_bytes = drive.as_bytes();
179        drive_bytes.len() == 1 && drive_bytes[0].is_ascii_alphabetic()
180    }
181    #[cfg(not(target_os = "linux"))]
182    {
183        let _ = path;
184        false
185    }
186}
187
188#[cfg(target_os = "linux")]
189fn ascii_eq_ignore_case(left: &[u8], right: &[u8]) -> bool {
190    left.len() == right.len()
191        && left
192            .iter()
193            .zip(right)
194            .all(|(lhs, rhs)| lhs.to_ascii_lowercase() == *rhs)
195}
196
197#[cfg(target_os = "linux")]
198fn lower_ascii_path(path: PathBuf) -> PathBuf {
199    use std::ffi::OsString;
200    use std::os::unix::ffi::OsStrExt;
201    use std::os::unix::ffi::OsStringExt;
202
203    // WSL mounts Windows drives under /mnt/<drive>, which are case-insensitive.
204    let bytes = path.as_os_str().as_bytes();
205    let mut lowered = Vec::with_capacity(bytes.len());
206    for byte in bytes {
207        lowered.push(byte.to_ascii_lowercase());
208    }
209    PathBuf::from(OsString::from_vec(lowered))
210}
211
212#[cfg(not(target_os = "linux"))]
213fn lower_ascii_path(path: PathBuf) -> PathBuf {
214    path
215}
216
217#[cfg(test)]
218#[path = "path_utils_tests.rs"]
219mod tests;