Skip to main content

cfgd_core/util/
paths.rs

1thread_local! {
2    /// Thread-local override for the resolved home directory.
3    ///
4    /// Tests that exercise code paths resolving `~` or `$HOME` must set this
5    /// to a tempdir to prevent real-filesystem mutations (writes to
6    /// `~/.cfgd.env`, injection into `~/.bashrc`, etc.). Production code
7    /// never reads or writes this cell — it only affects `home_dir_var` and
8    /// `default_config_dir` when a test scoped an override.
9    ///
10    /// Use `with_test_home(path, || ...)` to scope an override; the value is
11    /// restored on return even if the closure panics (RAII via the guard).
12    static TEST_HOME_OVERRIDE: std::cell::RefCell<Option<std::path::PathBuf>> =
13        const { std::cell::RefCell::new(None) };
14}
15
16/// RAII guard returned by [`with_test_home_guard`] — restores the prior
17/// override on drop. Used by test harnesses (like `TestEnvBuilder`) that want
18/// to install an override without wrapping the whole test in a closure.
19#[must_use = "dropping the guard immediately restores the previous override"]
20pub struct TestHomeGuard {
21    prev: Option<std::path::PathBuf>,
22}
23
24impl Drop for TestHomeGuard {
25    fn drop(&mut self) {
26        let prev = self.prev.take();
27        TEST_HOME_OVERRIDE.with(|o| *o.borrow_mut() = prev);
28    }
29}
30
31/// Install a HOME override for the current thread and return a guard that
32/// restores the prior value on drop. Use in test builders that need the
33/// override to outlive a single closure call.
34pub fn with_test_home_guard(home: &std::path::Path) -> TestHomeGuard {
35    let prev = TEST_HOME_OVERRIDE.with(|o| o.replace(Some(home.to_path_buf())));
36    TestHomeGuard { prev }
37}
38
39/// Scope a HOME override for the duration of `f`. The prior value (including
40/// `None`) is restored when `f` returns, whether normally or via panic.
41pub fn with_test_home<F, R>(home: &std::path::Path, f: F) -> R
42where
43    F: FnOnce() -> R,
44{
45    let _guard = with_test_home_guard(home);
46    f()
47}
48
49/// Read the current test HOME override (if any). Only used internally by
50/// `home_dir_var` / `default_config_dir`, and by `tests` to assert that the
51/// guard was installed/cleared as expected.
52pub(crate) fn test_home_override() -> Option<std::path::PathBuf> {
53    TEST_HOME_OVERRIDE.with(|o| o.borrow().clone())
54}
55
56/// Default config directory: `~/.config/cfgd` on Unix (respects XDG_CONFIG_HOME),
57/// `AppData\Roaming\cfgd` on Windows.
58pub fn default_config_dir() -> std::path::PathBuf {
59    // Thread-local test override always wins. Lets tests redirect config
60    // lookup to a tempdir without mutating global env state.
61    if let Some(home) = test_home_override() {
62        return home.join(".config").join("cfgd");
63    }
64    #[cfg(unix)]
65    {
66        if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
67            return std::path::PathBuf::from(xdg).join("cfgd");
68        }
69        expand_tilde(std::path::Path::new("~/.config/cfgd"))
70    }
71    #[cfg(windows)]
72    {
73        directories::BaseDirs::new()
74            .map(|b| b.config_dir().join("cfgd"))
75            .unwrap_or_else(|| std::path::PathBuf::from(r"C:\ProgramData\cfgd"))
76    }
77}
78
79/// Per-user runtime directory for short-lived sockets and pid files.
80///
81/// Resolution order:
82/// - Linux: `$XDG_RUNTIME_DIR/cfgd` if set, else `$HOME/.cache/cfgd`. The base
83///   `$XDG_RUNTIME_DIR` is owner-private by spec; the cache fallback is
84///   under the user's home where Linux-default permissions already protect it.
85/// - macOS: `$HOME/Library/Application Support/cfgd`. There is no
86///   per-user `tmpfs` on macOS, and `$TMPDIR` is per-user but still
87///   world-traversable when the umask leaks; Application Support is the
88///   conventional per-user location for app state.
89/// - Windows: `%LOCALAPPDATA%\cfgd` via `directories::BaseDirs`. (Daemons on
90///   Windows use named pipes, which are kernel objects — this path is
91///   provided for parity and is unused by the daemon socket flow.)
92///
93/// Honors the [`TestHomeGuard`] thread-local override on every platform so
94/// tests can redirect the runtime dir without mutating process-global env
95/// state. Returns `None` only when no home directory can be resolved at all.
96pub fn default_runtime_dir() -> Option<std::path::PathBuf> {
97    #[cfg(target_os = "linux")]
98    {
99        // XDG_RUNTIME_DIR is a per-user tmpfs (typically 0700) on systemd
100        // systems — prefer it. Test override of HOME does not shadow it
101        // because tests that need a deterministic socket path point
102        // XDG_RUNTIME_DIR at a tempdir directly.
103        if let Some(xdg) = std::env::var_os("XDG_RUNTIME_DIR") {
104            let xdg = std::path::PathBuf::from(xdg);
105            if !xdg.as_os_str().is_empty() {
106                return Some(xdg.join("cfgd"));
107            }
108        }
109        let home = home_dir_var()?;
110        Some(std::path::PathBuf::from(home).join(".cache").join("cfgd"))
111    }
112    #[cfg(target_os = "macos")]
113    {
114        let home = home_dir_var()?;
115        Some(
116            std::path::PathBuf::from(home)
117                .join("Library")
118                .join("Application Support")
119                .join("cfgd"),
120        )
121    }
122    #[cfg(windows)]
123    {
124        if let Some(home) = test_home_override() {
125            return Some(home.join("AppData").join("Local").join("cfgd"));
126        }
127        directories::BaseDirs::new().map(|b| b.data_local_dir().join("cfgd"))
128    }
129    #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
130    {
131        let home = home_dir_var()?;
132        Some(std::path::PathBuf::from(home).join(".cache").join("cfgd"))
133    }
134}
135
136/// Expand `~` and `~/...` paths to the user's home directory.
137pub fn expand_tilde(path: &std::path::Path) -> std::path::PathBuf {
138    let path_str = path.display().to_string();
139    let home = home_dir_var();
140    if let Some(home) = home {
141        if path_str == "~" {
142            return std::path::PathBuf::from(home);
143        }
144        if path_str.starts_with("~/") || path_str.starts_with("~\\") {
145            return std::path::PathBuf::from(path_str.replacen('~', &home, 1));
146        }
147    }
148    path.to_path_buf()
149}
150
151/// Resolve the user's home directory, consulting the test override first.
152/// Unix production path: checks HOME.
153/// Windows production path: checks USERPROFILE first, then HOME (for WSL/Git Bash contexts).
154pub(crate) fn home_dir_var() -> Option<String> {
155    if let Some(home) = test_home_override() {
156        return Some(home.to_string_lossy().into_owned());
157    }
158    #[cfg(unix)]
159    {
160        std::env::var("HOME").ok()
161    }
162    #[cfg(windows)]
163    {
164        std::env::var("USERPROFILE")
165            .or_else(|_| std::env::var("HOME"))
166            .ok()
167    }
168}
169
170/// Resolve a relative path against a base directory with traversal validation.
171/// Absolute paths are returned as-is. Relative paths are joined to `base` and
172/// validated with `validate_no_traversal`. Returns `Err` if the relative path
173/// contains `..` components.
174pub fn resolve_relative_path(
175    path: &std::path::Path,
176    base: &std::path::Path,
177) -> std::result::Result<std::path::PathBuf, String> {
178    if path.is_absolute() {
179        Ok(path.to_path_buf())
180    } else {
181        let joined = base.join(path);
182        validate_no_traversal(&joined)?;
183        Ok(joined)
184    }
185}
186
187/// Validate that a resolved path does not escape a root directory.
188///
189/// Canonicalizes both paths and checks containment. Returns the canonicalized
190/// path on success.
191pub fn validate_path_within(
192    path: &std::path::Path,
193    root: &std::path::Path,
194) -> std::result::Result<std::path::PathBuf, std::io::Error> {
195    let canonical_root = root.canonicalize()?;
196    let canonical_path = path.canonicalize()?;
197    if !canonical_path.starts_with(&canonical_root) {
198        return Err(std::io::Error::new(
199            std::io::ErrorKind::PermissionDenied,
200            format!(
201                "path {} escapes root {}",
202                canonical_path.posix(),
203                canonical_root.posix()
204            ),
205        ));
206    }
207    Ok(canonical_path)
208}
209
210/// Validate that a path contains no `..` components (pre-canonicalization check).
211///
212/// This catches traversal attempts even when intermediate directories don't
213/// exist yet, which `canonicalize()` cannot handle.
214pub fn validate_no_traversal(path: &std::path::Path) -> std::result::Result<(), String> {
215    for component in path.components() {
216        if let std::path::Component::ParentDir = component {
217            return Err(format!("path contains '..': {}", path.posix()));
218        }
219    }
220    Ok(())
221}
222
223/// Recursively copy a directory from source to target.
224/// Skips symlinks to prevent symlink-following attacks and infinite loops.
225pub fn copy_dir_recursive(
226    src: &std::path::Path,
227    dst: &std::path::Path,
228) -> std::result::Result<(), std::io::Error> {
229    std::fs::create_dir_all(dst)?;
230    for entry in std::fs::read_dir(src)? {
231        let entry = entry?;
232        let file_type = entry.file_type()?;
233        // Skip symlinks — prevents following links outside the source tree
234        if file_type.is_symlink() {
235            continue;
236        }
237        let dst_path = dst.join(entry.file_name());
238        if file_type.is_dir() {
239            copy_dir_recursive(&entry.path(), &dst_path)?;
240        } else {
241            std::fs::copy(entry.path(), &dst_path)?;
242        }
243    }
244    Ok(())
245}
246
247/// Always-fold POSIX form of a path. Use anywhere a path crosses into JSON,
248/// YAML, SQLite, gateway API, OCI annotations, `file://` URLs, or snapshot
249/// goldens. Backslash is treated as a separator; legitimate backslash-in-
250/// filename on POSIX is sacrificed for cross-OS state portability (see the
251/// path-handling consolidation spec for the fold-policy rationale).
252pub fn to_posix_string(path: impl AsRef<std::path::Path>) -> String {
253    path.as_ref().to_string_lossy().replace('\\', "/")
254}
255
256/// Fold `\` → `/` in free-form text that may contain native-separator paths.
257/// `Cow` so the unix path stays borrowed; only Windows captures pay for the
258/// allocation.
259pub fn posixify_text(s: &str) -> std::borrow::Cow<'_, str> {
260    if s.contains('\\') {
261        std::borrow::Cow::Owned(s.replace('\\', "/"))
262    } else {
263        std::borrow::Cow::Borrowed(s)
264    }
265}
266
267/// Build a `file://` URL that round-trips through `url::Url::parse` on both
268/// unix (`file:///home/foo`) and Windows (`file:///C:/Users/foo`). Replaces
269/// every hand-rolled `format!("file://{}", path.display())` callsite that
270/// silently emits backslashes and a missing third slash on Windows.
271pub fn to_file_url(path: impl AsRef<std::path::Path>) -> String {
272    let s = to_posix_string(path);
273    if s.starts_with('/') {
274        format!("file://{s}")
275    } else {
276        format!("file:///{s}")
277    }
278}
279
280/// CRLF → LF, for paired use with [`posixify_text`] in snapshot normalization.
281/// `Cow` so unix captures stay borrowed.
282pub fn normalize_line_endings(s: &str) -> std::borrow::Cow<'_, str> {
283    if s.contains("\r\n") {
284        std::borrow::Cow::Owned(s.replace("\r\n", "\n"))
285    } else {
286        std::borrow::Cow::Borrowed(s)
287    }
288}
289
290/// Composite normalizer for snapshot tests: CRLF→LF, fold `\`→`/`, then
291/// substitute each `(path, placeholder)` pair. Substitutions are applied
292/// longest-first to handle nested temp paths correctly (e.g. when
293/// `<BARE>/inner` and `<BARE_ROOT>` both match, longest wins). Each path is
294/// posixified before substitution so the captured text and the substitution
295/// keys share the same separator convention.
296pub fn normalize_for_snapshot(captured: &str, paths: &[(&std::path::Path, &str)]) -> String {
297    let lf = normalize_line_endings(captured);
298    let posix = posixify_text(&lf);
299    let os = posixify_os_error_text(&posix);
300    let mut subs: Vec<(String, &str)> = paths
301        .iter()
302        .map(|(p, label)| (to_posix_string(p), *label))
303        .collect();
304    subs.sort_by_key(|(p, _)| std::cmp::Reverse(p.len()));
305    let mut out = os.into_owned();
306    for (p, label) in subs {
307        if p.is_empty() {
308            continue;
309        }
310        out = out.replace(&p, label);
311    }
312    out
313}
314
315/// Collapse OS-specific `std::io::Error` text in captured snapshot output.
316/// Linux emits `... File exists (os error 17)` for `ErrorKind::AlreadyExists`;
317/// Windows emits `... Cannot create a file when that file already exists.
318/// (os error 183)` for the same kind. Both fold to a stable `<os error>`
319/// placeholder so a single golden file works on both.
320///
321/// Also collapses libgit2's `<prose>; class=Os (N)` form to
322/// `<os error>; class=Os (N)` — Linux libgit2 emits
323/// `... No such file or directory; class=Os (2)`, Windows libgit2 emits
324/// `... The system cannot find the file specified. — ; class=Os (2)`.
325/// Different prose, same logical error; fold to the common prefix shape
326/// so the golden is OS-independent.
327///
328/// Use after path normalization in [`normalize_for_snapshot`]-style
329/// pipelines for tests that touch the filesystem or git.
330pub fn posixify_os_error_text(s: &str) -> std::borrow::Cow<'_, str> {
331    const STD_MARKER: &str = "(os error ";
332    const GIT_MARKER: &str = "; class=Os (";
333    if !s.contains(STD_MARKER) && !s.contains(GIT_MARKER) {
334        return std::borrow::Cow::Borrowed(s);
335    }
336    let mut out = String::with_capacity(s.len());
337    let mut rest = s;
338    loop {
339        // Pick whichever marker appears next in `rest` — process each in turn.
340        let std_idx = rest.find(STD_MARKER);
341        let git_idx = rest.find(GIT_MARKER);
342        let (idx, marker, is_git) = match (std_idx, git_idx) {
343            (None, None) => {
344                out.push_str(rest);
345                break;
346            }
347            (Some(i), None) => (i, STD_MARKER, false),
348            (None, Some(i)) => (i, GIT_MARKER, true),
349            (Some(s_i), Some(g_i)) => {
350                if s_i <= g_i {
351                    (s_i, STD_MARKER, false)
352                } else {
353                    (g_i, GIT_MARKER, true)
354                }
355            }
356        };
357        let after_open = &rest[idx + marker.len()..];
358        let digits_end = after_open
359            .find(|c: char| !c.is_ascii_digit())
360            .unwrap_or(after_open.len());
361        let is_well_formed = digits_end > 0 && after_open.as_bytes().get(digits_end) == Some(&b')');
362        if !is_well_formed {
363            // Not a real marker — emit one byte and continue scanning.
364            let safe_end = idx + 1;
365            out.push_str(&rest[..safe_end]);
366            rest = &rest[safe_end..];
367            continue;
368        }
369        // Walk back from `idx` to the last "<sep>: " — that's the boundary
370        // between the error prefix (e.g. "io error on <PATH>: ") and the
371        // OS-native prose we collapse.
372        let prefix = &rest[..idx];
373        let cut = prefix.rfind(": ").map(|p| p + 2).unwrap_or(idx);
374        out.push_str(&prefix[..cut]);
375        out.push_str("<os error>");
376        if is_git {
377            // Preserve the `; class=Os (N)` tail so consumers that grep
378            // for the libgit2 marker still see it.
379            out.push_str(GIT_MARKER);
380            out.push_str(&after_open[..digits_end + 1]);
381        }
382        rest = &after_open[digits_end + 1..];
383    }
384    std::borrow::Cow::Owned(out)
385}
386
387/// User-input path tolerance: accept `C:\foo`, `C:/foo`, `~/foo`, `./foo`.
388/// Folds `\` → `/` and expands a leading `~` via [`expand_tilde`]. Use when
389/// loading config fields where a Linux author may write `/` and a Windows
390/// author may write `\` for the same logical location.
391pub fn from_user_input(s: &str) -> std::path::PathBuf {
392    let folded = if s.contains('\\') {
393        s.replace('\\', "/")
394    } else {
395        s.to_string()
396    };
397    expand_tilde(std::path::Path::new(&folded))
398}
399
400/// Display-only extension for human-facing path output. On Windows, folds
401/// `\` → `/` so a status subject or error message shows POSIX-form paths
402/// consistently across runners. On Unix, passes through unchanged — a
403/// legitimate `\` in a Unix filename survives byte-for-byte.
404///
405/// `display_posix()` is the eager form (returns `String`).
406/// `posix()` is the lazy form — returns `impl Display` so it composes with
407/// `format!`/`write!`/`println!` without an intermediate allocation.
408///
409/// Use in:
410/// - Printer status subjects (`status[_simple]`, kv values, error messages)
411/// - `tracing::info!`/`warn!`/`error!` event fields where the path is the
412///   human-visible value
413///
414/// Do NOT use in:
415/// - JSON / YAML / SQLite / OCI / gateway boundaries — use
416///   [`to_posix_string`] instead (always folds, not Windows-only)
417/// - Debug-only `tracing::debug!`/`trace!` event fields — keep native so
418///   debug tooling sees what's on disk
419pub trait PathDisplayExt {
420    /// Eager: returns a `String` with `\` folded to `/` on Windows, native on Unix.
421    fn display_posix(&self) -> String;
422    /// Lazy: returns a `Display` adapter suitable for `format!` / `write!`.
423    fn posix(&self) -> PathPosix<'_>;
424}
425
426impl<P: AsRef<std::path::Path>> PathDisplayExt for P {
427    fn display_posix(&self) -> String {
428        #[cfg(windows)]
429        {
430            to_posix_string(self.as_ref())
431        }
432        #[cfg(not(windows))]
433        {
434            self.as_ref().display().to_string()
435        }
436    }
437
438    fn posix(&self) -> PathPosix<'_> {
439        PathPosix(self.as_ref())
440    }
441}
442
443/// `Display` adapter returned by [`PathDisplayExt::posix`]. On Windows,
444/// renders the path with `\` → `/` substitution; on Unix it's
445/// indistinguishable from `Path::display()`.
446pub struct PathPosix<'a>(&'a std::path::Path);
447
448impl std::fmt::Display for PathPosix<'_> {
449    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
450        #[cfg(windows)]
451        {
452            let s = self.0.to_string_lossy();
453            for ch in s.chars() {
454                let mapped = if ch == '\\' { '/' } else { ch };
455                std::fmt::Write::write_char(f, mapped)?;
456            }
457            Ok(())
458        }
459        #[cfg(not(windows))]
460        {
461            std::fmt::Display::fmt(&self.0.display(), f)
462        }
463    }
464}