1use std::path::{Path, PathBuf};
11use std::sync::atomic::{AtomicU64, Ordering};
12
13use crate::error::CliCoreError;
14
15fn env_path(key: &str) -> Option<PathBuf> {
17 std::env::var(key)
18 .ok()
19 .filter(|v| !v.is_empty())
20 .map(PathBuf::from)
21}
22
23fn home_config_dir() -> Option<PathBuf> {
25 env_path("HOME").map(|home| home.join(".config"))
26}
27
28fn home_application_support_dir() -> Option<PathBuf> {
30 env_path("HOME").map(|home| home.join("Library").join("Application Support"))
31}
32
33#[must_use]
41pub fn config_base_dir() -> Option<PathBuf> {
42 env_path("XDG_CONFIG_HOME")
43 .or_else(|| {
44 if cfg!(windows) {
50 env_path("APPDATA").or_else(home_config_dir)
51 } else if cfg!(target_os = "macos") {
52 home_application_support_dir().or_else(home_config_dir)
53 } else {
54 home_config_dir().or_else(|| env_path("APPDATA"))
55 }
56 })
57 .filter(|p| p.is_absolute())
60}
61
62const MACOS_MIGRATION_FLAG: &str = ".cli_engine_macos_migrated";
65
66pub(crate) fn migrate_macos_config_dir(app_id: &str) {
78 if !cfg!(target_os = "macos") || env_path("XDG_CONFIG_HOME").is_some() {
79 return;
80 }
81 let (Some(new_base), Some(old_base)) = (home_application_support_dir(), home_config_dir())
82 else {
83 return;
84 };
85 let new_app_dir = new_base.join(app_id);
86 let flag_path = new_app_dir.join(MACOS_MIGRATION_FLAG);
87 if flag_path.is_file() {
88 return;
89 }
90
91 let old_app_dir = old_base.join(app_id);
92 let outcome = move_directory_contents(&old_app_dir, &new_app_dir);
93 if write_string_atomic(&flag_path, "").is_err() {
94 return;
97 }
98 if outcome.moved > 0 {
99 warn_macos_config_migrated(&old_app_dir, &new_app_dir, outcome.moved);
100 }
101 if outcome.skipped > 0 {
102 warn_macos_config_migration_conflicts(&old_app_dir, &new_app_dir, outcome.skipped);
103 }
104}
105
106struct MoveOutcome {
109 moved: usize,
110 skipped: usize,
111}
112
113fn move_directory_contents(old_dir: &Path, new_dir: &Path) -> MoveOutcome {
121 let Ok(entries) = std::fs::read_dir(old_dir) else {
122 return MoveOutcome {
123 moved: 0,
124 skipped: 0,
125 };
126 };
127 if ensure_private_dir(new_dir).is_err() {
128 return MoveOutcome {
129 moved: 0,
130 skipped: 0,
131 };
132 }
133
134 let mut moved = 0;
135 let mut skipped = 0;
136 for entry in entries.flatten() {
137 let old_path = entry.path();
138 let new_path = new_dir.join(entry.file_name());
139 if new_path.exists() {
140 skipped += 1;
141 continue;
142 }
143 if std::fs::rename(&old_path, &new_path).is_ok() {
144 moved += 1;
145 continue;
146 }
147 if old_path.is_file()
152 && std::fs::copy(&old_path, &new_path).is_ok()
153 && std::fs::remove_file(&old_path).is_ok()
154 {
155 moved += 1;
156 } else {
157 skipped += 1;
158 }
159 }
160 if skipped == 0 {
161 std::fs::remove_dir(old_dir).ok();
162 }
163 MoveOutcome { moved, skipped }
164}
165
166fn warn_macos_config_migrated(old_dir: &Path, new_dir: &Path, moved: usize) {
168 use std::io::Write as _;
169 std::io::stderr()
170 .lock()
171 .write_all(
172 format!(
173 "cli-engine: moved {moved} file(s) from {} to {} (macOS config location changed)\n",
174 old_dir.display(),
175 new_dir.display()
176 )
177 .as_bytes(),
178 )
179 .ok();
180}
181
182fn warn_macos_config_migration_conflicts(old_dir: &Path, new_dir: &Path, skipped: usize) {
185 use std::io::Write as _;
186 std::io::stderr()
187 .lock()
188 .write_all(
189 format!(
190 "cli-engine: left {skipped} file(s) in {} because {} already has file(s) with the same name. Please reconcile manually\n",
191 old_dir.display(),
192 new_dir.display()
193 )
194 .as_bytes(),
195 )
196 .ok();
197}
198
199#[must_use]
209pub fn home_dir() -> Option<PathBuf> {
210 if cfg!(windows) {
211 env_path("USERPROFILE").or_else(|| env_path("HOME"))
212 } else {
213 env_path("HOME")
214 }
215 .filter(|p| p.is_absolute())
216}
217
218#[must_use]
233pub fn is_safe_path_component(s: &str) -> bool {
234 const FORBIDDEN: &[char] = &['/', '\\', ':', '*', '?', '"', '<', '>', '|'];
238 if s.contains(FORBIDDEN) || s.bytes().any(|b| b < 0x20 || b == 0x7F) {
239 return false;
240 }
241 if s.starts_with(' ') || s.ends_with('.') || s.ends_with(' ') {
242 return false;
243 }
244 const RESERVED: &[&str] = &[
247 "CON", "PRN", "AUX", "NUL", "COM0", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7",
248 "COM8", "COM9", "LPT0", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8",
249 "LPT9",
250 ];
251 let stem = Path::new(s)
252 .file_stem()
253 .and_then(|s| s.to_str())
254 .unwrap_or(s);
255 if RESERVED.iter().any(|r| stem.eq_ignore_ascii_case(r)) {
256 return false;
257 }
258 let mut components = Path::new(s).components();
259 matches!(components.next(), Some(std::path::Component::Normal(_)))
260 && components.next().is_none()
261}
262
263pub fn write_string_atomic(path: &Path, contents: &str) -> crate::Result<()> {
279 if let Some(parent) = path.parent() {
280 ensure_private_dir(parent)
281 .map_err(|e| CliCoreError::message(format!("failed to create directory: {e}")))?;
282 }
283 static TMP_COUNTER: AtomicU64 = AtomicU64::new(0);
286 let unique = TMP_COUNTER.fetch_add(1, Ordering::Relaxed);
287 let pid = std::process::id();
288 let tmp_path = path.with_file_name(format!(
289 "{}.{pid:x}.{unique:x}.tmp",
290 path.file_name().and_then(|s| s.to_str()).unwrap_or("tmp"),
291 ));
292 write_tmp_file(&tmp_path, contents)?;
293 if let Err(e) = std::fs::rename(&tmp_path, path) {
294 std::fs::remove_file(&tmp_path).ok();
295 return Err(CliCoreError::message(format!(
296 "failed to finalize {}: {e}",
297 path.display()
298 )));
299 }
300 Ok(())
301}
302
303fn ensure_private_dir(dir: &Path) -> std::io::Result<()> {
308 let existed = dir.is_dir();
309 std::fs::create_dir_all(dir)?;
310 #[cfg(unix)]
311 if !existed {
312 use std::os::unix::fs::PermissionsExt as _;
313 if let Err(e) = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)) {
314 tracing::debug!(
315 path = %dir.display(),
316 error = %e,
317 "could not restrict directory permissions"
318 );
319 }
320 }
321 Ok(())
322}
323
324fn write_tmp_file(tmp_path: &Path, contents: &str) -> crate::Result<()> {
327 use std::io::Write as _;
328 let mut opts = std::fs::OpenOptions::new();
329 opts.write(true).create_new(true);
330 #[cfg(unix)]
331 {
332 use std::os::unix::fs::OpenOptionsExt as _;
333 opts.mode(0o600);
334 }
335 let mut file = opts.open(tmp_path).map_err(|e| {
336 CliCoreError::message(format!("failed to write {}: {e}", tmp_path.display()))
337 })?;
338 file.write_all(contents.as_bytes())
339 .map_err(|e| CliCoreError::message(format!("failed to write {}: {e}", tmp_path.display())))
340}
341
342#[cfg(test)]
343mod tests {
344 use super::*;
345 use crate::config::test_env::{EnvVarGuard, lock, with_xdg_config_home};
346
347 fn with_home<F: FnOnce() -> R, R>(value: &Path, f: F) -> R {
348 let _lock = lock();
349 let _restore = EnvVarGuard::set("HOME", Some(value));
350 f()
351 }
352
353 #[test]
354 fn safe_path_component_basic() {
355 assert!(is_safe_path_component("godaddy"));
356 assert!(!is_safe_path_component(".."));
357 assert!(!is_safe_path_component(""));
358 assert!(!is_safe_path_component("a/b"));
359 assert!(!is_safe_path_component("NUL"));
360 }
361
362 #[test]
363 fn safe_path_component_rejects_windows_reserved_names() {
364 for name in &[
365 "CON", "con", "NUL", "nul", "COM1", "LPT9", "CON.txt", "NUL.json",
366 ] {
367 assert!(
368 !is_safe_path_component(name),
369 "{name:?} should be rejected as a Windows reserved name"
370 );
371 }
372 }
373
374 #[test]
375 fn safe_path_component_rejects_control_and_space_edges() {
376 assert!(!is_safe_path_component(" prod"), "leading space");
377 assert!(!is_safe_path_component("prod\x7f"), "DEL byte");
378 assert!(!is_safe_path_component("prod."), "trailing dot");
379 assert!(!is_safe_path_component("prod "), "trailing space");
380 }
381
382 #[test]
383 fn safe_path_component_accepts_normal_values() {
384 for name in &["dev", "prod", "staging", "my-app", "my_app", "app.v2"] {
385 assert!(is_safe_path_component(name), "{name:?} should be accepted");
386 }
387 }
388
389 #[test]
390 fn config_base_dir_rejects_relative_xdg() {
391 with_xdg_config_home(Path::new("."), || {
392 assert!(
393 config_base_dir().is_none(),
394 "relative XDG_CONFIG_HOME should be rejected"
395 );
396 });
397 }
398
399 #[test]
400 fn config_base_dir_honors_xdg() {
401 let dir = std::env::temp_dir().join("cli-engine-fs-base-test");
402 with_xdg_config_home(&dir, || {
403 assert_eq!(config_base_dir(), Some(dir.clone()));
404 });
405 }
406
407 #[test]
408 #[cfg(target_os = "macos")]
409 fn config_base_dir_defaults_to_application_support_on_macos() {
410 let home = std::env::temp_dir().join("cli-engine-fs-macos-test");
411 let _lock = lock();
412 let _xdg = EnvVarGuard::set("XDG_CONFIG_HOME", None);
413 let _home = EnvVarGuard::set("HOME", Some(&home));
414 assert_eq!(
415 config_base_dir(),
416 Some(home.join("Library").join("Application Support"))
417 );
418 }
419
420 #[test]
421 fn home_dir_honors_home_env() {
422 let dir = std::env::temp_dir().join("cli-engine-fs-home-test");
423 with_home(&dir, || {
424 assert_eq!(home_dir(), Some(dir.clone()));
425 });
426 }
427
428 #[test]
429 fn home_dir_rejects_relative() {
430 with_home(Path::new("."), || {
431 assert!(home_dir().is_none(), "relative HOME should be rejected");
432 });
433 }
434
435 #[tokio::test]
436 async fn write_string_atomic_round_trip_creates_dirs() {
437 let tmp = tempfile::tempdir().expect("tempdir");
438 let path = tmp.path().join("nested").join("file.txt");
439 write_string_atomic(&path, "hello").expect("write");
440 assert_eq!(std::fs::read_to_string(&path).expect("read"), "hello");
441 write_string_atomic(&path, "world").expect("rewrite");
443 assert_eq!(std::fs::read_to_string(&path).expect("read"), "world");
444 let strays: Vec<_> = std::fs::read_dir(path.parent().expect("parent"))
446 .expect("read_dir")
447 .filter_map(|e| e.ok())
448 .filter(|e| e.file_name().to_string_lossy().ends_with(".tmp"))
449 .collect();
450 assert!(strays.is_empty(), "temp files should be renamed away");
451 }
452
453 #[cfg(unix)]
454 #[tokio::test]
455 async fn write_string_atomic_sets_owner_only_mode() {
456 use std::os::unix::fs::PermissionsExt as _;
457 let tmp = tempfile::tempdir().expect("tempdir");
458 let path = tmp.path().join("secret.txt");
459 write_string_atomic(&path, "s3cr3t").expect("write");
460 let mode = std::fs::metadata(&path).expect("meta").permissions().mode() & 0o777;
461 assert_eq!(mode, 0o600, "file should be owner read/write only");
462 }
463
464 #[test]
465 fn move_directory_contents_returns_zero_when_old_dir_is_absent() {
466 let tmp = tempfile::tempdir().expect("tempdir");
467 let outcome = move_directory_contents(&tmp.path().join("missing"), &tmp.path().join("new"));
468 assert_eq!((outcome.moved, outcome.skipped), (0, 0));
469 assert!(
470 !tmp.path().join("new").exists(),
471 "destination should not be created for a no-op move"
472 );
473 }
474
475 #[test]
476 fn move_directory_contents_moves_files_and_subdirectories() {
477 let tmp = tempfile::tempdir().expect("tempdir");
478 let old_dir = tmp.path().join("old");
479 let new_dir = tmp.path().join("new");
480 std::fs::create_dir_all(old_dir.join("credentials")).expect("mkdir");
481 std::fs::write(old_dir.join("config.toml"), "a = 1").expect("write");
482 std::fs::write(old_dir.join("contacts.toml"), "b = 2").expect("write");
483 std::fs::write(old_dir.join("credentials").join("token.json"), "{}").expect("write");
484
485 let outcome = move_directory_contents(&old_dir, &new_dir);
486
487 assert_eq!(outcome.moved, 3, "config.toml, contacts.toml, credentials/");
488 assert_eq!(outcome.skipped, 0);
489 assert_eq!(
490 std::fs::read_to_string(new_dir.join("config.toml")).expect("read"),
491 "a = 1"
492 );
493 assert_eq!(
494 std::fs::read_to_string(new_dir.join("contacts.toml")).expect("read"),
495 "b = 2"
496 );
497 assert_eq!(
498 std::fs::read_to_string(new_dir.join("credentials").join("token.json")).expect("read"),
499 "{}"
500 );
501 assert!(!old_dir.exists(), "emptied old directory should be removed");
502 }
503
504 #[test]
505 fn move_directory_contents_leaves_conflicting_entries_in_place() {
506 let tmp = tempfile::tempdir().expect("tempdir");
507 let old_dir = tmp.path().join("old");
508 let new_dir = tmp.path().join("new");
509 std::fs::create_dir_all(&old_dir).expect("mkdir");
510 std::fs::create_dir_all(&new_dir).expect("mkdir");
511 std::fs::write(old_dir.join("config.toml"), "old").expect("write");
512 std::fs::write(new_dir.join("config.toml"), "new").expect("write");
513 std::fs::write(old_dir.join("contacts.toml"), "moves fine").expect("write");
514
515 let outcome = move_directory_contents(&old_dir, &new_dir);
516
517 assert_eq!(outcome.moved, 1, "contacts.toml has no conflict");
518 assert_eq!(
519 outcome.skipped, 1,
520 "config.toml conflicts and is left alone"
521 );
522 assert_eq!(
523 std::fs::read_to_string(new_dir.join("config.toml")).expect("read"),
524 "new",
525 "destination copy must never be overwritten"
526 );
527 assert_eq!(
528 std::fs::read_to_string(old_dir.join("config.toml")).expect("read"),
529 "old",
530 "conflicting source file is left in place"
531 );
532 assert!(
533 old_dir.exists(),
534 "old directory is not removed while a conflict remains"
535 );
536 assert!(!old_dir.join("contacts.toml").exists());
537 }
538
539 #[test]
540 #[cfg(target_os = "macos")]
541 fn migrate_macos_config_dir_moves_files_once() {
542 let home = std::env::temp_dir().join("cli-engine-fs-migrate-test");
543 let old_app_dir = home.join(".config").join("my-app");
544 let new_app_dir = home
545 .join("Library")
546 .join("Application Support")
547 .join("my-app");
548 std::fs::remove_dir_all(&home).ok();
551 std::fs::create_dir_all(&old_app_dir).expect("mkdir");
552 std::fs::write(old_app_dir.join("environments.toml"), "env = true").expect("write");
553 let _lock = lock();
554 let _xdg = EnvVarGuard::set("XDG_CONFIG_HOME", None);
555 let _home = EnvVarGuard::set("HOME", Some(&home));
556
557 migrate_macos_config_dir("my-app");
558 assert_eq!(
559 std::fs::read_to_string(new_app_dir.join("environments.toml")).expect("read"),
560 "env = true"
561 );
562 assert!(new_app_dir.join(MACOS_MIGRATION_FLAG).is_file());
563 assert!(!old_app_dir.exists());
564
565 std::fs::create_dir_all(&old_app_dir).expect("mkdir");
568 std::fs::write(old_app_dir.join("late.toml"), "ignored").expect("write");
569 migrate_macos_config_dir("my-app");
570 assert!(
571 !new_app_dir.join("late.toml").exists(),
572 "migration must not repeat once the marker exists"
573 );
574 }
575
576 #[test]
577 #[cfg(target_os = "macos")]
578 fn migrate_macos_config_dir_is_a_noop_when_xdg_config_home_is_set() {
579 let home = std::env::temp_dir().join("cli-engine-fs-migrate-xdg-test");
580 let xdg = std::env::temp_dir().join("cli-engine-fs-migrate-xdg-override");
581 let old_app_dir = home.join(".config").join("my-app");
582 std::fs::remove_dir_all(&home).ok();
583 std::fs::create_dir_all(&old_app_dir).expect("mkdir");
584 std::fs::write(old_app_dir.join("config.toml"), "x = 1").expect("write");
585 with_xdg_config_home(&xdg, || {
588 let _home = EnvVarGuard::set("HOME", Some(&home));
589 migrate_macos_config_dir("my-app");
590 });
591
592 assert!(
593 old_app_dir.join("config.toml").is_file(),
594 "an explicit XDG_CONFIG_HOME must leave the old default location untouched"
595 );
596 }
597}