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::borrow::Cow;
8use std::cell::RefCell;
9use std::path::Display;
10use std::path::Path;
11use std::path::PathBuf;
12use ts_rs::TS;
13
14mod absolutize;
15
16#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, JsonSchema, TS)]
24pub struct AbsolutePathBuf(PathBuf);
25
26impl AbsolutePathBuf {
27 fn maybe_expand_home_directory(path: &Path) -> PathBuf {
28 if let Some(path_str) = path.to_str()
29 && let Some(home) = home_dir()
30 && let Some(rest) = path_str.strip_prefix('~')
31 {
32 if rest.is_empty() {
33 return home;
34 } else if let Some(rest) = rest.strip_prefix('/') {
35 return home.join(rest.trim_start_matches('/'));
36 } else if cfg!(windows)
37 && let Some(rest) = rest.strip_prefix('\\')
38 {
39 return home.join(rest.trim_start_matches('\\'));
40 }
41 }
42 path.to_path_buf()
43 }
44
45 pub fn resolve_path_against_base<P: AsRef<Path>, B: AsRef<Path>>(
46 path: P,
47 base_path: B,
48 ) -> Self {
49 let expanded = Self::maybe_expand_home_directory(path.as_ref());
50 let expanded = normalize_path_for_platform(&expanded);
51 let base_path = normalize_path_for_platform(base_path.as_ref());
52 Self(absolutize::absolutize_from(
53 expanded.as_ref(),
54 base_path.as_ref(),
55 ))
56 }
57
58 pub fn from_absolute_path<P: AsRef<Path>>(path: P) -> std::io::Result<Self> {
59 let expanded = Self::maybe_expand_home_directory(path.as_ref());
60 let expanded = normalize_path_for_platform(&expanded);
61 Ok(Self(absolutize::absolutize(expanded.as_ref())?))
62 }
63
64 pub fn from_absolute_path_checked<P: AsRef<Path>>(path: P) -> std::io::Result<Self> {
65 let expanded = Self::maybe_expand_home_directory(path.as_ref());
66 let expanded = normalize_path_for_platform(&expanded);
67 if !expanded.is_absolute() {
68 return Err(std::io::Error::new(
69 std::io::ErrorKind::InvalidInput,
70 format!("path is not absolute: {}", path.as_ref().display()),
71 ));
72 }
73
74 Ok(Self(absolutize::absolutize_from(
75 expanded.as_ref(),
76 Path::new("/"),
77 )))
78 }
79
80 pub fn current_dir() -> std::io::Result<Self> {
81 Self::from_absolute_path(std::env::current_dir()?)
82 }
83
84 pub fn relative_to_current_dir<P: AsRef<Path>>(path: P) -> std::io::Result<Self> {
87 Ok(Self::resolve_path_against_base(
88 path,
89 std::env::current_dir()?,
90 ))
91 }
92
93 pub fn join<P: AsRef<Path>>(&self, path: P) -> Self {
94 Self::resolve_path_against_base(path, &self.0)
95 }
96
97 pub fn canonicalize(&self) -> std::io::Result<Self> {
98 dunce::canonicalize(&self.0).map(Self)
99 }
100
101 pub fn parent(&self) -> Option<Self> {
102 self.0.parent().map(|p| {
103 debug_assert!(
104 p.is_absolute(),
105 "parent of AbsolutePathBuf must be absolute"
106 );
107 Self(p.to_path_buf())
108 })
109 }
110
111 pub fn ancestors(&self) -> impl Iterator<Item = Self> + '_ {
112 self.0.ancestors().map(|p| {
113 debug_assert!(
114 p.is_absolute(),
115 "ancestor of AbsolutePathBuf must be absolute"
116 );
117 Self(p.to_path_buf())
118 })
119 }
120
121 pub fn as_path(&self) -> &Path {
122 &self.0
123 }
124
125 pub fn into_path_buf(self) -> PathBuf {
126 self.0
127 }
128
129 pub fn to_path_buf(&self) -> PathBuf {
130 self.0.clone()
131 }
132
133 pub fn to_string_lossy(&self) -> std::borrow::Cow<'_, str> {
134 self.0.to_string_lossy()
135 }
136
137 pub fn display(&self) -> Display<'_> {
138 self.0.display()
139 }
140}
141
142fn normalize_path_for_platform(path: &Path) -> Cow<'_, Path> {
143 if cfg!(windows)
144 && let Some(path) = path.to_str()
145 && let Some(normalized) = normalize_windows_device_path(path)
146 {
147 return Cow::Owned(PathBuf::from(normalized));
148 }
149
150 Cow::Borrowed(path)
151}
152
153fn normalize_windows_device_path(path: &str) -> Option<String> {
154 if let Some(unc) = path.strip_prefix(r"\\?\UNC\") {
155 return Some(format!(r"\\{unc}"));
156 }
157 if let Some(unc) = path.strip_prefix(r"\\.\UNC\") {
158 return Some(format!(r"\\{unc}"));
159 }
160 if let Some(path) = path.strip_prefix(r"\\?\")
161 && is_windows_drive_absolute_path(path)
162 {
163 return Some(path.to_string());
164 }
165 if let Some(path) = path.strip_prefix(r"\\.\")
166 && is_windows_drive_absolute_path(path)
167 {
168 return Some(path.to_string());
169 }
170 None
171}
172
173fn is_windows_drive_absolute_path(path: &str) -> bool {
174 let bytes = path.as_bytes();
175 bytes.len() >= 3
176 && bytes[0].is_ascii_alphabetic()
177 && bytes[1] == b':'
178 && matches!(bytes[2], b'\\' | b'/')
179}
180
181pub fn canonicalize_preserving_symlinks(path: &Path) -> std::io::Result<PathBuf> {
190 let logical = AbsolutePathBuf::from_absolute_path(path)?.into_path_buf();
191 let preserve_logical_path = should_preserve_logical_path(&logical);
192 match dunce::canonicalize(path) {
193 Ok(canonical) if preserve_logical_path && canonical != logical => Ok(logical),
194 Ok(canonical) => Ok(canonical),
195 Err(_) => Ok(logical),
196 }
197}
198
199pub fn canonicalize_existing_preserving_symlinks(path: &Path) -> std::io::Result<PathBuf> {
205 let logical = AbsolutePathBuf::from_absolute_path(path)?.into_path_buf();
206 let canonical = dunce::canonicalize(path)?;
207 if should_preserve_logical_path(&logical) && canonical != logical {
208 Ok(logical)
209 } else {
210 Ok(canonical)
211 }
212}
213
214fn should_preserve_logical_path(logical: &Path) -> bool {
215 logical.ancestors().any(|ancestor| {
216 let Ok(metadata) = std::fs::symlink_metadata(ancestor) else {
217 return false;
218 };
219 metadata.file_type().is_symlink() && ancestor.parent().and_then(Path::parent).is_some()
220 })
221}
222
223impl AsRef<Path> for AbsolutePathBuf {
224 fn as_ref(&self) -> &Path {
225 &self.0
226 }
227}
228
229impl std::ops::Deref for AbsolutePathBuf {
230 type Target = Path;
231
232 fn deref(&self) -> &Self::Target {
233 &self.0
234 }
235}
236
237impl From<AbsolutePathBuf> for PathBuf {
238 fn from(path: AbsolutePathBuf) -> Self {
239 path.into_path_buf()
240 }
241}
242
243pub mod test_support {
245 use super::AbsolutePathBuf;
246 use std::path::Path;
247 use std::path::PathBuf;
248
249 pub fn test_path_buf(unix_path: &str) -> PathBuf {
253 if cfg!(windows) {
254 let mut path = PathBuf::from(r"C:\");
255 path.extend(
256 unix_path
257 .trim_start_matches('/')
258 .split('/')
259 .filter(|segment| !segment.is_empty()),
260 );
261 path
262 } else {
263 PathBuf::from(unix_path)
264 }
265 }
266
267 pub trait PathExt {
269 fn abs(&self) -> AbsolutePathBuf;
271 }
272
273 impl PathExt for Path {
274 #[expect(clippy::expect_used)]
275 fn abs(&self) -> AbsolutePathBuf {
276 AbsolutePathBuf::from_absolute_path_checked(self)
277 .expect("path should already be absolute")
278 }
279 }
280
281 pub trait PathBufExt {
283 fn abs(&self) -> AbsolutePathBuf;
285 }
286
287 impl PathBufExt for PathBuf {
288 fn abs(&self) -> AbsolutePathBuf {
289 self.as_path().abs()
290 }
291 }
292}
293
294impl TryFrom<&Path> for AbsolutePathBuf {
295 type Error = std::io::Error;
296
297 fn try_from(value: &Path) -> Result<Self, Self::Error> {
298 Self::from_absolute_path(value)
299 }
300}
301
302impl TryFrom<PathBuf> for AbsolutePathBuf {
303 type Error = std::io::Error;
304
305 fn try_from(value: PathBuf) -> Result<Self, Self::Error> {
306 Self::from_absolute_path(value)
307 }
308}
309
310impl TryFrom<&str> for AbsolutePathBuf {
311 type Error = std::io::Error;
312
313 fn try_from(value: &str) -> Result<Self, Self::Error> {
314 Self::from_absolute_path(value)
315 }
316}
317
318impl TryFrom<String> for AbsolutePathBuf {
319 type Error = std::io::Error;
320
321 fn try_from(value: String) -> Result<Self, Self::Error> {
322 Self::from_absolute_path(value)
323 }
324}
325
326thread_local! {
327 static ABSOLUTE_PATH_BASE: RefCell<Option<PathBuf>> = const { RefCell::new(None) };
328}
329
330pub struct AbsolutePathBufGuard;
335
336impl AbsolutePathBufGuard {
337 pub fn new(base_path: &Path) -> Self {
338 ABSOLUTE_PATH_BASE.with(|cell| {
339 *cell.borrow_mut() = Some(base_path.to_path_buf());
340 });
341 Self
342 }
343}
344
345impl Drop for AbsolutePathBufGuard {
346 fn drop(&mut self) {
347 ABSOLUTE_PATH_BASE.with(|cell| {
348 *cell.borrow_mut() = None;
349 });
350 }
351}
352
353impl<'de> Deserialize<'de> for AbsolutePathBuf {
354 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
355 where
356 D: Deserializer<'de>,
357 {
358 let path = PathBuf::deserialize(deserializer)?;
359 ABSOLUTE_PATH_BASE.with(|cell| match cell.borrow().as_deref() {
360 Some(base) => Ok(Self::resolve_path_against_base(path, base)),
361 None if path.is_absolute() => {
362 Self::from_absolute_path(path).map_err(SerdeError::custom)
363 }
364 None => Err(SerdeError::custom(
365 "AbsolutePathBuf deserialized without a base path",
366 )),
367 })
368 }
369}
370
371#[cfg(test)]
372mod tests {
373 use super::*;
374 use crate::test_support::test_path_buf;
375 use pretty_assertions::assert_eq;
376 use std::fs;
377 #[cfg(unix)]
378 use std::process::Command;
379 use tempfile::tempdir;
380
381 #[test]
382 fn create_with_absolute_path_ignores_base_path() {
383 let base_dir = tempdir().expect("base dir");
384 let absolute_dir = tempdir().expect("absolute dir");
385 let base_path = base_dir.path();
386 let absolute_path = absolute_dir.path().join("file.txt");
387 let abs_path_buf =
388 AbsolutePathBuf::resolve_path_against_base(absolute_path.clone(), base_path);
389 assert_eq!(abs_path_buf.as_path(), absolute_path.as_path());
390 }
391
392 #[cfg(unix)]
393 #[test]
394 fn from_absolute_path_does_not_read_current_dir_when_path_is_absolute() {
395 let status = Command::new(std::env::current_exe().expect("current test binary"))
396 .arg("from_absolute_path_with_removed_current_dir_child")
397 .arg("--ignored")
398 .env("CODEX_ABSOLUTE_PATH_REMOVED_CWD_CHILD", "1")
399 .status()
400 .expect("run child test");
401
402 assert!(status.success());
403 }
404
405 #[cfg(unix)]
406 #[test]
407 #[ignore]
408 fn from_absolute_path_with_removed_current_dir_child() {
409 if std::env::var_os("CODEX_ABSOLUTE_PATH_REMOVED_CWD_CHILD").is_none() {
410 return;
411 }
412
413 let original_cwd = std::env::current_dir().expect("original cwd");
414 let temp_dir = tempdir().expect("temp dir");
415 let removed_cwd = temp_dir.path().to_path_buf();
416 std::env::set_current_dir(&removed_cwd).expect("enter temp dir");
417 std::fs::remove_dir(&removed_cwd).expect("remove current dir");
418 std::env::current_dir().expect_err("current dir should be unavailable");
419
420 let path = AbsolutePathBuf::from_absolute_path(test_path_buf(
421 "/tmp/codex/../codex-home/plugins/cache",
422 ))
423 .expect("absolute path should not require current dir");
424
425 std::env::set_current_dir(original_cwd).expect("restore cwd");
426 assert_eq!(
427 path.as_path(),
428 test_path_buf("/tmp/codex-home/plugins/cache")
429 );
430 }
431
432 #[test]
433 fn from_absolute_path_checked_rejects_relative_path() {
434 let err = AbsolutePathBuf::from_absolute_path_checked("relative/path")
435 .expect_err("relative path should fail");
436
437 assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
438 }
439
440 #[test]
441 fn normalize_windows_device_path_strips_supported_verbatim_prefixes() {
442 assert_eq!(
443 normalize_windows_device_path(r"\\?\D:\c\x\worktrees\2508\swift-base"),
444 Some(r"D:\c\x\worktrees\2508\swift-base".to_string())
445 );
446 assert_eq!(
447 normalize_windows_device_path(r"\\.\D:\c\x\worktrees\2508\swift-base"),
448 Some(r"D:\c\x\worktrees\2508\swift-base".to_string())
449 );
450 assert_eq!(
451 normalize_windows_device_path(r"\\?\UNC\server\share\workspace"),
452 Some(r"\\server\share\workspace".to_string())
453 );
454 assert_eq!(
455 normalize_windows_device_path(r"\\.\UNC\server\share\workspace"),
456 Some(r"\\server\share\workspace".to_string())
457 );
458 assert_eq!(
459 normalize_windows_device_path(r"\\?\GLOBALROOT\Device"),
460 None
461 );
462 }
463
464 #[cfg(target_os = "windows")]
465 #[test]
466 fn from_absolute_path_strips_windows_verbatim_prefix() {
467 let path =
468 AbsolutePathBuf::from_absolute_path_checked(r"\\?\D:\c\x\worktrees\2508\swift-base")
469 .expect("verbatim drive path should be absolute");
470
471 assert_eq!(
472 path.as_path(),
473 Path::new(r"D:\c\x\worktrees\2508\swift-base")
474 );
475 }
476
477 #[test]
478 fn relative_path_is_resolved_against_base_path() {
479 let temp_dir = tempdir().expect("base dir");
480 let base_dir = temp_dir.path();
481 let abs_path_buf = AbsolutePathBuf::resolve_path_against_base("file.txt", base_dir);
482 assert_eq!(abs_path_buf.as_path(), base_dir.join("file.txt").as_path());
483 }
484
485 #[test]
486 fn relative_path_dots_are_normalized_against_base_path() {
487 let temp_dir = tempdir().expect("base dir");
488 let base_dir = temp_dir.path();
489 let abs_path_buf =
490 AbsolutePathBuf::resolve_path_against_base("./nested/../file.txt", base_dir);
491 assert_eq!(abs_path_buf.as_path(), base_dir.join("file.txt").as_path());
492 }
493
494 #[test]
495 fn canonicalize_returns_absolute_path_buf() {
496 let temp_dir = tempdir().expect("base dir");
497 fs::create_dir(temp_dir.path().join("one")).expect("create one dir");
498 fs::create_dir(temp_dir.path().join("two")).expect("create two dir");
499 fs::write(temp_dir.path().join("two").join("file.txt"), "").expect("write file");
500 let abs_path_buf =
501 AbsolutePathBuf::from_absolute_path(temp_dir.path().join("one/../two/./file.txt"))
502 .expect("absolute path");
503 assert_eq!(
504 abs_path_buf
505 .canonicalize()
506 .expect("path should canonicalize")
507 .as_path(),
508 dunce::canonicalize(temp_dir.path().join("two").join("file.txt"))
509 .expect("expected path should canonicalize")
510 .as_path()
511 );
512 }
513
514 #[test]
515 fn canonicalize_returns_error_for_missing_path() {
516 let temp_dir = tempdir().expect("base dir");
517 let abs_path_buf = AbsolutePathBuf::from_absolute_path(temp_dir.path().join("missing.txt"))
518 .expect("absolute path");
519
520 assert!(abs_path_buf.canonicalize().is_err());
521 }
522
523 #[test]
524 fn ancestors_returns_absolute_path_bufs() {
525 let abs_path_buf =
526 AbsolutePathBuf::from_absolute_path_checked(test_path_buf("/tmp/one/two"))
527 .expect("absolute path");
528
529 let ancestors = abs_path_buf
530 .ancestors()
531 .map(|path| path.to_path_buf())
532 .collect::<Vec<_>>();
533
534 let expected = vec![
535 test_path_buf("/tmp/one/two"),
536 test_path_buf("/tmp/one"),
537 test_path_buf("/tmp"),
538 test_path_buf("/"),
539 ];
540
541 assert_eq!(ancestors, expected);
542 }
543
544 #[test]
545 fn relative_to_current_dir_resolves_relative_path() -> std::io::Result<()> {
546 let current_dir = std::env::current_dir()?;
547 let abs_path_buf = AbsolutePathBuf::relative_to_current_dir("file.txt")?;
548 assert_eq!(
549 abs_path_buf.as_path(),
550 current_dir.join("file.txt").as_path()
551 );
552 Ok(())
553 }
554
555 #[test]
556 fn guard_used_in_deserialization() {
557 let temp_dir = tempdir().expect("base dir");
558 let base_dir = temp_dir.path();
559 let relative_path = "subdir/file.txt";
560 let abs_path_buf = {
561 let _guard = AbsolutePathBufGuard::new(base_dir);
562 serde_json::from_str::<AbsolutePathBuf>(&format!(r#""{relative_path}""#))
563 .expect("failed to deserialize")
564 };
565 assert_eq!(
566 abs_path_buf.as_path(),
567 base_dir.join(relative_path).as_path()
568 );
569 }
570
571 #[test]
572 fn home_directory_root_is_expanded_in_deserialization() {
573 let Some(home) = home_dir() else {
574 return;
575 };
576 let temp_dir = tempdir().expect("base dir");
577 let abs_path_buf = {
578 let _guard = AbsolutePathBufGuard::new(temp_dir.path());
579 serde_json::from_str::<AbsolutePathBuf>("\"~\"").expect("failed to deserialize")
580 };
581 assert_eq!(abs_path_buf.as_path(), home.as_path());
582 }
583
584 #[test]
585 fn home_directory_subpath_is_expanded_in_deserialization() {
586 let Some(home) = home_dir() else {
587 return;
588 };
589 let temp_dir = tempdir().expect("base dir");
590 let abs_path_buf = {
591 let _guard = AbsolutePathBufGuard::new(temp_dir.path());
592 serde_json::from_str::<AbsolutePathBuf>("\"~/code\"").expect("failed to deserialize")
593 };
594 assert_eq!(abs_path_buf.as_path(), home.join("code").as_path());
595 }
596
597 #[test]
598 fn home_directory_double_slash_is_expanded_in_deserialization() {
599 let Some(home) = home_dir() else {
600 return;
601 };
602 let temp_dir = tempdir().expect("base dir");
603 let abs_path_buf = {
604 let _guard = AbsolutePathBufGuard::new(temp_dir.path());
605 serde_json::from_str::<AbsolutePathBuf>("\"~//code\"").expect("failed to deserialize")
606 };
607 assert_eq!(abs_path_buf.as_path(), home.join("code").as_path());
608 }
609
610 #[cfg(unix)]
611 #[test]
612 fn canonicalize_preserving_symlinks_keeps_logical_symlink_path() {
613 let temp_dir = tempdir().expect("temp dir");
614 let real = temp_dir.path().join("real");
615 let link = temp_dir.path().join("link");
616 std::fs::create_dir_all(&real).expect("create real dir");
617 std::os::unix::fs::symlink(&real, &link).expect("create symlink");
618
619 let canonicalized =
620 canonicalize_preserving_symlinks(&link).expect("canonicalize preserving symlinks");
621
622 assert_eq!(canonicalized, link);
623 }
624
625 #[cfg(unix)]
626 #[test]
627 fn canonicalize_preserving_symlinks_keeps_logical_missing_child_under_symlink() {
628 let temp_dir = tempdir().expect("temp dir");
629 let real = temp_dir.path().join("real");
630 let link = temp_dir.path().join("link");
631 std::fs::create_dir_all(&real).expect("create real dir");
632 std::os::unix::fs::symlink(&real, &link).expect("create symlink");
633 let missing = link.join("missing.txt");
634
635 let canonicalized =
636 canonicalize_preserving_symlinks(&missing).expect("canonicalize preserving symlinks");
637
638 assert_eq!(canonicalized, missing);
639 }
640
641 #[test]
642 fn canonicalize_existing_preserving_symlinks_errors_for_missing_path() {
643 let temp_dir = tempdir().expect("temp dir");
644 let missing = temp_dir.path().join("missing");
645
646 let err = canonicalize_existing_preserving_symlinks(&missing)
647 .expect_err("missing path should fail canonicalization");
648
649 assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
650 }
651
652 #[cfg(unix)]
653 #[test]
654 fn canonicalize_existing_preserving_symlinks_keeps_logical_symlink_path() {
655 let temp_dir = tempdir().expect("temp dir");
656 let real = temp_dir.path().join("real");
657 let link = temp_dir.path().join("link");
658 std::fs::create_dir_all(&real).expect("create real dir");
659 std::os::unix::fs::symlink(&real, &link).expect("create symlink");
660
661 let canonicalized =
662 canonicalize_existing_preserving_symlinks(&link).expect("canonicalize symlink");
663
664 assert_eq!(canonicalized, link);
665 }
666
667 #[cfg(target_os = "windows")]
668 #[test]
669 fn home_directory_backslash_subpath_is_expanded_in_deserialization() {
670 let Some(home) = home_dir() else {
671 return;
672 };
673 let temp_dir = tempdir().expect("base dir");
674 let abs_path_buf = {
675 let _guard = AbsolutePathBufGuard::new(temp_dir.path());
676 let input =
677 serde_json::to_string(r#"~\code"#).expect("string should serialize as JSON");
678 serde_json::from_str::<AbsolutePathBuf>(&input).expect("is valid abs path")
679 };
680 assert_eq!(abs_path_buf.as_path(), home.join("code").as_path());
681 }
682
683 #[cfg(target_os = "windows")]
684 #[test]
685 fn canonicalize_preserving_symlinks_avoids_verbatim_prefixes() {
686 let temp_dir = tempdir().expect("temp dir");
687
688 let canonicalized =
689 canonicalize_preserving_symlinks(temp_dir.path()).expect("canonicalize");
690
691 assert_eq!(
692 canonicalized,
693 dunce::canonicalize(temp_dir.path()).expect("canonicalize temp dir")
694 );
695 assert!(
696 !canonicalized.to_string_lossy().starts_with(r"\\?\"),
697 "expected a non-verbatim Windows path, got {canonicalized:?}"
698 );
699 }
700}