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}