use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use crate::error::CliCoreError;
fn env_path(key: &str) -> Option<PathBuf> {
std::env::var(key)
.ok()
.filter(|v| !v.is_empty())
.map(PathBuf::from)
}
fn home_config_dir() -> Option<PathBuf> {
env_path("HOME").map(|home| home.join(".config"))
}
fn home_application_support_dir() -> Option<PathBuf> {
env_path("HOME").map(|home| home.join("Library").join("Application Support"))
}
#[must_use]
pub fn config_base_dir() -> Option<PathBuf> {
env_path("XDG_CONFIG_HOME")
.or_else(|| {
if cfg!(windows) {
env_path("APPDATA").or_else(home_config_dir)
} else if cfg!(target_os = "macos") {
home_application_support_dir().or_else(home_config_dir)
} else {
home_config_dir().or_else(|| env_path("APPDATA"))
}
})
.filter(|p| p.is_absolute())
}
const MACOS_MIGRATION_FLAG: &str = ".cli_engine_macos_migrated";
pub(crate) fn migrate_macos_config_dir(app_id: &str) {
if !cfg!(target_os = "macos") || env_path("XDG_CONFIG_HOME").is_some() {
return;
}
let (Some(new_base), Some(old_base)) = (home_application_support_dir(), home_config_dir())
else {
return;
};
let new_app_dir = new_base.join(app_id);
let flag_path = new_app_dir.join(MACOS_MIGRATION_FLAG);
if flag_path.is_file() {
return;
}
let old_app_dir = old_base.join(app_id);
let outcome = move_directory_contents(&old_app_dir, &new_app_dir);
if write_string_atomic(&flag_path, "").is_err() {
return;
}
if outcome.moved > 0 {
warn_macos_config_migrated(&old_app_dir, &new_app_dir, outcome.moved);
}
if outcome.skipped > 0 {
warn_macos_config_migration_conflicts(&old_app_dir, &new_app_dir, outcome.skipped);
}
}
struct MoveOutcome {
moved: usize,
skipped: usize,
}
fn move_directory_contents(old_dir: &Path, new_dir: &Path) -> MoveOutcome {
let Ok(entries) = std::fs::read_dir(old_dir) else {
return MoveOutcome {
moved: 0,
skipped: 0,
};
};
if ensure_private_dir(new_dir).is_err() {
return MoveOutcome {
moved: 0,
skipped: 0,
};
}
let mut moved = 0;
let mut skipped = 0;
for entry in entries.flatten() {
let old_path = entry.path();
let new_path = new_dir.join(entry.file_name());
if new_path.exists() {
skipped += 1;
continue;
}
if std::fs::rename(&old_path, &new_path).is_ok() {
moved += 1;
continue;
}
if old_path.is_file()
&& std::fs::copy(&old_path, &new_path).is_ok()
&& std::fs::remove_file(&old_path).is_ok()
{
moved += 1;
} else {
skipped += 1;
}
}
if skipped == 0 {
std::fs::remove_dir(old_dir).ok();
}
MoveOutcome { moved, skipped }
}
fn warn_macos_config_migrated(old_dir: &Path, new_dir: &Path, moved: usize) {
use std::io::Write as _;
std::io::stderr()
.lock()
.write_all(
format!(
"cli-engine: moved {moved} file(s) from {} to {} (macOS config location changed)\n",
old_dir.display(),
new_dir.display()
)
.as_bytes(),
)
.ok();
}
fn warn_macos_config_migration_conflicts(old_dir: &Path, new_dir: &Path, skipped: usize) {
use std::io::Write as _;
std::io::stderr()
.lock()
.write_all(
format!(
"cli-engine: left {skipped} file(s) in {} because {} already has file(s) with the same name. Please reconcile manually\n",
old_dir.display(),
new_dir.display()
)
.as_bytes(),
)
.ok();
}
#[must_use]
pub fn home_dir() -> Option<PathBuf> {
if cfg!(windows) {
env_path("USERPROFILE").or_else(|| env_path("HOME"))
} else {
env_path("HOME")
}
.filter(|p| p.is_absolute())
}
#[must_use]
pub fn is_safe_path_component(s: &str) -> bool {
const FORBIDDEN: &[char] = &['/', '\\', ':', '*', '?', '"', '<', '>', '|'];
if s.contains(FORBIDDEN) || s.bytes().any(|b| b < 0x20 || b == 0x7F) {
return false;
}
if s.starts_with(' ') || s.ends_with('.') || s.ends_with(' ') {
return false;
}
const RESERVED: &[&str] = &[
"CON", "PRN", "AUX", "NUL", "COM0", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7",
"COM8", "COM9", "LPT0", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8",
"LPT9",
];
let stem = Path::new(s)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or(s);
if RESERVED.iter().any(|r| stem.eq_ignore_ascii_case(r)) {
return false;
}
let mut components = Path::new(s).components();
matches!(components.next(), Some(std::path::Component::Normal(_)))
&& components.next().is_none()
}
pub fn write_string_atomic(path: &Path, contents: &str) -> crate::Result<()> {
if let Some(parent) = path.parent() {
ensure_private_dir(parent)
.map_err(|e| CliCoreError::message(format!("failed to create directory: {e}")))?;
}
static TMP_COUNTER: AtomicU64 = AtomicU64::new(0);
let unique = TMP_COUNTER.fetch_add(1, Ordering::Relaxed);
let pid = std::process::id();
let tmp_path = path.with_file_name(format!(
"{}.{pid:x}.{unique:x}.tmp",
path.file_name().and_then(|s| s.to_str()).unwrap_or("tmp"),
));
write_tmp_file(&tmp_path, contents)?;
if let Err(e) = std::fs::rename(&tmp_path, path) {
std::fs::remove_file(&tmp_path).ok();
return Err(CliCoreError::message(format!(
"failed to finalize {}: {e}",
path.display()
)));
}
Ok(())
}
fn ensure_private_dir(dir: &Path) -> std::io::Result<()> {
let existed = dir.is_dir();
std::fs::create_dir_all(dir)?;
#[cfg(unix)]
if !existed {
use std::os::unix::fs::PermissionsExt as _;
if let Err(e) = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)) {
tracing::debug!(
path = %dir.display(),
error = %e,
"could not restrict directory permissions"
);
}
}
Ok(())
}
fn write_tmp_file(tmp_path: &Path, contents: &str) -> crate::Result<()> {
use std::io::Write as _;
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt as _;
opts.mode(0o600);
}
let mut file = opts.open(tmp_path).map_err(|e| {
CliCoreError::message(format!("failed to write {}: {e}", tmp_path.display()))
})?;
file.write_all(contents.as_bytes())
.map_err(|e| CliCoreError::message(format!("failed to write {}: {e}", tmp_path.display())))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::test_env::{EnvVarGuard, lock, with_xdg_config_home};
fn with_home<F: FnOnce() -> R, R>(value: &Path, f: F) -> R {
let _lock = lock();
let _restore = EnvVarGuard::set("HOME", Some(value));
f()
}
#[test]
fn safe_path_component_basic() {
assert!(is_safe_path_component("godaddy"));
assert!(!is_safe_path_component(".."));
assert!(!is_safe_path_component(""));
assert!(!is_safe_path_component("a/b"));
assert!(!is_safe_path_component("NUL"));
}
#[test]
fn safe_path_component_rejects_windows_reserved_names() {
for name in &[
"CON", "con", "NUL", "nul", "COM1", "LPT9", "CON.txt", "NUL.json",
] {
assert!(
!is_safe_path_component(name),
"{name:?} should be rejected as a Windows reserved name"
);
}
}
#[test]
fn safe_path_component_rejects_control_and_space_edges() {
assert!(!is_safe_path_component(" prod"), "leading space");
assert!(!is_safe_path_component("prod\x7f"), "DEL byte");
assert!(!is_safe_path_component("prod."), "trailing dot");
assert!(!is_safe_path_component("prod "), "trailing space");
}
#[test]
fn safe_path_component_accepts_normal_values() {
for name in &["dev", "prod", "staging", "my-app", "my_app", "app.v2"] {
assert!(is_safe_path_component(name), "{name:?} should be accepted");
}
}
#[test]
fn config_base_dir_rejects_relative_xdg() {
with_xdg_config_home(Path::new("."), || {
assert!(
config_base_dir().is_none(),
"relative XDG_CONFIG_HOME should be rejected"
);
});
}
#[test]
fn config_base_dir_honors_xdg() {
let dir = std::env::temp_dir().join("cli-engine-fs-base-test");
with_xdg_config_home(&dir, || {
assert_eq!(config_base_dir(), Some(dir.clone()));
});
}
#[test]
#[cfg(target_os = "macos")]
fn config_base_dir_defaults_to_application_support_on_macos() {
let home = std::env::temp_dir().join("cli-engine-fs-macos-test");
let _lock = lock();
let _xdg = EnvVarGuard::set("XDG_CONFIG_HOME", None);
let _home = EnvVarGuard::set("HOME", Some(&home));
assert_eq!(
config_base_dir(),
Some(home.join("Library").join("Application Support"))
);
}
#[test]
fn home_dir_honors_home_env() {
let dir = std::env::temp_dir().join("cli-engine-fs-home-test");
with_home(&dir, || {
assert_eq!(home_dir(), Some(dir.clone()));
});
}
#[test]
fn home_dir_rejects_relative() {
with_home(Path::new("."), || {
assert!(home_dir().is_none(), "relative HOME should be rejected");
});
}
#[tokio::test]
async fn write_string_atomic_round_trip_creates_dirs() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("nested").join("file.txt");
write_string_atomic(&path, "hello").expect("write");
assert_eq!(std::fs::read_to_string(&path).expect("read"), "hello");
write_string_atomic(&path, "world").expect("rewrite");
assert_eq!(std::fs::read_to_string(&path).expect("read"), "world");
let strays: Vec<_> = std::fs::read_dir(path.parent().expect("parent"))
.expect("read_dir")
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().ends_with(".tmp"))
.collect();
assert!(strays.is_empty(), "temp files should be renamed away");
}
#[cfg(unix)]
#[tokio::test]
async fn write_string_atomic_sets_owner_only_mode() {
use std::os::unix::fs::PermissionsExt as _;
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("secret.txt");
write_string_atomic(&path, "s3cr3t").expect("write");
let mode = std::fs::metadata(&path).expect("meta").permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "file should be owner read/write only");
}
#[test]
fn move_directory_contents_returns_zero_when_old_dir_is_absent() {
let tmp = tempfile::tempdir().expect("tempdir");
let outcome = move_directory_contents(&tmp.path().join("missing"), &tmp.path().join("new"));
assert_eq!((outcome.moved, outcome.skipped), (0, 0));
assert!(
!tmp.path().join("new").exists(),
"destination should not be created for a no-op move"
);
}
#[test]
fn move_directory_contents_moves_files_and_subdirectories() {
let tmp = tempfile::tempdir().expect("tempdir");
let old_dir = tmp.path().join("old");
let new_dir = tmp.path().join("new");
std::fs::create_dir_all(old_dir.join("credentials")).expect("mkdir");
std::fs::write(old_dir.join("config.toml"), "a = 1").expect("write");
std::fs::write(old_dir.join("contacts.toml"), "b = 2").expect("write");
std::fs::write(old_dir.join("credentials").join("token.json"), "{}").expect("write");
let outcome = move_directory_contents(&old_dir, &new_dir);
assert_eq!(outcome.moved, 3, "config.toml, contacts.toml, credentials/");
assert_eq!(outcome.skipped, 0);
assert_eq!(
std::fs::read_to_string(new_dir.join("config.toml")).expect("read"),
"a = 1"
);
assert_eq!(
std::fs::read_to_string(new_dir.join("contacts.toml")).expect("read"),
"b = 2"
);
assert_eq!(
std::fs::read_to_string(new_dir.join("credentials").join("token.json")).expect("read"),
"{}"
);
assert!(!old_dir.exists(), "emptied old directory should be removed");
}
#[test]
fn move_directory_contents_leaves_conflicting_entries_in_place() {
let tmp = tempfile::tempdir().expect("tempdir");
let old_dir = tmp.path().join("old");
let new_dir = tmp.path().join("new");
std::fs::create_dir_all(&old_dir).expect("mkdir");
std::fs::create_dir_all(&new_dir).expect("mkdir");
std::fs::write(old_dir.join("config.toml"), "old").expect("write");
std::fs::write(new_dir.join("config.toml"), "new").expect("write");
std::fs::write(old_dir.join("contacts.toml"), "moves fine").expect("write");
let outcome = move_directory_contents(&old_dir, &new_dir);
assert_eq!(outcome.moved, 1, "contacts.toml has no conflict");
assert_eq!(
outcome.skipped, 1,
"config.toml conflicts and is left alone"
);
assert_eq!(
std::fs::read_to_string(new_dir.join("config.toml")).expect("read"),
"new",
"destination copy must never be overwritten"
);
assert_eq!(
std::fs::read_to_string(old_dir.join("config.toml")).expect("read"),
"old",
"conflicting source file is left in place"
);
assert!(
old_dir.exists(),
"old directory is not removed while a conflict remains"
);
assert!(!old_dir.join("contacts.toml").exists());
}
#[test]
#[cfg(target_os = "macos")]
fn migrate_macos_config_dir_moves_files_once() {
let home = std::env::temp_dir().join("cli-engine-fs-migrate-test");
let old_app_dir = home.join(".config").join("my-app");
let new_app_dir = home
.join("Library")
.join("Application Support")
.join("my-app");
std::fs::remove_dir_all(&home).ok();
std::fs::create_dir_all(&old_app_dir).expect("mkdir");
std::fs::write(old_app_dir.join("environments.toml"), "env = true").expect("write");
let _lock = lock();
let _xdg = EnvVarGuard::set("XDG_CONFIG_HOME", None);
let _home = EnvVarGuard::set("HOME", Some(&home));
migrate_macos_config_dir("my-app");
assert_eq!(
std::fs::read_to_string(new_app_dir.join("environments.toml")).expect("read"),
"env = true"
);
assert!(new_app_dir.join(MACOS_MIGRATION_FLAG).is_file());
assert!(!old_app_dir.exists());
std::fs::create_dir_all(&old_app_dir).expect("mkdir");
std::fs::write(old_app_dir.join("late.toml"), "ignored").expect("write");
migrate_macos_config_dir("my-app");
assert!(
!new_app_dir.join("late.toml").exists(),
"migration must not repeat once the marker exists"
);
}
#[test]
#[cfg(target_os = "macos")]
fn migrate_macos_config_dir_is_a_noop_when_xdg_config_home_is_set() {
let home = std::env::temp_dir().join("cli-engine-fs-migrate-xdg-test");
let xdg = std::env::temp_dir().join("cli-engine-fs-migrate-xdg-override");
let old_app_dir = home.join(".config").join("my-app");
std::fs::remove_dir_all(&home).ok();
std::fs::create_dir_all(&old_app_dir).expect("mkdir");
std::fs::write(old_app_dir.join("config.toml"), "x = 1").expect("write");
with_xdg_config_home(&xdg, || {
let _home = EnvVarGuard::set("HOME", Some(&home));
migrate_macos_config_dir("my-app");
});
assert!(
old_app_dir.join("config.toml").is_file(),
"an explicit XDG_CONFIG_HOME must leave the old default location untouched"
);
}
}