zerobox_utils_absolute_path/
lib.rs1use 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#[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 ¤t_dir,
73 ¤t_dir,
74 )))
75 }
76
77 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
135pub 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
153pub 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
197pub mod test_support {
199 use super::AbsolutePathBuf;
200 use std::path::Path;
201 use std::path::PathBuf;
202
203 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 pub trait PathExt {
223 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 pub trait PathBufExt {
237 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
284pub 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}