Files
codex/codex-rs/core/src/path_utils.rs
Eric Traut 1271d450b1 Fixed symlink support for config.toml (#9445)
We already support reading from `config.toml` through a symlink, but the
code was not properly handling updates to a symlinked config file. This
PR generalizes safe symlink-chain resolution and atomic writes into
path_utils, updating all config write paths to use the shared logic
(including set_default_oss_provider, which previously didn't use the
common path), and adds tests for symlink chains and cycles.

This resolves #6646.

Notes:
* Symlink cycles or resolution failures replace the top-level symlink
with a real file.
* Shared config write path now handles symlinks consistently across
edits, defaults, and empty-user-layer creation.

This PR was inspired by https://github.com/openai/codex/pull/9437, which
was contributed by @ryoppippi
2026-01-18 19:22:28 -08:00

244 lines
7.1 KiB
Rust

use codex_utils_absolute_path::AbsolutePathBuf;
use std::collections::HashSet;
use std::io;
use std::path::Path;
use std::path::PathBuf;
use tempfile::NamedTempFile;
use crate::env;
pub fn normalize_for_path_comparison(path: impl AsRef<Path>) -> std::io::Result<PathBuf> {
let canonical = path.as_ref().canonicalize()?;
Ok(normalize_for_wsl(canonical))
}
pub struct SymlinkWritePaths {
pub read_path: Option<PathBuf>,
pub write_path: PathBuf,
}
/// Resolve the final filesystem target for `path` while retaining a safe write path.
///
/// This follows symlink chains (including relative symlink targets) until it reaches a
/// non-symlink path. If the chain cycles or any metadata/link resolution fails, it
/// returns `read_path: None` and uses the original absolute path as `write_path`.
/// There is no fixed max-resolution count; cycles are detected via a visited set.
pub fn resolve_symlink_write_paths(path: &Path) -> io::Result<SymlinkWritePaths> {
let root = AbsolutePathBuf::from_absolute_path(path)
.map(AbsolutePathBuf::into_path_buf)
.unwrap_or_else(|_| path.to_path_buf());
let mut current = root.clone();
let mut visited = HashSet::new();
// Follow symlink chains while guarding against cycles.
loop {
let meta = match std::fs::symlink_metadata(&current) {
Ok(meta) => meta,
Err(err) if err.kind() == io::ErrorKind::NotFound => {
return Ok(SymlinkWritePaths {
read_path: Some(current.clone()),
write_path: current,
});
}
Err(_) => {
return Ok(SymlinkWritePaths {
read_path: None,
write_path: root,
});
}
};
if !meta.file_type().is_symlink() {
return Ok(SymlinkWritePaths {
read_path: Some(current.clone()),
write_path: current,
});
}
// If we've already seen this path, the chain cycles.
if !visited.insert(current.clone()) {
return Ok(SymlinkWritePaths {
read_path: None,
write_path: root,
});
}
let target = match std::fs::read_link(&current) {
Ok(target) => target,
Err(_) => {
return Ok(SymlinkWritePaths {
read_path: None,
write_path: root,
});
}
};
let next = if target.is_absolute() {
AbsolutePathBuf::from_absolute_path(&target)
} else if let Some(parent) = current.parent() {
AbsolutePathBuf::resolve_path_against_base(&target, parent)
} else {
return Ok(SymlinkWritePaths {
read_path: None,
write_path: root,
});
};
let next = match next {
Ok(path) => path.into_path_buf(),
Err(_) => {
return Ok(SymlinkWritePaths {
read_path: None,
write_path: root,
});
}
};
current = next;
}
}
pub fn write_atomically(write_path: &Path, contents: &str) -> io::Result<()> {
let parent = write_path.parent().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("path {} has no parent directory", write_path.display()),
)
})?;
std::fs::create_dir_all(parent)?;
let tmp = NamedTempFile::new_in(parent)?;
std::fs::write(tmp.path(), contents)?;
tmp.persist(write_path)?;
Ok(())
}
fn normalize_for_wsl(path: PathBuf) -> PathBuf {
normalize_for_wsl_with_flag(path, env::is_wsl())
}
fn normalize_for_wsl_with_flag(path: PathBuf, is_wsl: bool) -> PathBuf {
if !is_wsl {
return path;
}
if !is_wsl_case_insensitive_path(&path) {
return path;
}
lower_ascii_path(path)
}
fn is_wsl_case_insensitive_path(path: &Path) -> bool {
#[cfg(target_os = "linux")]
{
use std::os::unix::ffi::OsStrExt;
use std::path::Component;
let mut components = path.components();
let Some(Component::RootDir) = components.next() else {
return false;
};
let Some(Component::Normal(mnt)) = components.next() else {
return false;
};
if !ascii_eq_ignore_case(mnt.as_bytes(), b"mnt") {
return false;
}
let Some(Component::Normal(drive)) = components.next() else {
return false;
};
let drive_bytes = drive.as_bytes();
drive_bytes.len() == 1 && drive_bytes[0].is_ascii_alphabetic()
}
#[cfg(not(target_os = "linux"))]
{
let _ = path;
false
}
}
#[cfg(target_os = "linux")]
fn ascii_eq_ignore_case(left: &[u8], right: &[u8]) -> bool {
left.len() == right.len()
&& left
.iter()
.zip(right)
.all(|(lhs, rhs)| lhs.to_ascii_lowercase() == *rhs)
}
#[cfg(target_os = "linux")]
fn lower_ascii_path(path: PathBuf) -> PathBuf {
use std::ffi::OsString;
use std::os::unix::ffi::OsStrExt;
use std::os::unix::ffi::OsStringExt;
// WSL mounts Windows drives under /mnt/<drive>, which are case-insensitive.
let bytes = path.as_os_str().as_bytes();
let mut lowered = Vec::with_capacity(bytes.len());
for byte in bytes {
lowered.push(byte.to_ascii_lowercase());
}
PathBuf::from(OsString::from_vec(lowered))
}
#[cfg(not(target_os = "linux"))]
fn lower_ascii_path(path: PathBuf) -> PathBuf {
path
}
#[cfg(test)]
mod tests {
#[cfg(unix)]
mod symlinks {
use super::super::resolve_symlink_write_paths;
use pretty_assertions::assert_eq;
use std::os::unix::fs::symlink;
#[test]
fn symlink_cycles_fall_back_to_root_write_path() -> std::io::Result<()> {
let dir = tempfile::tempdir()?;
let a = dir.path().join("a");
let b = dir.path().join("b");
symlink(&b, &a)?;
symlink(&a, &b)?;
let resolved = resolve_symlink_write_paths(&a)?;
assert_eq!(resolved.read_path, None);
assert_eq!(resolved.write_path, a);
Ok(())
}
}
#[cfg(target_os = "linux")]
mod wsl {
use super::super::normalize_for_wsl_with_flag;
use pretty_assertions::assert_eq;
use std::path::PathBuf;
#[test]
fn wsl_mnt_drive_paths_lowercase() {
let normalized = normalize_for_wsl_with_flag(PathBuf::from("/mnt/C/Users/Dev"), true);
assert_eq!(normalized, PathBuf::from("/mnt/c/users/dev"));
}
#[test]
fn wsl_non_drive_paths_unchanged() {
let path = PathBuf::from("/mnt/cc/Users/Dev");
let normalized = normalize_for_wsl_with_flag(path.clone(), true);
assert_eq!(normalized, path);
}
#[test]
fn wsl_non_mnt_paths_unchanged() {
let path = PathBuf::from("/home/Dev");
let normalized = normalize_for_wsl_with_flag(path.clone(), true);
assert_eq!(normalized, path);
}
}
}