Skip to main content

zerobox_utils_absolute_path/
lib.rs

1use dirs::home_dir;
2use schemars::JsonSchema;
3use serde::Deserialize;
4use serde::Deserializer;
5use serde::Serialize;
6use serde::de::Error as SerdeError;
7use std::cell::RefCell;
8use std::path::Display;
9use std::path::Path;
10use std::path::PathBuf;
11use ts_rs::TS;
12
13mod absolutize;
14
15/// A path that is guaranteed to be absolute and normalized (though it is not
16/// guaranteed to be canonicalized or exist on the filesystem).
17///
18/// IMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set
19/// using [AbsolutePathBufGuard::new]. If no base path is set, the
20/// deserialization will fail unless the path being deserialized is already
21/// absolute.
22#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, JsonSchema, TS)]
23pub struct AbsolutePathBuf(PathBuf);
24
25impl AbsolutePathBuf {
26    fn maybe_expand_home_directory(path: &Path) -> PathBuf {
27        if let Some(path_str) = path.to_str()
28            && let Some(home) = home_dir()
29            && let Some(rest) = path_str.strip_prefix('~')
30        {
31            if rest.is_empty() {
32                return home;
33            } else if let Some(rest) = rest.strip_prefix('/') {
34                return home.join(rest.trim_start_matches('/'));
35            } else if cfg!(windows)
36                && let Some(rest) = rest.strip_prefix('\\')
37            {
38                return home.join(rest.trim_start_matches('\\'));
39            }
40        }
41        path.to_path_buf()
42    }
43
44    pub fn resolve_path_against_base<P: AsRef<Path>, B: AsRef<Path>>(
45        path: P,
46        base_path: B,
47    ) -> Self {
48        let expanded = Self::maybe_expand_home_directory(path.as_ref());
49        Self(absolutize::absolutize_from(&expanded, base_path.as_ref()))
50    }
51
52    pub fn from_absolute_path<P: AsRef<Path>>(path: P) -> std::io::Result<Self> {
53        let expanded = Self::maybe_expand_home_directory(path.as_ref());
54        Ok(Self(absolutize::absolutize(&expanded)?))
55    }
56
57    pub fn from_absolute_path_checked<P: AsRef<Path>>(path: P) -> std::io::Result<Self> {
58        let expanded = Self::maybe_expand_home_directory(path.as_ref());
59        if !expanded.is_absolute() {
60            return Err(std::io::Error::new(
61                std::io::ErrorKind::InvalidInput,
62                format!("path is not absolute: {}", path.as_ref().display()),
63            ));
64        }
65
66        Ok(Self(absolutize::absolutize_from(&expanded, Path::new("/"))))
67    }
68
69    pub fn current_dir() -> std::io::Result<Self> {
70        let current_dir = std::env::current_dir()?;
71        Ok(Self(absolutize::absolutize_from(
72            &current_dir,
73            &current_dir,
74        )))
75    }
76
77    /// Construct an absolute path from `path`, resolving relative paths against
78    /// the process current working directory.
79    pub fn relative_to_current_dir<P: AsRef<Path>>(path: P) -> std::io::Result<Self> {
80        Ok(Self::resolve_path_against_base(
81            path,
82            std::env::current_dir()?,
83        ))
84    }
85
86    pub fn join<P: AsRef<Path>>(&self, path: P) -> Self {
87        Self::resolve_path_against_base(path, &self.0)
88    }
89
90    pub fn canonicalize(&self) -> std::io::Result<Self> {
91        dunce::canonicalize(&self.0).map(Self)
92    }
93
94    pub fn parent(&self) -> Option<Self> {
95        self.0.parent().map(|p| {
96            debug_assert!(
97                p.is_absolute(),
98                "parent of AbsolutePathBuf must be absolute"
99            );
100            Self(p.to_path_buf())
101        })
102    }
103
104    pub fn ancestors(&self) -> impl Iterator<Item = Self> + '_ {
105        self.0.ancestors().map(|p| {
106            debug_assert!(
107                p.is_absolute(),
108                "ancestor of AbsolutePathBuf must be absolute"
109            );
110            Self(p.to_path_buf())
111        })
112    }
113
114    pub fn as_path(&self) -> &Path {
115        &self.0
116    }
117
118    pub fn into_path_buf(self) -> PathBuf {
119        self.0
120    }
121
122    pub fn to_path_buf(&self) -> PathBuf {
123        self.0.clone()
124    }
125
126    pub fn to_string_lossy(&self) -> std::borrow::Cow<'_, str> {
127        self.0.to_string_lossy()
128    }
129
130    pub fn display(&self) -> Display<'_> {
131        self.0.display()
132    }
133}
134
135/// Canonicalize a path when possible, but preserve the logical absolute path
136/// whenever canonicalization would rewrite it through a nested symlink.
137///
138/// Top-level system aliases such as macOS `/var -> /private/var` still remain
139/// canonicalized so existing runtime expectations around those paths stay
140/// stable. If the full path cannot be canonicalized, this returns the logical
141/// absolute path; use [`canonicalize_existing_preserving_symlinks`] for paths
142/// that must exist.
143pub fn canonicalize_preserving_symlinks(path: &Path) -> std::io::Result<PathBuf> {
144    let logical = AbsolutePathBuf::from_absolute_path(path)?.into_path_buf();
145    let preserve_logical_path = should_preserve_logical_path(&logical);
146    match dunce::canonicalize(path) {
147        Ok(canonical) if preserve_logical_path && canonical != logical => Ok(logical),
148        Ok(canonical) => Ok(canonical),
149        Err(_) => Ok(logical),
150    }
151}
152
153/// Canonicalize an existing path while preserving the logical absolute path
154/// whenever canonicalization would rewrite it through a nested symlink.
155///
156/// Unlike [`canonicalize_preserving_symlinks`], canonicalization failures are
157/// propagated so callers can reject invalid working directories early.
158pub fn canonicalize_existing_preserving_symlinks(path: &Path) -> std::io::Result<PathBuf> {
159    let logical = AbsolutePathBuf::from_absolute_path(path)?.into_path_buf();
160    let canonical = dunce::canonicalize(path)?;
161    if should_preserve_logical_path(&logical) && canonical != logical {
162        Ok(logical)
163    } else {
164        Ok(canonical)
165    }
166}
167
168fn should_preserve_logical_path(logical: &Path) -> bool {
169    logical.ancestors().any(|ancestor| {
170        let Ok(metadata) = std::fs::symlink_metadata(ancestor) else {
171            return false;
172        };
173        metadata.file_type().is_symlink() && ancestor.parent().and_then(Path::parent).is_some()
174    })
175}
176
177impl AsRef<Path> for AbsolutePathBuf {
178    fn as_ref(&self) -> &Path {
179        &self.0
180    }
181}
182
183impl std::ops::Deref for AbsolutePathBuf {
184    type Target = Path;
185
186    fn deref(&self) -> &Self::Target {
187        &self.0
188    }
189}
190
191impl From<AbsolutePathBuf> for PathBuf {
192    fn from(path: AbsolutePathBuf) -> Self {
193        path.into_path_buf()
194    }
195}
196
197/// Helpers for constructing absolute paths in tests.
198pub mod test_support {
199    use super::AbsolutePathBuf;
200    use std::path::Path;
201    use std::path::PathBuf;
202
203    /// Creates a platform-absolute [`PathBuf`] from a Unix-style absolute test path.
204    ///
205    /// On Windows, `/tmp/example` maps to `C:\tmp\example`.
206    pub fn test_path_buf(unix_path: &str) -> PathBuf {
207        if cfg!(windows) {
208            let mut path = PathBuf::from(r"C:\");
209            path.extend(
210                unix_path
211                    .trim_start_matches('/')
212                    .split('/')
213                    .filter(|segment| !segment.is_empty()),
214            );
215            path
216        } else {
217            PathBuf::from(unix_path)
218        }
219    }
220
221    /// Extension methods for converting paths into [`AbsolutePathBuf`] values in tests.
222    pub trait PathExt {
223        /// Converts an already absolute path into an [`AbsolutePathBuf`].
224        fn abs(&self) -> AbsolutePathBuf;
225    }
226
227    impl PathExt for Path {
228        #[expect(clippy::expect_used)]
229        fn abs(&self) -> AbsolutePathBuf {
230            AbsolutePathBuf::from_absolute_path_checked(self)
231                .expect("path should already be absolute")
232        }
233    }
234
235    /// Extension methods for converting path buffers into [`AbsolutePathBuf`] values in tests.
236    pub trait PathBufExt {
237        /// Converts an already absolute path buffer into an [`AbsolutePathBuf`].
238        fn abs(&self) -> AbsolutePathBuf;
239    }
240
241    impl PathBufExt for PathBuf {
242        fn abs(&self) -> AbsolutePathBuf {
243            self.as_path().abs()
244        }
245    }
246}
247
248impl TryFrom<&Path> for AbsolutePathBuf {
249    type Error = std::io::Error;
250
251    fn try_from(value: &Path) -> Result<Self, Self::Error> {
252        Self::from_absolute_path(value)
253    }
254}
255
256impl TryFrom<PathBuf> for AbsolutePathBuf {
257    type Error = std::io::Error;
258
259    fn try_from(value: PathBuf) -> Result<Self, Self::Error> {
260        Self::from_absolute_path(value)
261    }
262}
263
264impl TryFrom<&str> for AbsolutePathBuf {
265    type Error = std::io::Error;
266
267    fn try_from(value: &str) -> Result<Self, Self::Error> {
268        Self::from_absolute_path(value)
269    }
270}
271
272impl TryFrom<String> for AbsolutePathBuf {
273    type Error = std::io::Error;
274
275    fn try_from(value: String) -> Result<Self, Self::Error> {
276        Self::from_absolute_path(value)
277    }
278}
279
280thread_local! {
281    static ABSOLUTE_PATH_BASE: RefCell<Option<PathBuf>> = const { RefCell::new(None) };
282}
283
284/// Ensure this guard is held while deserializing `AbsolutePathBuf` values to
285/// provide a base path for resolving relative paths. Because this relies on
286/// thread-local storage, the deserialization must be single-threaded and
287/// occur on the same thread that created the guard.
288pub struct AbsolutePathBufGuard;
289
290impl AbsolutePathBufGuard {
291    pub fn new(base_path: &Path) -> Self {
292        ABSOLUTE_PATH_BASE.with(|cell| {
293            *cell.borrow_mut() = Some(base_path.to_path_buf());
294        });
295        Self
296    }
297}
298
299impl Drop for AbsolutePathBufGuard {
300    fn drop(&mut self) {
301        ABSOLUTE_PATH_BASE.with(|cell| {
302            *cell.borrow_mut() = None;
303        });
304    }
305}
306
307impl<'de> Deserialize<'de> for AbsolutePathBuf {
308    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
309    where
310        D: Deserializer<'de>,
311    {
312        let path = PathBuf::deserialize(deserializer)?;
313        ABSOLUTE_PATH_BASE.with(|cell| match cell.borrow().as_deref() {
314            Some(base) => Ok(Self::resolve_path_against_base(path, base)),
315            None if path.is_absolute() => {
316                Self::from_absolute_path(path).map_err(SerdeError::custom)
317            }
318            None => Err(SerdeError::custom(
319                "AbsolutePathBuf deserialized without a base path",
320            )),
321        })
322    }
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328    use crate::test_support::test_path_buf;
329    use pretty_assertions::assert_eq;
330    use std::fs;
331    use tempfile::tempdir;
332
333    #[test]
334    fn create_with_absolute_path_ignores_base_path() {
335        let base_dir = tempdir().expect("base dir");
336        let absolute_dir = tempdir().expect("absolute dir");
337        let base_path = base_dir.path();
338        let absolute_path = absolute_dir.path().join("file.txt");
339        let abs_path_buf =
340            AbsolutePathBuf::resolve_path_against_base(absolute_path.clone(), base_path);
341        assert_eq!(abs_path_buf.as_path(), absolute_path.as_path());
342    }
343
344    #[test]
345    fn from_absolute_path_checked_rejects_relative_path() {
346        let err = AbsolutePathBuf::from_absolute_path_checked("relative/path")
347            .expect_err("relative path should fail");
348
349        assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
350    }
351
352    #[test]
353    fn relative_path_is_resolved_against_base_path() {
354        let temp_dir = tempdir().expect("base dir");
355        let base_dir = temp_dir.path();
356        let abs_path_buf = AbsolutePathBuf::resolve_path_against_base("file.txt", base_dir);
357        assert_eq!(abs_path_buf.as_path(), base_dir.join("file.txt").as_path());
358    }
359
360    #[test]
361    fn relative_path_dots_are_normalized_against_base_path() {
362        let temp_dir = tempdir().expect("base dir");
363        let base_dir = temp_dir.path();
364        let abs_path_buf =
365            AbsolutePathBuf::resolve_path_against_base("./nested/../file.txt", base_dir);
366        assert_eq!(abs_path_buf.as_path(), base_dir.join("file.txt").as_path());
367    }
368
369    #[test]
370    fn canonicalize_returns_absolute_path_buf() {
371        let temp_dir = tempdir().expect("base dir");
372        fs::create_dir(temp_dir.path().join("one")).expect("create one dir");
373        fs::create_dir(temp_dir.path().join("two")).expect("create two dir");
374        fs::write(temp_dir.path().join("two").join("file.txt"), "").expect("write file");
375        let abs_path_buf =
376            AbsolutePathBuf::from_absolute_path(temp_dir.path().join("one/../two/./file.txt"))
377                .expect("absolute path");
378        assert_eq!(
379            abs_path_buf
380                .canonicalize()
381                .expect("path should canonicalize")
382                .as_path(),
383            dunce::canonicalize(temp_dir.path().join("two").join("file.txt"))
384                .expect("expected path should canonicalize")
385                .as_path()
386        );
387    }
388
389    #[test]
390    fn canonicalize_returns_error_for_missing_path() {
391        let temp_dir = tempdir().expect("base dir");
392        let abs_path_buf = AbsolutePathBuf::from_absolute_path(temp_dir.path().join("missing.txt"))
393            .expect("absolute path");
394
395        assert!(abs_path_buf.canonicalize().is_err());
396    }
397
398    #[test]
399    fn ancestors_returns_absolute_path_bufs() {
400        let abs_path_buf =
401            AbsolutePathBuf::from_absolute_path_checked(test_path_buf("/tmp/one/two"))
402                .expect("absolute path");
403
404        let ancestors = abs_path_buf
405            .ancestors()
406            .map(|path| path.to_path_buf())
407            .collect::<Vec<_>>();
408
409        let expected = vec![
410            test_path_buf("/tmp/one/two"),
411            test_path_buf("/tmp/one"),
412            test_path_buf("/tmp"),
413            test_path_buf("/"),
414        ];
415
416        assert_eq!(ancestors, expected);
417    }
418
419    #[test]
420    fn relative_to_current_dir_resolves_relative_path() -> std::io::Result<()> {
421        let current_dir = std::env::current_dir()?;
422        let abs_path_buf = AbsolutePathBuf::relative_to_current_dir("file.txt")?;
423        assert_eq!(
424            abs_path_buf.as_path(),
425            current_dir.join("file.txt").as_path()
426        );
427        Ok(())
428    }
429
430    #[test]
431    fn guard_used_in_deserialization() {
432        let temp_dir = tempdir().expect("base dir");
433        let base_dir = temp_dir.path();
434        let relative_path = "subdir/file.txt";
435        let abs_path_buf = {
436            let _guard = AbsolutePathBufGuard::new(base_dir);
437            serde_json::from_str::<AbsolutePathBuf>(&format!(r#""{relative_path}""#))
438                .expect("failed to deserialize")
439        };
440        assert_eq!(
441            abs_path_buf.as_path(),
442            base_dir.join(relative_path).as_path()
443        );
444    }
445
446    #[test]
447    fn home_directory_root_is_expanded_in_deserialization() {
448        let Some(home) = home_dir() else {
449            return;
450        };
451        let temp_dir = tempdir().expect("base dir");
452        let abs_path_buf = {
453            let _guard = AbsolutePathBufGuard::new(temp_dir.path());
454            serde_json::from_str::<AbsolutePathBuf>("\"~\"").expect("failed to deserialize")
455        };
456        assert_eq!(abs_path_buf.as_path(), home.as_path());
457    }
458
459    #[test]
460    fn home_directory_subpath_is_expanded_in_deserialization() {
461        let Some(home) = home_dir() else {
462            return;
463        };
464        let temp_dir = tempdir().expect("base dir");
465        let abs_path_buf = {
466            let _guard = AbsolutePathBufGuard::new(temp_dir.path());
467            serde_json::from_str::<AbsolutePathBuf>("\"~/code\"").expect("failed to deserialize")
468        };
469        assert_eq!(abs_path_buf.as_path(), home.join("code").as_path());
470    }
471
472    #[test]
473    fn home_directory_double_slash_is_expanded_in_deserialization() {
474        let Some(home) = home_dir() else {
475            return;
476        };
477        let temp_dir = tempdir().expect("base dir");
478        let abs_path_buf = {
479            let _guard = AbsolutePathBufGuard::new(temp_dir.path());
480            serde_json::from_str::<AbsolutePathBuf>("\"~//code\"").expect("failed to deserialize")
481        };
482        assert_eq!(abs_path_buf.as_path(), home.join("code").as_path());
483    }
484
485    #[cfg(unix)]
486    #[test]
487    fn canonicalize_preserving_symlinks_keeps_logical_symlink_path() {
488        let temp_dir = tempdir().expect("temp dir");
489        let real = temp_dir.path().join("real");
490        let link = temp_dir.path().join("link");
491        std::fs::create_dir_all(&real).expect("create real dir");
492        std::os::unix::fs::symlink(&real, &link).expect("create symlink");
493
494        let canonicalized =
495            canonicalize_preserving_symlinks(&link).expect("canonicalize preserving symlinks");
496
497        assert_eq!(canonicalized, link);
498    }
499
500    #[cfg(unix)]
501    #[test]
502    fn canonicalize_preserving_symlinks_keeps_logical_missing_child_under_symlink() {
503        let temp_dir = tempdir().expect("temp dir");
504        let real = temp_dir.path().join("real");
505        let link = temp_dir.path().join("link");
506        std::fs::create_dir_all(&real).expect("create real dir");
507        std::os::unix::fs::symlink(&real, &link).expect("create symlink");
508        let missing = link.join("missing.txt");
509
510        let canonicalized =
511            canonicalize_preserving_symlinks(&missing).expect("canonicalize preserving symlinks");
512
513        assert_eq!(canonicalized, missing);
514    }
515
516    #[test]
517    fn canonicalize_existing_preserving_symlinks_errors_for_missing_path() {
518        let temp_dir = tempdir().expect("temp dir");
519        let missing = temp_dir.path().join("missing");
520
521        let err = canonicalize_existing_preserving_symlinks(&missing)
522            .expect_err("missing path should fail canonicalization");
523
524        assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
525    }
526
527    #[cfg(unix)]
528    #[test]
529    fn canonicalize_existing_preserving_symlinks_keeps_logical_symlink_path() {
530        let temp_dir = tempdir().expect("temp dir");
531        let real = temp_dir.path().join("real");
532        let link = temp_dir.path().join("link");
533        std::fs::create_dir_all(&real).expect("create real dir");
534        std::os::unix::fs::symlink(&real, &link).expect("create symlink");
535
536        let canonicalized =
537            canonicalize_existing_preserving_symlinks(&link).expect("canonicalize symlink");
538
539        assert_eq!(canonicalized, link);
540    }
541
542    #[cfg(target_os = "windows")]
543    #[test]
544    fn home_directory_backslash_subpath_is_expanded_in_deserialization() {
545        let Some(home) = home_dir() else {
546            return;
547        };
548        let temp_dir = tempdir().expect("base dir");
549        let abs_path_buf = {
550            let _guard = AbsolutePathBufGuard::new(temp_dir.path());
551            let input =
552                serde_json::to_string(r#"~\code"#).expect("string should serialize as JSON");
553            serde_json::from_str::<AbsolutePathBuf>(&input).expect("is valid abs path")
554        };
555        assert_eq!(abs_path_buf.as_path(), home.join("code").as_path());
556    }
557
558    #[cfg(target_os = "windows")]
559    #[test]
560    fn canonicalize_preserving_symlinks_avoids_verbatim_prefixes() {
561        let temp_dir = tempdir().expect("temp dir");
562
563        let canonicalized =
564            canonicalize_preserving_symlinks(temp_dir.path()).expect("canonicalize");
565
566        assert_eq!(
567            canonicalized,
568            dunce::canonicalize(temp_dir.path()).expect("canonicalize temp dir")
569        );
570        assert!(
571            !canonicalized.to_string_lossy().starts_with(r"\\?\"),
572            "expected a non-verbatim Windows path, got {canonicalized:?}"
573        );
574    }
575}