1use base64::Engine;
6use codex_utils_absolute_path::AbsolutePathBuf;
7use schemars::JsonSchema;
8use serde::Deserialize;
9use serde::Deserializer;
10use serde::Serialize;
11use serde::Serializer;
12use std::fmt;
13use std::io;
14use std::path::Path;
15use std::path::PathBuf;
16use std::str::FromStr;
17use thiserror::Error;
18use ts_rs::TS;
19use url::Url;
20
21mod absolute_path_normalization;
22mod api_path_string;
23
24use absolute_path_normalization::path_uri_from_segments;
25
26pub use api_path_string::LegacyAppPathString;
27pub use api_path_string::LegacyAppPathStringError;
28
29pub const FILE_SCHEME: &str = "file";
30const BAD_PATH_URI_PREFIX: &str = "file:///%00/bad/path/";
31
32#[derive(Clone, Debug, PartialEq, Eq, Hash, TS)]
56#[ts(type = "string")]
57pub struct PathUri(Url);
58
59impl PathUri {
60 pub fn parse(uri: &str) -> Result<Self, PathUriParseError> {
62 Url::parse(uri)?.try_into()
63 }
64
65 pub fn from_abs_path(path: &AbsolutePathBuf) -> Self {
76 if let Ok(url) = Url::from_file_path(path.as_path())
77 && let Ok(uri) = Self::try_from(url)
78 {
79 return uri;
80 }
81
82 #[cfg(unix)]
83 let path_bytes = {
84 use std::os::unix::ffi::OsStrExt;
85 path.as_path().as_os_str().as_bytes().to_vec()
86 };
87 #[cfg(windows)]
88 let path_bytes = {
89 use std::os::windows::ffi::OsStrExt;
90 path.as_path()
91 .as_os_str()
92 .encode_wide()
93 .flat_map(u16::to_le_bytes)
94 .collect::<Vec<_>>()
95 };
96 Self::from_opaque_path_bytes(&path_bytes)
97 }
98
99 pub(crate) fn from_absolute_native_path(
101 path: &str,
102 convention: PathConvention,
103 ) -> Option<Self> {
104 match convention {
105 PathConvention::Posix => parse_posix_path(path),
106 PathConvention::Windows => parse_windows_path(path),
107 }
108 }
109
110 fn from_opaque_path_bytes(path_bytes: &[u8]) -> Self {
111 let encoded_path = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(path_bytes);
112 let Ok(uri) = Self::parse(&format!("{BAD_PATH_URI_PREFIX}{encoded_path}")) else {
113 unreachable!("URL-safe base64 always produces a valid fallback path URI");
114 };
115 uri
116 }
117
118 pub fn from_host_native_path(path: impl AsRef<Path>) -> io::Result<Self> {
124 let path = AbsolutePathBuf::from_absolute_path_checked(path)
125 .map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
126 Ok(Self::from_abs_path(&path))
127 }
128
129 pub fn encoded_path(&self) -> &str {
134 self.0.path()
135 }
136
137 fn opaque_fallback_bytes(&self) -> Option<Vec<u8>> {
138 decode_bad_path_uri(&self.0)
139 }
140
141 pub fn infer_path_convention(&self) -> Option<PathConvention> {
158 if let Some(path_bytes) = self.opaque_fallback_bytes() {
159 return infer_opaque_path_convention(&path_bytes);
160 }
161 if self.0.host_str().is_some() {
162 return Some(PathConvention::Windows);
163 }
164
165 let has_windows_drive = self
166 .0
167 .path_segments()
168 .and_then(|mut segments| segments.find(|segment| !segment.is_empty()))
169 .is_some_and(is_windows_drive_uri_segment);
170 if has_windows_drive {
171 Some(PathConvention::Windows)
172 } else {
173 Some(PathConvention::Posix)
174 }
175 }
176
177 pub fn inferred_native_path_string(&self) -> String {
184 self.infer_path_convention()
185 .and_then(|convention| LegacyAppPathString::from_path_uri(self, convention).ok())
186 .map(LegacyAppPathString::into_string)
187 .unwrap_or_else(|| self.to_string())
188 }
189
190 pub fn basename(&self) -> Option<String> {
196 if decode_bad_path_uri(&self.0).is_some() {
197 return None;
198 }
199
200 self.0
201 .path_segments()?
202 .rfind(|segment| !segment.is_empty())
203 .map(decode_uri_path)
204 }
205
206 pub fn to_path_buf(&self) -> PathBuf {
208 PathBuf::from(self.inferred_native_path_string())
209 }
210
211 pub fn parent(&self) -> Option<Self> {
216 if decode_bad_path_uri(&self.0).is_some() {
217 return None;
218 }
219
220 let convention = self.infer_path_convention()?;
221 let anchor_depth = usize::from(convention == PathConvention::Windows);
225 let depth = self
226 .0
227 .path_segments()?
228 .filter(|segment| !segment.is_empty())
229 .count();
230 if depth <= anchor_depth {
231 return None;
232 }
233 let mut url = self.0.clone();
234 {
235 let mut segments = match url.path_segments_mut() {
236 Ok(segments) => segments,
237 Err(()) => unreachable!("validated file URLs support hierarchical path segments"),
238 };
239 segments.pop_if_empty().pop();
240 }
241 Some(Self(url))
242 }
243
244 pub fn ancestors(&self) -> impl Iterator<Item = Self> {
246 std::iter::successors(Some(self.clone()), Self::parent)
247 }
248
249 pub fn starts_with(&self, base: &Self) -> bool {
257 if self == base {
258 return true;
259 }
260 if decode_bad_path_uri(&self.0).is_some() || decode_bad_path_uri(&base.0).is_some() {
261 return false;
262 }
263 if self.0.host_str() != base.0.host_str() {
264 return false;
265 }
266
267 let Some(path_segments) = containment_path_segments(
268 &self.0,
269 self.infer_path_convention()
270 .unwrap_or(PathConvention::Posix),
271 ) else {
272 return false;
273 };
274 let Some(base_segments) = containment_path_segments(
275 &base.0,
276 base.infer_path_convention()
277 .unwrap_or(PathConvention::Posix),
278 ) else {
279 return false;
280 };
281 path_segments.starts_with(&base_segments)
282 }
283
284 pub fn join(&self, path: &str) -> Result<Self, PathUriParseError> {
298 if path.contains('\0') {
299 return Err(PathUriParseError::InvalidFileUriPath {
300 path: path.to_string(),
301 });
302 }
303 if path.is_empty() {
304 return Ok(self.clone());
305 }
306 let convention =
307 self.infer_path_convention()
308 .ok_or_else(|| PathUriParseError::InvalidFileUriPath {
309 path: self.to_string(),
310 })?;
311 if let Some(absolute) = Self::from_absolute_native_path(path, convention) {
314 return Ok(absolute);
315 }
316 let path_bytes = path.as_bytes();
317 if convention == PathConvention::Windows
318 && matches!(path_bytes, [drive, b':', ..] if drive.is_ascii_alphabetic())
319 {
320 return Err(PathUriParseError::InvalidFileUriPath {
321 path: path.to_string(),
322 });
323 }
324 if decode_bad_path_uri(&self.0).is_some() {
325 return Err(PathUriParseError::InvalidFileUriPath {
326 path: self.to_string(),
327 });
328 }
329
330 let mut url = self.0.clone();
331 let anchor_depth = usize::from(convention == PathConvention::Windows);
332 let mut depth = url
333 .path_segments()
334 .map(|segments| segments.filter(|segment| !segment.is_empty()).count())
335 .unwrap_or_default();
336 let windows_root_relative = convention == PathConvention::Windows
337 && matches!(path_bytes, [b'\\' | b'/', rest @ ..] if !matches!(rest, [b'\\' | b'/', ..]));
338 {
339 let Ok(mut segments) = url.path_segments_mut() else {
340 unreachable!("validated file URLs support hierarchical path segments");
341 };
342 segments.pop_if_empty();
343 if windows_root_relative {
344 while depth > anchor_depth {
345 segments.pop();
346 depth -= 1;
347 }
348 }
349 let path = match convention {
350 PathConvention::Posix => path.to_string(),
351 PathConvention::Windows => path.replace('\\', "/"),
352 };
353 for component in path.split('/') {
354 match component {
355 "" | "." => {}
356 ".." => {
357 if depth > anchor_depth {
358 segments.pop();
359 depth -= 1;
360 }
361 }
362 component => {
363 segments.push(component);
364 depth += 1;
365 }
366 }
367 }
368 }
369 Self::try_from(url)
370 }
371
372 pub fn to_abs_path(&self) -> io::Result<AbsolutePathBuf> {
379 if self.infer_path_convention() != Some(PathConvention::native()) {
380 return Err(io::Error::new(
381 io::ErrorKind::InvalidInput,
382 PathUriParseError::InvalidFileUriPath {
383 path: self.to_string(),
384 },
385 ));
386 }
387 if let Some(path_bytes) = decode_bad_path_uri(&self.0) {
388 #[cfg(unix)]
389 let decoded_path = {
390 use std::os::unix::ffi::OsStringExt;
391 Some(std::path::PathBuf::from(std::ffi::OsString::from_vec(
392 path_bytes,
393 )))
394 };
395 #[cfg(windows)]
396 let decoded_path = {
397 use std::os::windows::ffi::OsStringExt;
398 path_bytes.len().is_multiple_of(2).then(|| {
399 let path_wide = path_bytes
400 .chunks_exact(2)
401 .map(|bytes| u16::from_le_bytes([bytes[0], bytes[1]]))
402 .collect::<Vec<_>>();
403 std::path::PathBuf::from(std::ffi::OsString::from_wide(&path_wide))
404 })
405 };
406 if let Some(decoded_path) = decoded_path
407 && let Ok(path) = AbsolutePathBuf::from_absolute_path_checked(decoded_path)
408 && Self::from_abs_path(&path).eq(self)
409 {
410 return Ok(path);
411 }
412
413 return Err(io::Error::new(
414 io::ErrorKind::InvalidInput,
415 PathUriParseError::InvalidFileUriPath {
416 path: self.to_string(),
417 },
418 ));
419 }
420
421 let path = self.0.to_file_path().map_err(|()| {
422 io::Error::new(
423 io::ErrorKind::InvalidInput,
424 PathUriParseError::InvalidFileUriPath {
425 path: self.to_string(),
426 },
427 )
428 })?;
429 AbsolutePathBuf::from_absolute_path_checked(path).map_err(|_| {
430 io::Error::new(
431 io::ErrorKind::InvalidInput,
432 PathUriParseError::InvalidFileUriPath {
433 path: self.to_string(),
434 },
435 )
436 })
437 }
438
439 pub fn to_url(&self) -> Url {
441 self.0.clone()
442 }
443}
444
445impl TryFrom<Url> for PathUri {
446 type Error = PathUriParseError;
447
448 fn try_from(url: Url) -> Result<Self, Self::Error> {
449 if url.scheme() != FILE_SCHEME {
450 return Err(PathUriParseError::UnsupportedScheme(
451 url.scheme().to_string(),
452 ));
453 }
454 validate_file_url(&url)?;
455 let url = without_localhost_authority(url);
456 Ok(Self(url))
457 }
458}
459
460impl TryFrom<String> for PathUri {
461 type Error = PathUriParseError;
462
463 fn try_from(uri: String) -> Result<Self, Self::Error> {
464 Self::parse(&uri)
465 }
466}
467
468impl From<AbsolutePathBuf> for PathUri {
469 fn from(p: AbsolutePathBuf) -> Self {
470 Self::from_abs_path(&p)
471 }
472}
473
474impl<'de> Deserialize<'de> for PathUri {
475 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
476 where
477 D: Deserializer<'de>,
478 {
479 let value = String::deserialize(deserializer)?;
480 Self::parse(&value).map_err(serde::de::Error::custom)
481 }
482}
483
484impl FromStr for PathUri {
485 type Err = PathUriParseError;
486
487 fn from_str(uri: &str) -> Result<Self, Self::Err> {
488 Self::parse(uri)
489 }
490}
491
492impl fmt::Display for PathUri {
493 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
494 self.0.fmt(f)
495 }
496}
497
498impl Serialize for PathUri {
499 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
500 where
501 S: Serializer,
502 {
503 serializer.serialize_str(self.0.as_str())
504 }
505}
506
507impl JsonSchema for PathUri {
508 fn schema_name() -> String {
509 "PathUri".to_string()
510 }
511
512 fn json_schema(generator: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema {
513 String::json_schema(generator)
514 }
515}
516
517fn without_localhost_authority(mut url: Url) -> Url {
519 if url.host_str() == Some("localhost") {
520 let Ok(()) = url.set_host(None) else {
521 unreachable!("validated file URLs can remove a localhost authority");
522 };
523 }
524 url
525}
526
527fn decode_uri_path(path: &str) -> String {
533 urlencoding::decode(path)
534 .map(std::borrow::Cow::into_owned)
535 .unwrap_or_else(|_| path.to_string())
536}
537
538fn decode_bad_path_uri(url: &Url) -> Option<Vec<u8>> {
540 let encoded_path = url.as_str().strip_prefix(BAD_PATH_URI_PREFIX)?;
541 if encoded_path.is_empty() || encoded_path.contains('/') {
542 return None;
543 }
544
545 let path_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
546 .decode(encoded_path)
547 .ok()?;
548 (base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&path_bytes) == encoded_path)
549 .then_some(path_bytes)
550}
551
552fn is_windows_drive_uri_segment(segment: &str) -> bool {
553 matches!(segment.as_bytes(), [drive, b':'] if drive.is_ascii_alphabetic())
554}
555
556fn containment_path_segments(url: &Url, convention: PathConvention) -> Option<Vec<&str>> {
557 let segments = url
558 .path_segments()?
559 .filter(|segment| !segment.is_empty())
560 .collect::<Vec<_>>();
561 (!segments.iter().any(|segment| {
562 urlencoding::decode_binary(segment.as_bytes())
563 .iter()
564 .any(|byte| *byte == b'/' || (convention == PathConvention::Windows && *byte == b'\\'))
565 }))
566 .then_some(segments)
567}
568
569fn infer_opaque_path_convention(path_bytes: &[u8]) -> Option<PathConvention> {
570 if path_bytes.starts_with(b"/") {
571 return Some(PathConvention::Posix);
572 }
573 if !path_bytes.len().is_multiple_of(2) {
574 return None;
575 }
576
577 let mut path_wide = path_bytes
578 .chunks_exact(2)
579 .map(|bytes| u16::from_le_bytes([bytes[0], bytes[1]]));
580 let first = path_wide.next()?;
581 let second = path_wide.next()?;
582 let has_drive = u8::try_from(first).is_ok_and(|drive| drive.is_ascii_alphabetic())
583 && second == u16::from(b':');
584 let has_unc_prefix = first == u16::from(b'\\') && second == u16::from(b'\\');
585 (has_drive || has_unc_prefix).then_some(PathConvention::Windows)
586}
587
588fn parse_posix_path(path: &str) -> Option<PathUri> {
589 let path = path.strip_prefix('/')?;
590 if path.contains('\0') {
591 return Some(PathUri::from_opaque_path_bytes(
592 format!("/{path}").as_bytes(),
593 ));
594 }
595 path_uri_from_segments(PathConvention::Posix, None, path.split('/'))
596}
597
598fn parse_windows_path(path: &str) -> Option<PathUri> {
599 let bytes = path.as_bytes();
600 let uses_namespace = matches!(
601 bytes,
602 [first, second, namespace @ (b'.' | b'?'), separator, ..]
603 if is_windows_separator_byte(*first)
604 && is_windows_separator_byte(*second)
605 && is_windows_separator_byte(*separator)
606 && matches!(*namespace, b'.' | b'?')
607 );
608 if uses_namespace || path.contains('\0') {
609 return Some(windows_opaque_path_uri(path));
610 }
611
612 if matches!(
613 bytes,
614 [drive, b':', separator, ..]
615 if drive.is_ascii_alphabetic() && is_windows_separator_byte(*separator)
616 ) {
617 return path_uri_from_segments(
618 PathConvention::Windows,
619 None,
620 std::iter::once(&path[..2]).chain(path[3..].split(is_windows_separator_char)),
621 );
622 }
623
624 if matches!(bytes, [first, second, ..]
625 if is_windows_separator_byte(*first) && is_windows_separator_byte(*second))
626 {
627 let mut components = path[2..].split(is_windows_separator_char);
628 let host = components.next().filter(|host| !host.is_empty())?;
629 let share = components.next().filter(|share| !share.is_empty())?;
630 return path_uri_from_segments(
631 PathConvention::Windows,
632 Some(host),
633 std::iter::once(share).chain(components),
634 )
635 .or_else(|| Some(windows_opaque_path_uri(path)));
636 }
637
638 None
639}
640
641fn windows_opaque_path_uri(path: &str) -> PathUri {
642 let path_bytes = path
643 .encode_utf16()
644 .flat_map(u16::to_le_bytes)
645 .collect::<Vec<_>>();
646 PathUri::from_opaque_path_bytes(&path_bytes)
647}
648
649fn is_windows_separator_char(character: char) -> bool {
650 matches!(character, '\\' | '/')
651}
652
653pub(crate) fn is_windows_separator_byte(character: u8) -> bool {
654 matches!(character, b'\\' | b'/')
655}
656
657fn validate_common_known_uri(url: &Url) -> Result<(), PathUriParseError> {
659 if !url.username().is_empty() || url.password().is_some() {
660 return Err(PathUriParseError::CredentialsNotAllowed);
661 }
662 if url.port().is_some() {
663 return Err(PathUriParseError::PortNotAllowed);
664 }
665 if url.query().is_some() {
666 return Err(PathUriParseError::QueryNotAllowed);
667 }
668 if url.fragment().is_some() {
669 return Err(PathUriParseError::FragmentNotAllowed);
670 }
671 Ok(())
672}
673
674fn validate_file_url(url: &Url) -> Result<(), PathUriParseError> {
676 validate_common_known_uri(url)?;
677 if urlencoding::decode_binary(url.path().as_bytes()).contains(&0)
680 && decode_bad_path_uri(url).is_none()
681 {
682 return Err(PathUriParseError::InvalidFileUriPath {
683 path: url.to_string(),
684 });
685 }
686 Ok(())
687}
688
689#[derive(Debug, Error, PartialEq, Eq)]
690pub enum PathUriParseError {
691 #[error("invalid URI: {0}")]
692 InvalidUri(#[from] url::ParseError),
693 #[error("unsupported path URI scheme `{0}`")]
694 UnsupportedScheme(String),
695 #[error("'{path}' is invalid on '{os}'", os = std::env::consts::OS)]
696 InvalidFileUriPath { path: String },
697 #[error("credentials are not allowed in path URIs")]
698 CredentialsNotAllowed,
699 #[error("ports are not allowed in path URIs")]
700 PortNotAllowed,
701 #[error("query parameters are not allowed in path URIs")]
702 QueryNotAllowed,
703 #[error("fragments are not allowed in path URIs")]
704 FragmentNotAllowed,
705 #[error("path `{0}` must be relative when joining a path URI")]
706 JoinPathMustBeRelative(String),
707}
708
709#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, TS)]
714#[serde(rename_all = "snake_case")]
715#[ts(rename_all = "snake_case")]
716pub enum PathConvention {
717 Posix,
718 Windows,
719}
720
721impl PathConvention {
722 #[cfg(windows)]
724 pub const fn native() -> Self {
725 Self::Windows
726 }
727
728 #[cfg(unix)]
730 pub const fn native() -> Self {
731 Self::Posix
732 }
733
734 pub fn path_segments(self, path: &str) -> impl DoubleEndedIterator<Item = &str> {
739 path.split(move |character| match self {
740 Self::Posix => character == '/',
741 Self::Windows => matches!(character, '/' | '\\'),
742 })
743 }
744}
745
746impl fmt::Display for PathConvention {
747 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
748 match self {
749 Self::Posix => f.write_str("POSIX"),
750 Self::Windows => f.write_str("Windows"),
751 }
752 }
753}
754
755#[cfg(test)]
756#[path = "tests.rs"]
757mod tests;