Skip to main content

codex_utils_path_uri/
lib.rs

1//! Typed, immutable `file:` URIs with cross-platform path inspection.
2//!
3//! See [`PathUri`] for scheme, normalization, and serialization behavior.
4
5use 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/// An immutable, cross-platform representation of a `file:` URI.
33///
34/// Only the `file:` scheme is currently accepted. Construction validates the
35/// URL, and the URI cannot be mutated after construction. [`Self::basename`],
36/// [`Self::parent`], and [`Self::join`] operate on URI path segments without
37/// interpreting them using the operating system running Codex. Fallback URIs
38/// created by [`Self::from_abs_path`] are opaque to these lexical operations.
39///
40/// `file:` paths retain their URI spelling so they can be parsed independently
41/// of the current host. A local POSIX `file:` URI can also retain
42/// percent-encoded non-UTF-8 bytes for lossless native round trips.
43///
44/// Like [VS Code resources], path operations use `/` URI separators on every
45/// host. Lexical path operations preserve a URL authority without interpreting
46/// Windows drive or UNC roots from path text. Native path normalization,
47/// filesystem aliases, symlinks, case sensitivity, and Unicode normalization
48/// are not resolved.
49///
50/// Serde represents a `PathUri` as its canonical URI string. Deserialization
51/// accepts only valid `file:` URI strings. These strings round-trip through
52/// their canonical URL form, including encoded non-UTF-8 path bytes.
53///
54/// [VS Code resources]: https://github.com/microsoft/vscode/blob/main/src/vs/base/common/resources.ts
55#[derive(Clone, Debug, PartialEq, Eq, Hash, TS)]
56#[ts(type = "string")]
57pub struct PathUri(Url);
58
59impl PathUri {
60    /// Parses and validates a `file:` URI.
61    pub fn parse(uri: &str) -> Result<Self, PathUriParseError> {
62        Url::parse(uri)?.try_into()
63    }
64
65    /// Converts an absolute path on the current host to a `file:` URI.
66    ///
67    /// Paths without a valid URI representation are replaced by
68    /// `file:///%00/bad/path/<base64>`, where `<base64>` is the URL-safe, unpadded
69    /// encoding of the original path (Unix bytes or Windows UTF-16LE). This
70    /// includes paths containing nulls and, on Windows, unsupported prefix
71    /// kinds such as device and generic verbatim namespaces, non-Unicode path
72    /// or UNC components, and UNC server names that are not valid URL hosts.
73    /// The encoded null reserves a URI namespace that cannot collide with a
74    /// real path on Unix or Windows.
75    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    /// Parses an absolute native path using the specified path convention.
100    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    /// Converts a path on the current host to a `file:` URI.
119    ///
120    /// Relative paths are reported as invalid input. Absolute paths without a
121    /// valid URI representation use the fallback documented on
122    /// [`Self::from_abs_path`].
123    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    /// Returns the percent-encoded URI path.
130    ///
131    /// The URL authority is not included. For example,
132    /// `file://server/share/file.rs` has the path `/share/file.rs`.
133    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    /// Infers the native path convention represented by this URI.
142    ///
143    /// A URI authority is treated as a Windows UNC host, and a leading
144    /// drive-letter segment such as `C:` is treated as a Windows drive. All
145    /// other ordinary file URIs are treated as POSIX paths. This deliberately
146    /// classifies `file:///C:/src` as Windows even though `/C:/src` is also a
147    /// valid POSIX path. In practice, POSIX paths with a drive-shaped first
148    /// component are rare enough that recognizing foreign Windows paths is the
149    /// more useful default.
150    ///
151    /// Opaque fallback URIs are inspected for an absolute POSIX byte prefix or
152    /// an absolute Windows UTF-16LE prefix. `None` is returned when their
153    /// payload does not identify either convention.
154    ///
155    /// TODO(anp): Once `PathUri` carries an environment identifier, prefer the
156    /// environment's declared convention over this spelling-based heuristic.
157    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    /// Renders this URI using the native path syntax inferred from its shape.
178    ///
179    /// This is independent of the current host: a Windows URI renders with
180    /// Windows separators on every host. If the convention cannot be inferred
181    /// or the URI cannot be represented using that convention, the canonical
182    /// URI string is returned instead.
183    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    /// Returns the decoded final URI path segment, or `None` for the URI root
191    /// or an opaque fallback URI created by [`Self::from_abs_path`].
192    ///
193    /// If the segment contains non-UTF-8 encoded bytes, its percent-encoded
194    /// spelling is returned instead.
195    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    /// Renders this URI as a path-flavored string using its inferred convention.
207    pub fn to_path_buf(&self) -> PathBuf {
208        PathBuf::from(self.inferred_native_path_string())
209    }
210
211    /// Returns the lexical parent without crossing the inferred native path root.
212    ///
213    /// POSIX `/`, Windows drive roots, Windows UNC share roots, and opaque fallback
214    /// URIs created by [`Self::from_abs_path`] have no parent.
215    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        // In URI form, both a Windows drive root (`file:///C:`) and a UNC share root
222        // (`file://server/share`) retain one non-empty path segment. Keep that segment as the
223        // anchor so parent traversal cannot produce a URI that is not an absolute Windows path.
224        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    /// Returns this URI and each lexical parent up to its inferred native path root.
245    pub fn ancestors(&self) -> impl Iterator<Item = Self> {
246        std::iter::successors(Some(self.clone()), Self::parent)
247    }
248
249    /// Returns true when this URI is lexically equal to or below `base`.
250    ///
251    /// Containment is computed using URI authority and path-segment boundaries,
252    /// without consulting the host filesystem. Percent-encoded native path
253    /// separators fail closed because native path conversion may interpret them
254    /// as segment boundaries. Opaque fallback URIs created by
255    /// [`Self::from_abs_path`] only contain themselves.
256    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    /// Lexically resolves native absolute or relative path text against this URI.
285    ///
286    /// Path text is interpreted using the POSIX or Windows convention inferred
287    /// from the base URI. An absolute path replaces the base URI's path, while a
288    /// relative path is appended lexically. Windows root-relative paths retain
289    /// the base drive or UNC share, while drive-relative paths are rejected.
290    /// Empty and `.` segments are ignored, while `..` removes one segment
291    /// without escaping the POSIX root, Windows drive, or UNC share. Literal
292    /// `%`, `?`, and `#` characters are percent-encoded as filename text. Paths
293    /// containing a null character are rejected because they cannot be safely
294    /// converted to native paths.
295    /// Opaque fallback URIs created by [`Self::from_abs_path`] reject non-empty
296    /// joins.
297    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        // An absolute native path is already fully resolved, so replace the base URI's main path
312        // instead of appending it.
313        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    /// Converts this file URI to a path using the current host's path rules.
373    ///
374    /// The URI's inferred path convention must match the current host. Conversion should succeed
375    /// when the URI was created from an [`AbsolutePathBuf`] on the current host, including fallback
376    /// URIs created by [`Self::from_abs_path`]. Foreign conventions are rejected rather than being
377    /// projected onto a syntactically valid but unrelated host path.
378    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    /// Returns a clone of the canonical URL.
440    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
517/// Removes the local `localhost` alias while retaining non-local UNC authority.
518fn 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
527/// Percent-decodes a URI path when it is valid UTF-8.
528///
529/// `file:` URLs may contain encoded non-UTF-8 bytes. In that case the encoded
530/// spelling remains available for lexical inspection while the original `Url`
531/// is retained for lossless native conversion.
532fn 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
538/// Returns the original platform path bytes from a canonical bad-path URI.
539fn 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, /*host*/ 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            /*host*/ 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
657/// Rejects URI metadata that has no defined meaning for `file:` URIs.
658fn 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
674/// Applies the common URI checks plus `file:` path-byte restrictions.
675fn validate_file_url(url: &Url) -> Result<(), PathUriParseError> {
676    validate_common_known_uri(url)?;
677    // `Url` accepts `%00`, but native path APIs use null as a terminator and
678    // `Url::to_file_path` cannot represent a decoded null byte.
679    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/// Path syntax used to render a [`PathUri`] as an operating-system path.
710///
711/// This describes path grammar rather than a specific operating system because
712/// Linux and macOS share the POSIX representation relevant here.
713#[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    /// Returns the path convention used by the current process.
723    #[cfg(windows)]
724    pub const fn native() -> Self {
725        Self::Windows
726    }
727
728    /// Returns the path convention used by the current process.
729    #[cfg(unix)]
730    pub const fn native() -> Self {
731        Self::Posix
732    }
733
734    /// Splits absolute or relative native path text into lexical segments.
735    ///
736    /// This does not validate the path or require it to be absolute. POSIX paths split on `/`,
737    /// while Windows paths split on both `\\` and `/`. Empty segments are retained.
738    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;