Skip to main content

cli_engine/
fs.rs

1//! Filesystem and path utilities shared across the engine.
2//!
3//! These primitives back both the engine [config file](crate::config) and
4//! [credential storage](crate::auth::storage): resolving the per-user base
5//! directory, validating untrusted path components, and writing files
6//! atomically with restrictive permissions. They are domain-agnostic so callers
7//! that persist their own files can reuse them rather than re-implementing the
8//! same path safety and atomic-write logic.
9
10use std::path::{Path, PathBuf};
11use std::sync::atomic::{AtomicU64, Ordering};
12
13use crate::error::CliCoreError;
14
15/// Reads `key` from the environment as a non-empty path, or `None`.
16fn 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
23/// XDG-conventional `$HOME/.config`, if `HOME` is set.
24fn home_config_dir() -> Option<PathBuf> {
25    env_path("HOME").map(|home| home.join(".config"))
26}
27
28/// macOS-idiomatic `$HOME/Library/Application Support`, if `HOME` is set.
29fn home_application_support_dir() -> Option<PathBuf> {
30    env_path("HOME").map(|home| home.join("Library").join("Application Support"))
31}
32
33/// Resolves the per-user base directory for an app's config and data files.
34///
35/// Returns `$XDG_CONFIG_HOME` when set, else the platform-idiomatic default:
36/// `$HOME/Library/Application Support` on macOS, `%APPDATA%` on Windows, or
37/// `$HOME/.config` elsewhere. Only absolute paths are accepted; a relative
38/// value is rejected so files never land relative to the current working
39/// directory.
40#[must_use]
41pub fn config_base_dir() -> Option<PathBuf> {
42    env_path("XDG_CONFIG_HOME")
43        .or_else(|| {
44            // On Windows prefer APPDATA over HOME/.config: HOME is often set by
45            // Git Bash/MSYS shells and would place files in a non-standard
46            // location. On macOS prefer the idiomatic Application Support
47            // directory over XDG-conventional HOME/.config. `cfg!(...)` keeps
48            // every branch compiled (and type-checked) on all platforms.
49            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        // Reject relative paths: a relative XDG_CONFIG_HOME/APPDATA/HOME would
58        // silently place files relative to the current working directory.
59        .filter(|p| p.is_absolute())
60}
61
62/// Name of the marker file that records a completed macOS config-dir
63/// migration, so [`migrate_macos_config_dir`] only ever moves files once.
64const MACOS_MIGRATION_FLAG: &str = ".cli_engine_macos_migrated";
65
66/// One-time startup migration for the `$HOME/.config` → `$HOME/Library/Application
67/// Support` default change on macOS.
68///
69/// No-op on any other platform, when `XDG_CONFIG_HOME` is set (the user
70/// already controls the location explicitly), or once the migration marker
71/// exists under the new `<app_id>` directory. Otherwise moves every entry
72/// files and subdirectories alike, whatever they're named, out of
73/// `$HOME/.config/<app_id>` and into `$HOME/Library/Application
74/// Support/<app_id>`, since callers other than this crate (credential
75/// storage, and any consumer-owned files) may have written there and this
76/// function has no way to know their names.
77pub(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        // Couldn't record the marker (e.g. read-only filesystem), leave
95        // things as they are and just re-check on the next invocation.
96        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
106/// Result of [`move_directory_contents`]: counts, not names, are all callers
107/// currently need.
108struct MoveOutcome {
109    moved: usize,
110    skipped: usize,
111}
112
113/// Moves every entry from `old_dir` into `new_dir`, creating `new_dir` only
114/// if there's at least one entry to move. An entry whose name already exists
115/// under `new_dir` is left untouched in `old_dir` (never overwritten) and
116/// counted as skipped. Returns `(0, 0)` when `old_dir` doesn't exist.
117///
118/// Platform-agnostic: callers decide *when* to invoke this (e.g. only on
119/// macOS); this function only knows how to move a directory's contents.
120fn 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        // Cross-device fallback for regular files; a directory (in practice,
148        // only the `credentials/` subdirectory) that fails to rename across
149        // devices is left in place rather than recursively copied — same-
150        // volume rename covers every normal `$HOME`-relative setup.
151        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
166/// Best-effort, single-line stderr notice for a completed migration.
167fn 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
182/// Best-effort, single-line stderr notice for entries left behind because the
183/// destination already had a same-named entry.
184fn 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/// Returns the user's home directory.
200///
201/// On non-Windows platforms this reads `$HOME`. On Windows, `%USERPROFILE%` is
202/// tried first, then `$HOME` as a fallback (matching shell environments such as
203/// Git Bash that set `HOME`).
204///
205/// Only absolute paths are accepted; a relative value is rejected so files
206/// never land relative to the current working directory. Returns `None` when
207/// no suitable variable is set or the resolved path is relative.
208#[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/// Returns true only when `s` is a single, non-traversal path component that is
219/// valid on all supported platforms.
220///
221/// Use this to validate untrusted segments (app ids, environment names, etc.)
222/// before joining them into a path.
223///
224/// Rejects:
225/// - empty strings, `.`, and `..`
226/// - strings containing `/` or `\` (path separators on any platform)
227/// - Windows-forbidden filename characters: `:  * ? " < > |`
228/// - ASCII control characters (bytes 0x00–0x1F) and the DEL character (0x7F)
229/// - leading or trailing space (leading space is invisible in directory listings)
230/// - trailing `.` (valid on Unix but rejected by Windows)
231/// - Windows reserved device names (`CON`, `NUL`, `COM1`, etc.) with or without extension
232#[must_use]
233pub fn is_safe_path_component(s: &str) -> bool {
234    // '/' is listed explicitly because Path::components() silently strips trailing
235    // slashes — "prod/" parses as a single Normal("prod") component and would
236    // otherwise pass the components check below.
237    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    // Windows treats these device names as special regardless of extension,
245    // e.g. opening "NUL.json" writes to the null device, not a file.
246    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
263/// Writes `contents` to `path` via a uniquely-named temp file then renames it
264/// into place. On Unix the rename is atomic, the file is created `0600`, and
265/// **newly-created** parent directories are best-effort restricted to `0700`.
266/// Pre-existing parent directories are left unchanged so callers that write
267/// into established locations (e.g. `$HOME`) do not alter their permissions.
268/// On Windows the rename replaces an existing destination but is not
269/// crash-atomic.
270///
271/// **Blocking**: this function uses synchronous filesystem I/O. Call it from
272/// within [`tokio::task::spawn_blocking`] when used in an async context to
273/// avoid stalling the executor.
274///
275/// # Errors
276/// Returns an error when the directory cannot be created or the write/rename
277/// fails.
278pub 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    // Unique temp name without pulling in `rand`: pid plus a monotonic counter is
284    // unique within a process, and the pid differs across processes.
285    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
303/// Creates `dir` (and any missing ancestors) if absent. On Unix, a directory
304/// that did **not** already exist is best-effort restricted to `0700`;
305/// pre-existing directories (e.g. `$HOME`) are left unchanged so their
306/// permissions aren't altered by a caller that merely writes into them.
307fn 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
324/// Opens `tmp_path` with `O_CREAT|O_EXCL` and writes `contents`, mode `0600` on
325/// Unix so files are never world-readable.
326fn 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        // Overwrite replaces the contents.
442        write_string_atomic(&path, "world").expect("rewrite");
443        assert_eq!(std::fs::read_to_string(&path).expect("read"), "world");
444        // No stray temp files remain alongside the target.
445        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        // Fixed temp-dir name (matching this file's other macOS test): clean
549        // up any residue from a previous run before writing real fixtures.
550        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        // Second run is a no-op: dropping a new file into the old location
566        // (simulating a stray write) must not be picked up once migrated.
567        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` takes the shared env-var lock itself; taking
586        // it again here would deadlock on the same thread.
587        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}