1use std::fmt;
14
15use nutype::nutype;
16use snafu::{Snafu, ensure};
17use unicode_general_category::{GeneralCategory, get_general_category};
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
24pub enum ForbiddenCategory {
25 Control,
28 Format,
33 LineSeparator,
36 PrivateUse,
38 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#[derive(Debug, Clone, PartialEq, Eq, Snafu)]
59pub enum SegmentError {
60 #[snafu(display("path segment is empty"))]
62 Empty,
63
64 #[snafu(display("path segment is the literal `.`"))]
66 Dot,
67
68 #[snafu(display("path segment is the literal `..`"))]
70 DotDot,
71
72 #[snafu(display("path segment contains separator '/'"))]
74 ContainsSeparator,
75
76 #[snafu(display(
80 "path segment contains forbidden {category} codepoint U+{:04X}",
81 u32::from(*c)
82 ))]
83 ContainsForbidden {
84 c: char,
86 category: ForbiddenCategory,
88 },
89}
90
91pub(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#[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 #[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 #[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 #[test]
196 fn path_002_rejects_nul() {
197 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 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 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 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 #[test]
246 fn path_002_rejects_zero_width_space() {
247 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 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 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 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 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 #[test]
308 fn path_002_rejects_line_separator() {
309 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 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 #[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 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 assert!(validate("hello world.txt").is_ok());
351 }
352
353 #[test]
354 fn path_002_accepts_nbsp() {
355 assert!(validate("hello\u{00A0}world.txt").is_ok());
360 }
361
362 #[test]
363 fn path_002_accepts_latin_extended() {
364 assert!(validate("caf\u{00E9}.txt").is_ok());
366 }
367
368 #[test]
369 fn path_002_accepts_cyrillic() {
370 assert!(validate("\u{0436}urnal.txt").is_ok());
372 }
373
374 #[test]
375 fn path_002_accepts_cjk() {
376 assert!(validate("\u{65E5}.txt").is_ok());
378 }
379
380 #[test]
381 fn path_002_accepts_combining_mark() {
382 assert!(validate("caf\u{0061}\u{0301}.txt").is_ok());
384 }
385
386 #[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}