Skip to main content

acyclic_fs/
path.rs

1//! Strict portable absolute paths used at the volume boundary.
2
3use crate::model::VolumeLimits;
4use serde::{Deserialize, Serialize};
5use std::fmt;
6use thiserror::Error;
7
8/// Canonical absolute path with `/` separators and no relative components.
9#[derive(Clone, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
10#[serde(transparent)]
11pub struct PortablePath(String);
12
13impl PortablePath {
14    /// Canonical volume root.
15    pub const ROOT: &'static str = "/";
16
17    /// Parses and bounds one canonical absolute path.
18    ///
19    /// # Errors
20    ///
21    /// Rejects relative, repeated-separator, dot, parent, NUL, trailing
22    /// separator, or over-limit paths. Host adapters parse native paths before
23    /// this portable boundary.
24    pub fn parse(value: &str, limits: VolumeLimits) -> Result<Self, PathError> {
25        let path_bytes = u32::try_from(value.len()).map_err(|_| PathError::PathTooLong)?;
26        if path_bytes > limits.maximum_path_bytes {
27            return Err(PathError::PathTooLong);
28        }
29        if value == Self::ROOT {
30            return Ok(Self(value.to_owned()));
31        }
32        if !value.starts_with('/') {
33            return Err(PathError::NotAbsolute);
34        }
35        if value.ends_with('/') {
36            return Err(PathError::TrailingSeparator);
37        }
38
39        let mut depth = 0_u16;
40        for component in value.strip_prefix('/').unwrap_or(value).split('/') {
41            if component.is_empty() {
42                return Err(PathError::EmptyComponent);
43            }
44            if component == "." {
45                return Err(PathError::CurrentComponent);
46            }
47            if component == ".." {
48                return Err(PathError::ParentComponent);
49            }
50            if component.as_bytes().contains(&0) {
51                return Err(PathError::Nul);
52            }
53            let component_bytes =
54                u32::try_from(component.len()).map_err(|_| PathError::ComponentTooLong)?;
55            if component_bytes > limits.maximum_component_bytes {
56                return Err(PathError::ComponentTooLong);
57            }
58            depth = depth.checked_add(1).ok_or(PathError::TooDeep)?;
59            if depth > limits.maximum_path_depth {
60                return Err(PathError::TooDeep);
61            }
62        }
63        Ok(Self(value.to_owned()))
64    }
65
66    /// Borrows the canonical wire string.
67    #[must_use]
68    pub fn as_str(&self) -> &str {
69        &self.0
70    }
71
72    /// Returns whether this path is the same as or lies below `ancestor`.
73    #[must_use]
74    pub fn is_within(&self, ancestor: &Self) -> bool {
75        if ancestor.0 == Self::ROOT {
76            return true;
77        }
78        self.0 == ancestor.0
79            || self
80                .0
81                .strip_prefix(&ancestor.0)
82                .is_some_and(|suffix| suffix.starts_with('/'))
83    }
84
85    /// Returns the number of logical components.
86    #[must_use]
87    pub fn depth(&self) -> usize {
88        if self.0 == Self::ROOT {
89            0
90        } else {
91            self.0
92                .strip_prefix('/')
93                .unwrap_or(&self.0)
94                .split('/')
95                .count()
96        }
97    }
98
99    /// Iterates canonical components without allocating.
100    pub fn components(&self) -> impl Iterator<Item = &str> {
101        self.0
102            .strip_prefix('/')
103            .unwrap_or_default()
104            .split('/')
105            .filter(|component| !component.is_empty())
106    }
107}
108
109impl fmt::Debug for PortablePath {
110    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
111        formatter
112            .debug_tuple("PortablePath")
113            .field(&self.0)
114            .finish()
115    }
116}
117
118impl fmt::Display for PortablePath {
119    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
120        formatter.write_str(&self.0)
121    }
122}
123
124/// Portable path admission failures.
125#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
126pub enum PathError {
127    /// Path is not absolute.
128    #[error("portable path must be absolute")]
129    NotAbsolute,
130    /// A non-root path ends in `/`.
131    #[error("portable path must not end in a separator")]
132    TrailingSeparator,
133    /// Two separators produced an empty name.
134    #[error("portable path contains an empty component")]
135    EmptyComponent,
136    /// `.` is never canonical.
137    #[error("portable path contains a current-directory component")]
138    CurrentComponent,
139    /// `..` is never admitted.
140    #[error("portable path contains a parent component")]
141    ParentComponent,
142    /// NUL cannot be represented by host adapters.
143    #[error("portable path contains NUL")]
144    Nul,
145    /// Encoded path exceeds its configured byte bound.
146    #[error("portable path exceeds its byte bound")]
147    PathTooLong,
148    /// One encoded component exceeds its configured byte bound.
149    #[error("portable path component exceeds its byte bound")]
150    ComponentTooLong,
151    /// Component depth exceeds its configured bound.
152    #[error("portable path exceeds its depth bound")]
153    TooDeep,
154}
155
156#[cfg(test)]
157#[path = "tests/path.rs"]
158mod tests;