Files
codex/codex-rs/external-agent-migration/src/utils.rs
viyatb-oai b1ccaa0e08 Avoid overwriting symlinked migration targets (#36051)
## Why

External-agent migration treated symlinked empty text targets as overwritable files. Writing migrated configuration through such a target could modify a file outside the repository.

## What changed

- Use symlink metadata when checking whether a migration target is missing or empty, so only regular files are considered overwritable.
- Preserve symlinked `AGENTS.md` and `.codex/hooks.json` targets during both detection and import.

## Testing

Added Unix regression coverage for existing and dangling symlink targets for both guidance and hooks migration.

See https://github.com/openai/codex/pull/26021.

GitOrigin-RevId: 13c78e7458d1c02abdb22b38ba88ed66bb5a154b
2026-07-30 00:50:02 +00:00

96 lines
2.5 KiB
Rust

use crate::RewriteProfile;
use serde_json::Value as JsonValue;
use std::fs;
use std::io;
use std::path::Path;
use std::path::PathBuf;
pub(super) fn display_source_paths(paths: &[PathBuf]) -> String {
paths
.iter()
.map(|path| path.display().to_string())
.collect::<Vec<_>>()
.join(", ")
}
pub(crate) fn read_json_file(path: &Path) -> io::Result<Option<JsonValue>> {
if !path.is_file() {
return Ok(None);
}
let raw = fs::read_to_string(path)?;
serde_json::from_str(&raw)
.map(Some)
.map_err(|err| invalid_data_error(err.to_string()))
}
pub(super) fn is_missing_or_empty_text_file(path: &Path) -> io::Result<bool> {
let metadata = match fs::symlink_metadata(path) {
Ok(metadata) => metadata,
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(true),
Err(err) => return Err(err),
};
if !metadata.is_file() {
return Ok(false);
}
Ok(fs::read_to_string(path)?.trim().is_empty())
}
pub(super) fn invalid_data_error(message: impl Into<String>) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, message.into())
}
pub(super) fn copy_dir_recursive(
source: &Path,
target: &Path,
rewrite_profile: RewriteProfile,
) -> io::Result<()> {
fs::create_dir_all(target)?;
for entry in fs::read_dir(source)? {
let entry = entry?;
let source_path = entry.path();
let target_path = target.join(entry.file_name());
let file_type = entry.file_type()?;
if file_type.is_dir() {
copy_dir_recursive(&source_path, &target_path, rewrite_profile)?;
continue;
}
if file_type.is_file() {
if is_skill_md(&source_path) {
rewrite_and_copy_text_file(&source_path, &target_path, rewrite_profile)?;
} else {
fs::copy(source_path, target_path)?;
}
}
}
Ok(())
}
pub(super) fn rewrite_external_agent_terms(
content: &str,
rewrite_profile: RewriteProfile,
) -> String {
rewrite_profile.rewrite(content)
}
fn is_skill_md(path: &Path) -> bool {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.eq_ignore_ascii_case("SKILL.md"))
}
fn rewrite_and_copy_text_file(
source: &Path,
target: &Path,
rewrite_profile: RewriteProfile,
) -> io::Result<()> {
let source_contents = fs::read_to_string(source)?;
let rewritten = rewrite_external_agent_terms(&source_contents, rewrite_profile);
fs::write(target, rewritten)
}