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/// Resolves the per-user base directory for an app's config and data files.
29///
30/// Returns `$XDG_CONFIG_HOME` when set, else `$HOME/.config` (or `%APPDATA%` on
31/// Windows). Only absolute paths are accepted; a relative value is rejected so
32/// files never land relative to the current working directory.
33#[must_use]
34pub fn config_base_dir() -> Option<PathBuf> {
35    env_path("XDG_CONFIG_HOME")
36        .or_else(|| {
37            // On Windows prefer APPDATA over HOME/.config: HOME is often set by
38            // Git Bash/MSYS shells and would place files in a non-standard
39            // location. On all other platforms prefer XDG-conventional
40            // HOME/.config, falling back to APPDATA only as a last resort.
41            // `cfg!(windows)` keeps both branches compiled (and type-checked)
42            // on every platform.
43            if cfg!(windows) {
44                env_path("APPDATA").or_else(home_config_dir)
45            } else {
46                home_config_dir().or_else(|| env_path("APPDATA"))
47            }
48        })
49        // Reject relative paths: a relative XDG_CONFIG_HOME/APPDATA/HOME would
50        // silently place files relative to the current working directory.
51        .filter(|p| p.is_absolute())
52}
53
54/// Returns the user's home directory.
55///
56/// On non-Windows platforms this reads `$HOME`. On Windows, `%USERPROFILE%` is
57/// tried first, then `$HOME` as a fallback (matching shell environments such as
58/// Git Bash that set `HOME`).
59///
60/// Only absolute paths are accepted; a relative value is rejected so files
61/// never land relative to the current working directory. Returns `None` when
62/// no suitable variable is set or the resolved path is relative.
63#[must_use]
64pub fn home_dir() -> Option<PathBuf> {
65    if cfg!(windows) {
66        env_path("USERPROFILE").or_else(|| env_path("HOME"))
67    } else {
68        env_path("HOME")
69    }
70    .filter(|p| p.is_absolute())
71}
72
73/// Returns true only when `s` is a single, non-traversal path component that is
74/// valid on all supported platforms.
75///
76/// Use this to validate untrusted segments (app ids, environment names, etc.)
77/// before joining them into a path.
78///
79/// Rejects:
80/// - empty strings, `.`, and `..`
81/// - strings containing `/` or `\` (path separators on any platform)
82/// - Windows-forbidden filename characters: `:  * ? " < > |`
83/// - ASCII control characters (bytes 0x00–0x1F) and the DEL character (0x7F)
84/// - leading or trailing space (leading space is invisible in directory listings)
85/// - trailing `.` (valid on Unix but rejected by Windows)
86/// - Windows reserved device names (`CON`, `NUL`, `COM1`, etc.) with or without extension
87#[must_use]
88pub fn is_safe_path_component(s: &str) -> bool {
89    // '/' is listed explicitly because Path::components() silently strips trailing
90    // slashes — "prod/" parses as a single Normal("prod") component and would
91    // otherwise pass the components check below.
92    const FORBIDDEN: &[char] = &['/', '\\', ':', '*', '?', '"', '<', '>', '|'];
93    if s.contains(FORBIDDEN) || s.bytes().any(|b| b < 0x20 || b == 0x7F) {
94        return false;
95    }
96    if s.starts_with(' ') || s.ends_with('.') || s.ends_with(' ') {
97        return false;
98    }
99    // Windows treats these device names as special regardless of extension,
100    // e.g. opening "NUL.json" writes to the null device, not a file.
101    const RESERVED: &[&str] = &[
102        "CON", "PRN", "AUX", "NUL", "COM0", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7",
103        "COM8", "COM9", "LPT0", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8",
104        "LPT9",
105    ];
106    let stem = Path::new(s)
107        .file_stem()
108        .and_then(|s| s.to_str())
109        .unwrap_or(s);
110    if RESERVED.iter().any(|r| stem.eq_ignore_ascii_case(r)) {
111        return false;
112    }
113    let mut components = Path::new(s).components();
114    matches!(components.next(), Some(std::path::Component::Normal(_)))
115        && components.next().is_none()
116}
117
118/// Writes `contents` to `path` via a uniquely-named temp file then renames it
119/// into place. On Unix the rename is atomic, the file is created `0600`, and
120/// **newly-created** parent directories are best-effort restricted to `0700`.
121/// Pre-existing parent directories are left unchanged so callers that write
122/// into established locations (e.g. `$HOME`) do not alter their permissions.
123/// On Windows the rename replaces an existing destination but is not
124/// crash-atomic.
125///
126/// **Blocking**: this function uses synchronous filesystem I/O. Call it from
127/// within [`tokio::task::spawn_blocking`] when used in an async context to
128/// avoid stalling the executor.
129///
130/// # Errors
131/// Returns an error when the directory cannot be created or the write/rename
132/// fails.
133pub fn write_string_atomic(path: &Path, contents: &str) -> crate::Result<()> {
134    if let Some(parent) = path.parent() {
135        // Record whether the parent already existed so we only restrict
136        // permissions on directories we create, not on pre-existing ones
137        // such as $HOME (which other users need to traverse).
138        let parent_existed = parent.is_dir();
139        std::fs::create_dir_all(parent)
140            .map_err(|e| CliCoreError::message(format!("failed to create directory: {e}")))?;
141        #[cfg(unix)]
142        if !parent_existed {
143            use std::os::unix::fs::PermissionsExt as _;
144            if let Err(e) = std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700))
145            {
146                tracing::debug!(
147                    path = %parent.display(),
148                    error = %e,
149                    "could not restrict directory permissions"
150                );
151            }
152        }
153    }
154    // Unique temp name without pulling in `rand`: pid plus a monotonic counter is
155    // unique within a process, and the pid differs across processes.
156    static TMP_COUNTER: AtomicU64 = AtomicU64::new(0);
157    let unique = TMP_COUNTER.fetch_add(1, Ordering::Relaxed);
158    let pid = std::process::id();
159    let tmp_path = path.with_file_name(format!(
160        "{}.{pid:x}.{unique:x}.tmp",
161        path.file_name().and_then(|s| s.to_str()).unwrap_or("tmp"),
162    ));
163    write_tmp_file(&tmp_path, contents)?;
164    if let Err(e) = std::fs::rename(&tmp_path, path) {
165        std::fs::remove_file(&tmp_path).ok();
166        return Err(CliCoreError::message(format!(
167            "failed to finalize {}: {e}",
168            path.display()
169        )));
170    }
171    Ok(())
172}
173
174/// Opens `tmp_path` with `O_CREAT|O_EXCL` and writes `contents`, mode `0600` on
175/// Unix so files are never world-readable.
176fn write_tmp_file(tmp_path: &Path, contents: &str) -> crate::Result<()> {
177    use std::io::Write as _;
178    let mut opts = std::fs::OpenOptions::new();
179    opts.write(true).create_new(true);
180    #[cfg(unix)]
181    {
182        use std::os::unix::fs::OpenOptionsExt as _;
183        opts.mode(0o600);
184    }
185    let mut file = opts.open(tmp_path).map_err(|e| {
186        CliCoreError::message(format!("failed to write {}: {e}", tmp_path.display()))
187    })?;
188    file.write_all(contents.as_bytes())
189        .map_err(|e| CliCoreError::message(format!("failed to write {}: {e}", tmp_path.display())))
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use crate::config::test_env::{EnvVarGuard, lock, with_xdg_config_home};
196
197    fn with_home<F: FnOnce() -> R, R>(value: &Path, f: F) -> R {
198        let _lock = lock();
199        let _restore = EnvVarGuard::set("HOME", Some(value));
200        f()
201    }
202
203    #[test]
204    fn safe_path_component_basic() {
205        assert!(is_safe_path_component("godaddy"));
206        assert!(!is_safe_path_component(".."));
207        assert!(!is_safe_path_component(""));
208        assert!(!is_safe_path_component("a/b"));
209        assert!(!is_safe_path_component("NUL"));
210    }
211
212    #[test]
213    fn safe_path_component_rejects_windows_reserved_names() {
214        for name in &[
215            "CON", "con", "NUL", "nul", "COM1", "LPT9", "CON.txt", "NUL.json",
216        ] {
217            assert!(
218                !is_safe_path_component(name),
219                "{name:?} should be rejected as a Windows reserved name"
220            );
221        }
222    }
223
224    #[test]
225    fn safe_path_component_rejects_control_and_space_edges() {
226        assert!(!is_safe_path_component(" prod"), "leading space");
227        assert!(!is_safe_path_component("prod\x7f"), "DEL byte");
228        assert!(!is_safe_path_component("prod."), "trailing dot");
229        assert!(!is_safe_path_component("prod "), "trailing space");
230    }
231
232    #[test]
233    fn safe_path_component_accepts_normal_values() {
234        for name in &["dev", "prod", "staging", "my-app", "my_app", "app.v2"] {
235            assert!(is_safe_path_component(name), "{name:?} should be accepted");
236        }
237    }
238
239    #[test]
240    fn config_base_dir_rejects_relative_xdg() {
241        with_xdg_config_home(Path::new("."), || {
242            assert!(
243                config_base_dir().is_none(),
244                "relative XDG_CONFIG_HOME should be rejected"
245            );
246        });
247    }
248
249    #[test]
250    fn config_base_dir_honors_xdg() {
251        let dir = std::env::temp_dir().join("cli-engine-fs-base-test");
252        with_xdg_config_home(&dir, || {
253            assert_eq!(config_base_dir(), Some(dir.clone()));
254        });
255    }
256
257    #[test]
258    fn home_dir_honors_home_env() {
259        let dir = std::env::temp_dir().join("cli-engine-fs-home-test");
260        with_home(&dir, || {
261            assert_eq!(home_dir(), Some(dir.clone()));
262        });
263    }
264
265    #[test]
266    fn home_dir_rejects_relative() {
267        with_home(Path::new("."), || {
268            assert!(home_dir().is_none(), "relative HOME should be rejected");
269        });
270    }
271
272    #[tokio::test]
273    async fn write_string_atomic_round_trip_creates_dirs() {
274        let tmp = tempfile::tempdir().expect("tempdir");
275        let path = tmp.path().join("nested").join("file.txt");
276        write_string_atomic(&path, "hello").expect("write");
277        assert_eq!(std::fs::read_to_string(&path).expect("read"), "hello");
278        // Overwrite replaces the contents.
279        write_string_atomic(&path, "world").expect("rewrite");
280        assert_eq!(std::fs::read_to_string(&path).expect("read"), "world");
281        // No stray temp files remain alongside the target.
282        let strays: Vec<_> = std::fs::read_dir(path.parent().expect("parent"))
283            .expect("read_dir")
284            .filter_map(|e| e.ok())
285            .filter(|e| e.file_name().to_string_lossy().ends_with(".tmp"))
286            .collect();
287        assert!(strays.is_empty(), "temp files should be renamed away");
288    }
289
290    #[cfg(unix)]
291    #[tokio::test]
292    async fn write_string_atomic_sets_owner_only_mode() {
293        use std::os::unix::fs::PermissionsExt as _;
294        let tmp = tempfile::tempdir().expect("tempdir");
295        let path = tmp.path().join("secret.txt");
296        write_string_atomic(&path, "s3cr3t").expect("write");
297        let mode = std::fs::metadata(&path).expect("meta").permissions().mode() & 0o777;
298        assert_eq!(mode, 0o600, "file should be owner read/write only");
299    }
300}