Skip to main content

sim_table_core/
path.rs

1//! Table path segments, references, and canonical resolution.
2//!
3//! [`TablePath`] is the canonical absolute identity: it stores only validated
4//! table segments and formats with a leading `/` when it is rendered as a
5//! reference. [`TablePathRef`] is the parsed user/reference form. It can be
6//! absolute (`/a/b`) or relative (`../b`), can use `.` and `..` as traversal
7//! components, percent-escapes non-plain bytes, and resolves against a base
8//! [`TablePath`] without escaping above root.
9
10use std::fmt;
11
12/// Maximum number of path components a parsed reference can carry.
13pub const MAX_TABLE_PATH_SEGMENTS: usize = 128;
14
15/// Maximum byte length of one textual path reference.
16pub const MAX_TABLE_PATH_TEXT_BYTES: usize = 4096;
17
18/// Whether `name` is a legal single table path segment.
19///
20/// This is the exact predicate `sim-table-db` enforces in its `child_path`
21/// check: a segment is illegal when it is empty, the relative `.`/`..` markers,
22/// or contains a path separator (`/` or `\`). Everything else is legal.
23pub fn is_legal_table_segment(name: &str) -> bool {
24    !(name.is_empty() || name == "." || name == ".." || name.contains('/') || name.contains('\\'))
25}
26
27/// A validated, slash-joinable sequence of table path segments.
28#[derive(Clone, Debug, Default, PartialEq, Eq)]
29pub struct TablePath {
30    segments: Vec<String>,
31}
32
33impl TablePath {
34    /// Create an empty path.
35    pub fn new() -> Self {
36        Self::default()
37    }
38
39    /// Create the root path.
40    pub fn root() -> Self {
41        Self::new()
42    }
43
44    /// Build a canonical path from validated segments.
45    pub fn from_segments<I, S>(segments: I) -> Result<Self, TablePathError>
46    where
47        I: IntoIterator<Item = S>,
48        S: AsRef<str>,
49    {
50        let mut path = Self::new();
51        for segment in segments {
52            path.push(segment.as_ref())?;
53        }
54        Ok(path)
55    }
56
57    /// Parse an absolute textual path reference into a canonical path.
58    pub fn parse_absolute(input: &str) -> Result<Self, TablePathRefError> {
59        let reference = TablePathRef::parse(input)?;
60        if !reference.is_absolute() {
61            return Err(TablePathRefError::ExpectedAbsolute);
62        }
63        reference.resolve(&Self::root())
64    }
65
66    /// The accumulated segments, in order.
67    pub fn segments(&self) -> &[String] {
68        &self.segments
69    }
70
71    /// Whether this is the root path.
72    pub fn is_root(&self) -> bool {
73        self.segments.is_empty()
74    }
75
76    /// Append `segment`, validating it with [`is_legal_table_segment`].
77    pub fn push(&mut self, segment: &str) -> Result<(), TablePathError> {
78        if !is_legal_table_segment(segment) {
79            return Err(TablePathError::IllegalSegment(segment.to_owned()));
80        }
81        if self.segments.len() == MAX_TABLE_PATH_SEGMENTS {
82            return Err(TablePathError::TooManySegments {
83                limit: MAX_TABLE_PATH_SEGMENTS,
84            });
85        }
86        self.segments.push(segment.to_owned());
87        Ok(())
88    }
89
90    /// Join the segments with `/`.
91    pub fn join(&self) -> String {
92        self.segments.join("/")
93    }
94
95    /// Format this canonical path as an absolute escaped path reference.
96    pub fn to_absolute_reference(&self) -> String {
97        TablePathRef::absolute(self).to_reference_string()
98    }
99
100    /// Resolve `reference` against this canonical path.
101    pub fn resolve(&self, reference: &TablePathRef) -> Result<Self, TablePathRefError> {
102        reference.resolve(self)
103    }
104}
105
106impl fmt::Display for TablePath {
107    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
108        f.write_str(&self.to_absolute_reference())
109    }
110}
111
112/// Why a [`TablePath`] operation failed.
113#[derive(Clone, Debug, PartialEq, Eq)]
114pub enum TablePathError {
115    /// The given segment did not satisfy [`is_legal_table_segment`].
116    IllegalSegment(String),
117    /// The path exceeds [`MAX_TABLE_PATH_SEGMENTS`].
118    TooManySegments {
119        /// The configured segment limit.
120        limit: usize,
121    },
122}
123
124/// One component in a textual table path reference.
125#[derive(Clone, Debug, PartialEq, Eq)]
126pub enum TablePathRefPart {
127    /// A validated path segment.
128    Segment(String),
129    /// The current path marker, `.`.
130    Current,
131    /// The parent path marker, `..`.
132    Parent,
133}
134
135/// A parsed absolute or relative table path reference.
136#[derive(Clone, Debug, PartialEq, Eq)]
137pub struct TablePathRef {
138    absolute: bool,
139    parts: Vec<TablePathRefPart>,
140}
141
142impl TablePathRef {
143    /// Create a path reference from already separated components.
144    pub fn new(absolute: bool, parts: Vec<TablePathRefPart>) -> Result<Self, TablePathRefError> {
145        if parts.len() > MAX_TABLE_PATH_SEGMENTS {
146            return Err(TablePathRefError::TooManySegments {
147                limit: MAX_TABLE_PATH_SEGMENTS,
148            });
149        }
150        for part in &parts {
151            if let TablePathRefPart::Segment(segment) = part
152                && !is_legal_table_segment(segment)
153            {
154                return Err(TablePathRefError::IllegalSegment(segment.clone()));
155            }
156        }
157        Ok(Self { absolute, parts })
158    }
159
160    /// Return a relative reference to the current path.
161    pub fn current() -> Self {
162        Self {
163            absolute: false,
164            parts: vec![TablePathRefPart::Current],
165        }
166    }
167
168    /// Return an absolute reference for `path`.
169    pub fn absolute(path: &TablePath) -> Self {
170        Self {
171            absolute: true,
172            parts: path
173                .segments()
174                .iter()
175                .cloned()
176                .map(TablePathRefPart::Segment)
177                .collect(),
178        }
179    }
180
181    /// Parse an absolute or relative textual path reference.
182    pub fn parse(input: &str) -> Result<Self, TablePathRefError> {
183        if input.is_empty() {
184            return Err(TablePathRefError::EmptyReference);
185        }
186        if input.len() > MAX_TABLE_PATH_TEXT_BYTES {
187            return Err(TablePathRefError::ReferenceTooLong {
188                limit: MAX_TABLE_PATH_TEXT_BYTES,
189            });
190        }
191        if input.as_bytes().contains(&b'\\') {
192            return Err(TablePathRefError::AmbiguousSeparator('\\'));
193        }
194
195        let absolute = input.starts_with('/');
196        let body = if absolute { &input[1..] } else { input };
197        if body.is_empty() {
198            return if absolute {
199                Self::new(true, Vec::new())
200            } else {
201                Err(TablePathRefError::EmptyReference)
202            };
203        }
204
205        let mut parts = Vec::new();
206        for raw in body.split('/') {
207            if raw.is_empty() {
208                return Err(TablePathRefError::EmptySegment);
209            }
210            let segment = decode_segment(raw)?;
211            let part = match segment.as_str() {
212                "." => TablePathRefPart::Current,
213                ".." => TablePathRefPart::Parent,
214                _ if is_legal_table_segment(&segment) => TablePathRefPart::Segment(segment),
215                _ => return Err(TablePathRefError::IllegalSegment(segment)),
216            };
217            parts.push(part);
218        }
219        Self::new(absolute, parts)
220    }
221
222    /// Whether this reference starts at root.
223    pub fn is_absolute(&self) -> bool {
224        self.absolute
225    }
226
227    /// The parsed reference components.
228    pub fn parts(&self) -> &[TablePathRefPart] {
229        &self.parts
230    }
231
232    /// Resolve this reference against `base`, returning a canonical path.
233    pub fn resolve(&self, base: &TablePath) -> Result<TablePath, TablePathRefError> {
234        let mut segments = if self.absolute {
235            Vec::new()
236        } else {
237            base.segments.clone()
238        };
239        for part in &self.parts {
240            match part {
241                TablePathRefPart::Current => {}
242                TablePathRefPart::Parent => {
243                    if segments.pop().is_none() {
244                        return Err(TablePathRefError::RootEscape);
245                    }
246                }
247                TablePathRefPart::Segment(segment) => {
248                    if segments.len() == MAX_TABLE_PATH_SEGMENTS {
249                        return Err(TablePathRefError::TooManySegments {
250                            limit: MAX_TABLE_PATH_SEGMENTS,
251                        });
252                    }
253                    segments.push(segment.clone());
254                }
255            }
256        }
257        Ok(TablePath { segments })
258    }
259
260    /// Format this reference with percent-escaped segments and `/` separators.
261    pub fn to_reference_string(&self) -> String {
262        if self.absolute && self.parts.is_empty() {
263            return "/".to_owned();
264        }
265        if !self.absolute && self.parts.is_empty() {
266            return ".".to_owned();
267        }
268
269        let mut out = String::new();
270        if self.absolute {
271            out.push('/');
272        }
273        for (index, part) in self.parts.iter().enumerate() {
274            if index > 0 {
275                out.push('/');
276            }
277            match part {
278                TablePathRefPart::Segment(segment) => out.push_str(&encode_segment(segment)),
279                TablePathRefPart::Current => out.push('.'),
280                TablePathRefPart::Parent => out.push_str(".."),
281            }
282        }
283        out
284    }
285}
286
287impl fmt::Display for TablePathRef {
288    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
289        f.write_str(&self.to_reference_string())
290    }
291}
292
293/// Why parsing or resolving a [`TablePathRef`] failed.
294#[derive(Clone, Debug, PartialEq, Eq)]
295pub enum TablePathRefError {
296    /// The textual reference was empty.
297    EmptyReference,
298    /// A separator created an empty path component.
299    EmptySegment,
300    /// The textual reference used a separator other than `/`.
301    AmbiguousSeparator(char),
302    /// A percent escape was incomplete or contained a non-hex digit.
303    BadEscape {
304        /// The byte index within the raw segment.
305        index: usize,
306    },
307    /// Percent escapes did not decode to UTF-8.
308    InvalidUtf8Escape,
309    /// The decoded component is not a legal table segment.
310    IllegalSegment(String),
311    /// The reference exceeds [`MAX_TABLE_PATH_SEGMENTS`].
312    TooManySegments {
313        /// The configured segment limit.
314        limit: usize,
315    },
316    /// The textual reference exceeds [`MAX_TABLE_PATH_TEXT_BYTES`].
317    ReferenceTooLong {
318        /// The configured byte limit.
319        limit: usize,
320    },
321    /// An absolute path was required.
322    ExpectedAbsolute,
323    /// Resolving `..` would move above root.
324    RootEscape,
325}
326
327fn decode_segment(raw: &str) -> Result<String, TablePathRefError> {
328    let bytes = raw.as_bytes();
329    let mut out = Vec::with_capacity(bytes.len());
330    let mut index = 0;
331    while index < bytes.len() {
332        if bytes[index] == b'%' {
333            if index + 2 >= bytes.len() {
334                return Err(TablePathRefError::BadEscape { index });
335            }
336            let high = hex_value(bytes[index + 1]).ok_or(TablePathRefError::BadEscape { index })?;
337            let low = hex_value(bytes[index + 2]).ok_or(TablePathRefError::BadEscape { index })?;
338            out.push((high << 4) | low);
339            index += 3;
340        } else {
341            out.push(bytes[index]);
342            index += 1;
343        }
344    }
345    String::from_utf8(out).map_err(|_| TablePathRefError::InvalidUtf8Escape)
346}
347
348fn encode_segment(segment: &str) -> String {
349    let mut out = String::new();
350    for byte in segment.bytes() {
351        if is_unreserved_reference_byte(byte) {
352            out.push(char::from(byte));
353        } else {
354            out.push('%');
355            out.push(HEX[(byte >> 4) as usize] as char);
356            out.push(HEX[(byte & 0x0F) as usize] as char);
357        }
358    }
359    out
360}
361
362fn hex_value(byte: u8) -> Option<u8> {
363    match byte {
364        b'0'..=b'9' => Some(byte - b'0'),
365        b'a'..=b'f' => Some(byte - b'a' + 10),
366        b'A'..=b'F' => Some(byte - b'A' + 10),
367        _ => None,
368    }
369}
370
371fn is_unreserved_reference_byte(byte: u8) -> bool {
372    byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~')
373}
374
375const HEX: &[u8; 16] = b"0123456789ABCDEF";