Skip to main content

haz_domain/path/
segment.rs

1//! `PathSegment`: a single validated component of a path.
2//!
3//! A path segment is the smallest unit between `/` separators in a
4//! [`crate::path::HazPath`]. Per `PATH-002`, segments cannot be
5//! empty, cannot equal `.` or `..`, cannot contain `/`, and cannot
6//! contain a codepoint whose Unicode General Category is one of
7//! `Cc`, `Cf`, `Cn`, `Co`, `Zl`, or `Zp`. The forbidden categories
8//! cover all control characters (ASCII and C1), all format
9//! characters (zero-widths, BOM, bidirectional overrides, soft
10//! hyphen), line / paragraph separators, unassigned codepoints,
11//! and private-use codepoints.
12
13use std::fmt;
14
15use nutype::nutype;
16use snafu::{Snafu, ensure};
17use unicode_general_category::{GeneralCategory, get_general_category};
18
19/// Categories of Unicode codepoints forbidden inside a path segment.
20///
21/// Each variant corresponds to a Unicode General Category (or a
22/// pair of related categories) that `PATH-002` rejects.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
24pub enum ForbiddenCategory {
25    /// `Cc` (Other, Control). ASCII controls (`U+0000`..`U+001F`,
26    /// `U+007F`) and C1 controls (`U+0080`..`U+009F`).
27    Control,
28    /// `Cf` (Other, Format). Zero-width characters
29    /// (`U+200B`..`U+200D`, `U+2060`, `U+FEFF`), bidirectional
30    /// overrides (`U+202A`..`U+202E`, `U+2066`..`U+2069`), the
31    /// soft hyphen (`U+00AD`), and other format codepoints.
32    Format,
33    /// `Zl` (Separator, Line) `U+2028` or `Zp` (Separator,
34    /// Paragraph) `U+2029`.
35    LineSeparator,
36    /// `Co` (Other, Private-Use).
37    PrivateUse,
38    /// `Cn` (Other, Unassigned).
39    Unassigned,
40}
41
42impl fmt::Display for ForbiddenCategory {
43    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
44        let s = match self {
45            ForbiddenCategory::Control => "control",
46            ForbiddenCategory::Format => "format",
47            ForbiddenCategory::LineSeparator => "line/paragraph separator",
48            ForbiddenCategory::PrivateUse => "private-use",
49            ForbiddenCategory::Unassigned => "unassigned",
50        };
51        f.write_str(s)
52    }
53}
54
55/// Error produced when constructing a [`PathSegment`].
56///
57/// Each variant maps to a sub-clause of `PATH-002`.
58#[derive(Debug, Clone, PartialEq, Eq, Snafu)]
59pub enum SegmentError {
60    /// Violates `PATH-002`: the input is empty.
61    #[snafu(display("path segment is empty"))]
62    Empty,
63
64    /// Violates `PATH-002`: the input is the literal `.`.
65    #[snafu(display("path segment is the literal `.`"))]
66    Dot,
67
68    /// Violates `PATH-002`: the input is the literal `..`.
69    #[snafu(display("path segment is the literal `..`"))]
70    DotDot,
71
72    /// Violates `PATH-002`: the input contains the path separator `/`.
73    #[snafu(display("path segment contains separator '/'"))]
74    ContainsSeparator,
75
76    /// Violates `PATH-002`: the input contains a codepoint in a
77    /// forbidden Unicode General Category (see
78    /// [`ForbiddenCategory`]).
79    #[snafu(display(
80        "path segment contains forbidden {category} codepoint U+{:04X}",
81        u32::from(*c)
82    ))]
83    ContainsForbidden {
84        /// The offending codepoint.
85        c: char,
86        /// The Unicode General Category that disqualifies it.
87        category: ForbiddenCategory,
88    },
89}
90
91/// Classify `c` against the forbidden-category set of `PATH-002`.
92///
93/// Returns `Some` if `c` belongs to one of the forbidden Unicode
94/// General Categories; returns `None` otherwise.
95///
96/// Re-exported at crate scope so glob-segment validation in
97/// [`crate::path::pattern`] can apply the same character rule.
98pub(crate) fn forbidden_category(c: char) -> Option<ForbiddenCategory> {
99    match get_general_category(c) {
100        GeneralCategory::Control => Some(ForbiddenCategory::Control),
101        GeneralCategory::Format => Some(ForbiddenCategory::Format),
102        GeneralCategory::LineSeparator | GeneralCategory::ParagraphSeparator => {
103            Some(ForbiddenCategory::LineSeparator)
104        }
105        GeneralCategory::PrivateUse => Some(ForbiddenCategory::PrivateUse),
106        GeneralCategory::Unassigned => Some(ForbiddenCategory::Unassigned),
107        _ => None,
108    }
109}
110
111pub(crate) fn validate(s: &str) -> Result<(), SegmentError> {
112    ensure!(!s.is_empty(), EmptySnafu);
113    ensure!(s != ".", DotSnafu);
114    ensure!(s != "..", DotDotSnafu);
115
116    for c in s.chars() {
117        ensure!(c != '/', ContainsSeparatorSnafu);
118        if let Some(category) = forbidden_category(c) {
119            return ContainsForbiddenSnafu { c, category }.fail();
120        }
121    }
122    Ok(())
123}
124
125/// A validated path segment.
126///
127/// Constructed via [`PathSegment::try_new`] (or
128/// [`std::str::FromStr`]). The full set of rejection cases is
129/// captured in [`SegmentError`].
130///
131/// # Examples
132///
133/// ```
134/// use haz_domain::path::PathSegment;
135///
136/// let s = PathSegment::try_new("lib.rs").unwrap();
137/// assert_eq!(s.to_string(), "lib.rs");
138/// ```
139#[nutype(
140    validate(with = validate, error = SegmentError),
141    derive(
142        Debug,
143        Clone,
144        PartialEq,
145        Eq,
146        PartialOrd,
147        Ord,
148        Hash,
149        Display,
150        FromStr,
151        AsRef,
152    ),
153)]
154pub struct PathSegment(String);
155
156impl PathSegment {
157    /// Borrow the segment as a `&str`.
158    #[must_use]
159    pub fn as_str(&self) -> &str {
160        self.as_ref()
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use crate::path::segment::{ForbiddenCategory, PathSegment, SegmentError, validate};
167
168    // PATH-002: structural rejections.
169
170    #[test]
171    fn path_002_rejects_empty() {
172        assert!(matches!(validate(""), Err(SegmentError::Empty)));
173    }
174
175    #[test]
176    fn path_002_rejects_dot() {
177        assert!(matches!(validate("."), Err(SegmentError::Dot)));
178    }
179
180    #[test]
181    fn path_002_rejects_dotdot() {
182        assert!(matches!(validate(".."), Err(SegmentError::DotDot)));
183    }
184
185    #[test]
186    fn path_002_rejects_separator() {
187        assert!(matches!(
188            validate("a/b"),
189            Err(SegmentError::ContainsSeparator)
190        ));
191    }
192
193    // PATH-002: Cc category (control characters, ASCII + C1).
194
195    #[test]
196    fn path_002_rejects_nul() {
197        // `\0` denotes U+0000 (NUL).
198        assert!(matches!(
199            validate("foo\u{0000}bar"),
200            Err(SegmentError::ContainsForbidden {
201                c: '\u{0000}',
202                category: ForbiddenCategory::Control,
203            })
204        ));
205    }
206
207    #[test]
208    fn path_002_rejects_tab() {
209        // `\t` denotes U+0009 (TAB).
210        assert!(matches!(
211            validate("foo\tbar"),
212            Err(SegmentError::ContainsForbidden {
213                c: '\t',
214                category: ForbiddenCategory::Control,
215            })
216        ));
217    }
218
219    #[test]
220    fn path_002_rejects_del() {
221        // U+007F (DEL) is ASCII control.
222        assert!(matches!(
223            validate("foo\u{007F}bar"),
224            Err(SegmentError::ContainsForbidden {
225                c: '\u{007F}',
226                category: ForbiddenCategory::Control,
227            })
228        ));
229    }
230
231    #[test]
232    fn path_002_rejects_c1_control() {
233        // U+0085 (NEXT LINE, NEL) is a C1 control codepoint.
234        assert!(matches!(
235            validate("foo\u{0085}bar"),
236            Err(SegmentError::ContainsForbidden {
237                c: '\u{0085}',
238                category: ForbiddenCategory::Control,
239            })
240        ));
241    }
242
243    // PATH-002: Cf category (format / invisible characters).
244
245    #[test]
246    fn path_002_rejects_zero_width_space() {
247        // U+200B (ZERO WIDTH SPACE).
248        assert!(matches!(
249            validate("foo\u{200B}bar"),
250            Err(SegmentError::ContainsForbidden {
251                c: '\u{200B}',
252                category: ForbiddenCategory::Format,
253            })
254        ));
255    }
256
257    #[test]
258    fn path_002_rejects_zero_width_joiner() {
259        // U+200D (ZERO WIDTH JOINER).
260        assert!(matches!(
261            validate("foo\u{200D}bar"),
262            Err(SegmentError::ContainsForbidden {
263                c: '\u{200D}',
264                category: ForbiddenCategory::Format,
265            })
266        ));
267    }
268
269    #[test]
270    fn path_002_rejects_byte_order_mark() {
271        // U+FEFF (BOM / ZERO WIDTH NO-BREAK SPACE).
272        assert!(matches!(
273            validate("foo\u{FEFF}bar"),
274            Err(SegmentError::ContainsForbidden {
275                c: '\u{FEFF}',
276                category: ForbiddenCategory::Format,
277            })
278        ));
279    }
280
281    #[test]
282    fn path_002_rejects_bidirectional_override() {
283        // U+202E (RIGHT-TO-LEFT OVERRIDE), Trojan Source family.
284        assert!(matches!(
285            validate("foo\u{202E}bar"),
286            Err(SegmentError::ContainsForbidden {
287                c: '\u{202E}',
288                category: ForbiddenCategory::Format,
289            })
290        ));
291    }
292
293    #[test]
294    fn path_002_rejects_soft_hyphen() {
295        // U+00AD (SOFT HYPHEN), invisible by default.
296        assert!(matches!(
297            validate("foo\u{00AD}bar"),
298            Err(SegmentError::ContainsForbidden {
299                c: '\u{00AD}',
300                category: ForbiddenCategory::Format,
301            })
302        ));
303    }
304
305    // PATH-002: Zl / Zp categories (line / paragraph separators).
306
307    #[test]
308    fn path_002_rejects_line_separator() {
309        // U+2028 (LINE SEPARATOR), Zl.
310        assert!(matches!(
311            validate("foo\u{2028}bar"),
312            Err(SegmentError::ContainsForbidden {
313                c: '\u{2028}',
314                category: ForbiddenCategory::LineSeparator,
315            })
316        ));
317    }
318
319    #[test]
320    fn path_002_rejects_paragraph_separator() {
321        // U+2029 (PARAGRAPH SEPARATOR), Zp.
322        assert!(matches!(
323            validate("foo\u{2029}bar"),
324            Err(SegmentError::ContainsForbidden {
325                c: '\u{2029}',
326                category: ForbiddenCategory::LineSeparator,
327            })
328        ));
329    }
330
331    // PATH-002: acceptances.
332
333    #[test]
334    fn path_002_accepts_alnum_segment() {
335        assert!(validate("lib").is_ok());
336        assert!(validate("Makefile").is_ok());
337    }
338
339    #[test]
340    fn path_002_accepts_segment_with_dot() {
341        // A literal `.` is invalid; `.` inside a segment is fine.
342        assert!(validate("lib.rs").is_ok());
343        assert!(validate("logo.png").is_ok());
344        assert!(validate(".dotfile").is_ok());
345    }
346
347    #[test]
348    fn path_002_accepts_regular_space() {
349        // U+0020 (SPACE), Zs, is allowed.
350        assert!(validate("hello world.txt").is_ok());
351    }
352
353    #[test]
354    fn path_002_accepts_nbsp() {
355        // U+00A0 (NO-BREAK SPACE), Zs. Documenting an intentional
356        // allow: NBSP is a confusable with regular space, but it
357        // is not in the forbidden category list. Future SPEC
358        // revisions may tighten this.
359        assert!(validate("hello\u{00A0}world.txt").is_ok());
360    }
361
362    #[test]
363    fn path_002_accepts_latin_extended() {
364        // U+00E9 (LATIN SMALL LETTER E WITH ACUTE), Ll.
365        assert!(validate("caf\u{00E9}.txt").is_ok());
366    }
367
368    #[test]
369    fn path_002_accepts_cyrillic() {
370        // U+0436 (CYRILLIC SMALL LETTER ZHE), Ll.
371        assert!(validate("\u{0436}urnal.txt").is_ok());
372    }
373
374    #[test]
375    fn path_002_accepts_cjk() {
376        // U+65E5 (CJK UNIFIED IDEOGRAPH SUN), Lo.
377        assert!(validate("\u{65E5}.txt").is_ok());
378    }
379
380    #[test]
381    fn path_002_accepts_combining_mark() {
382        // 'a' + U+0301 (COMBINING ACUTE ACCENT), Mn.
383        assert!(validate("caf\u{0061}\u{0301}.txt").is_ok());
384    }
385
386    // Round-trip.
387
388    #[test]
389    fn round_trip_via_try_new_and_display() {
390        let s = PathSegment::try_new("lib.rs").unwrap();
391        assert_eq!(s.to_string(), "lib.rs");
392        assert_eq!(s.as_str(), "lib.rs");
393    }
394}