1use crate::ids::string_id;
4use serde::{Deserialize, Serialize};
5use std::fmt;
6use thiserror::Error;
7
8#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
14pub struct AbsolutePath {
15 normalized: String,
16 components: Vec<PathComponent>,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
24pub struct PathComponent(String);
25
26string_id! {
27 DisplayName,
29 error = PathError,
30 validate = validate_display_name
31}
32
33pub const MAX_DISPLAY_NAME_BYTES: usize = 255;
38
39pub const MAX_PATH_BYTES: usize = 4_096;
43
44pub const MAX_PATH_DEPTH: usize = 128;
46
47#[derive(Debug, Clone, PartialEq, Eq, Error)]
49pub enum PathError {
50 #[error("absolute path must not be empty")]
52 EmptyPath,
53 #[error("path `{path:?}` is not absolute")]
55 RelativePath {
56 path: String,
58 },
59 #[error("path `{path:?}` contains `.` component")]
61 DotComponent {
62 path: String,
64 },
65 #[error("path `{path:?}` contains `..` component")]
67 ParentComponent {
68 path: String,
70 },
71 #[error("display name must not be empty")]
73 EmptyDisplayName,
74 #[error("display name `{display_name:?}` contains `/`")]
76 DisplayNameContainsSeparator {
77 display_name: String,
79 },
80 #[error("display name `{display_name:?}` is reserved")]
82 ReservedDisplayName {
83 display_name: String,
85 },
86 #[error("display name contains control character U+{code_point:04X}")]
88 DisplayNameContainsControlCharacter {
89 code_point: u32,
91 },
92 #[error("display name is {byte_length} bytes; the maximum is {MAX_DISPLAY_NAME_BYTES} bytes")]
94 DisplayNameTooLong {
95 byte_length: usize,
97 },
98 #[error("path is {byte_length} bytes; the maximum is {MAX_PATH_BYTES} bytes")]
100 PathTooLong {
101 byte_length: usize,
103 },
104 #[error("path has {depth} components; the maximum is {MAX_PATH_DEPTH}")]
106 PathTooDeep {
107 depth: usize,
109 },
110 #[error("display name `{display_name}` {reason}")]
112 UnportableDisplayName {
113 display_name: String,
115 reason: &'static str,
117 },
118 #[error(
120 "display name `{display_name}` contains `{character}`, which Windows cannot store; \
121 the reserved characters are `:` `?` `*` `|` `\"` `<` `>` `\\`"
122 )]
123 UnportableDisplayNameCharacter {
124 display_name: String,
126 character: char,
128 },
129 #[error(
131 "display name folds to a {byte_length}-byte name key; the maximum is \
132 {max} bytes",
133 max = crate::ids::MAX_NAME_KEY_BYTES
134 )]
135 FoldedNameKeyTooLong {
136 byte_length: usize,
138 },
139}
140
141impl AbsolutePath {
142 pub fn parse(value: impl AsRef<str>) -> Result<Self, PathError> {
148 let value = value.as_ref();
149 if value.is_empty() {
150 return Err(PathError::EmptyPath);
151 }
152 if !value.starts_with('/') {
153 return Err(PathError::RelativePath {
154 path: value.to_owned(),
155 });
156 }
157 if value == "/" {
158 return Ok(Self::root());
159 }
160
161 let mut components = Vec::new();
162 for component in value[1..].split('/') {
163 if component.is_empty() {
164 return Err(PathError::EmptyDisplayName);
165 }
166 if component == "." {
167 return Err(PathError::DotComponent {
168 path: value.to_owned(),
169 });
170 }
171 if component == ".." {
172 return Err(PathError::ParentComponent {
173 path: value.to_owned(),
174 });
175 }
176 validate_display_name(component)?;
180 components.push(PathComponent(component.to_owned()));
181 }
182 validate_path_bounds(value.len(), components.len())?;
183
184 Ok(Self::from_components(components))
185 }
186
187 pub fn root() -> Self {
189 Self {
190 normalized: "/".to_owned(),
191 components: Vec::new(),
192 }
193 }
194
195 pub fn as_str(&self) -> &str {
197 &self.normalized
198 }
199
200 pub fn is_root(&self) -> bool {
202 self.components.is_empty()
203 }
204
205 pub fn components(&self) -> &[PathComponent] {
207 &self.components
208 }
209
210 pub fn parent(&self) -> Option<Self> {
212 if self.is_root() {
213 return None;
214 }
215 if self.components.len() == 1 {
216 return Some(Self::root());
217 }
218
219 Some(Self::from_components(
220 self.components[..self.components.len() - 1].to_vec(),
221 ))
222 }
223
224 pub fn final_component(&self) -> Option<&PathComponent> {
226 self.components.last()
227 }
228
229 pub fn join(&self, display_name: &DisplayName) -> Self {
231 let mut components = self.components.clone();
232 components.push(PathComponent(display_name.as_str().to_owned()));
233 Self::from_components(components)
234 }
235
236 fn from_components(components: Vec<PathComponent>) -> Self {
237 let normalized = normalized_path(&components);
238 Self {
239 normalized,
240 components,
241 }
242 }
243}
244
245impl AsRef<str> for AbsolutePath {
246 fn as_ref(&self) -> &str {
247 self.as_str()
248 }
249}
250
251impl std::ops::Deref for AbsolutePath {
252 type Target = str;
253
254 fn deref(&self) -> &Self::Target {
255 self.as_str()
256 }
257}
258
259impl PartialEq<&str> for AbsolutePath {
260 fn eq(&self, other: &&str) -> bool {
261 self.as_str() == *other
262 }
263}
264
265impl fmt::Display for AbsolutePath {
266 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
267 f.write_str(&self.normalized)
268 }
269}
270
271#[cfg(feature = "openapi")]
272impl utoipa::PartialSchema for AbsolutePath {
273 fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
274 utoipa::openapi::schema::Object::builder()
275 .schema_type(utoipa::openapi::schema::Type::String)
276 .description(Some(
277 "Validated complete absolute namespace path, serialized as a plain string.",
278 ))
279 .into()
280 }
281}
282
283#[cfg(feature = "openapi")]
284impl utoipa::ToSchema for AbsolutePath {}
285
286impl Serialize for AbsolutePath {
287 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
288 where
289 S: serde::Serializer,
290 {
291 serializer.serialize_str(&self.normalized)
292 }
293}
294
295impl<'de> Deserialize<'de> for AbsolutePath {
296 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
297 where
298 D: serde::Deserializer<'de>,
299 {
300 let value = String::deserialize(deserializer)?;
301 Self::parse(value).map_err(serde::de::Error::custom)
302 }
303}
304
305fn normalized_path(components: &[PathComponent]) -> String {
306 if components.is_empty() {
307 "/".to_owned()
308 } else {
309 format!(
310 "/{}",
311 components
312 .iter()
313 .map(PathComponent::as_str)
314 .collect::<Vec<_>>()
315 .join("/")
316 )
317 }
318}
319
320impl PathComponent {
321 pub fn as_str(&self) -> &str {
323 &self.0
324 }
325
326 pub fn to_display_name(&self) -> DisplayName {
328 DisplayName(self.0.clone())
329 }
330}
331
332impl AsRef<str> for PathComponent {
333 fn as_ref(&self) -> &str {
334 self.as_str()
335 }
336}
337
338impl fmt::Display for PathComponent {
339 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
340 f.write_str(&self.0)
341 }
342}
343
344fn validate_display_name(value: &str) -> Result<(), PathError> {
345 if value.is_empty() {
346 return Err(PathError::EmptyDisplayName);
347 }
348 if value.contains('/') {
349 return Err(PathError::DisplayNameContainsSeparator {
350 display_name: value.to_owned(),
351 });
352 }
353 if value == "." || value == ".." {
354 return Err(PathError::ReservedDisplayName {
355 display_name: value.to_owned(),
356 });
357 }
358 if let Some(control) = value.chars().find(|character| character.is_control()) {
359 return Err(PathError::DisplayNameContainsControlCharacter {
360 code_point: control as u32,
361 });
362 }
363 if value.len() > MAX_DISPLAY_NAME_BYTES {
364 return Err(PathError::DisplayNameTooLong {
365 byte_length: value.len(),
366 });
367 }
368 if let Some(character) = first_windows_reserved_character(value) {
374 return Err(PathError::UnportableDisplayNameCharacter {
375 display_name: value.to_owned(),
376 character,
377 });
378 }
379 if value.chars().all(char::is_whitespace) {
380 return Err(PathError::UnportableDisplayName {
381 display_name: value.to_owned(),
382 reason: "is entirely whitespace",
383 });
384 }
385 if value.ends_with(' ') {
386 return Err(PathError::UnportableDisplayName {
387 display_name: value.to_owned(),
388 reason: "ends with a space, which Windows cannot store",
389 });
390 }
391 if value.ends_with('.') {
392 return Err(PathError::UnportableDisplayName {
393 display_name: value.to_owned(),
394 reason: "ends with a dot, which Windows cannot store",
395 });
396 }
397 if is_windows_reserved_device_name(value) {
398 return Err(PathError::UnportableDisplayName {
399 display_name: value.to_owned(),
400 reason: "is a Windows reserved device name",
401 });
402 }
403 let folded_length = crate::name_policy::name_key_for_display_name(value).len();
410 if folded_length > crate::ids::MAX_NAME_KEY_BYTES {
411 return Err(PathError::FoldedNameKeyTooLong {
412 byte_length: folded_length,
413 });
414 }
415 Ok(())
416}
417
418fn validate_path_bounds(byte_length: usize, depth: usize) -> Result<(), PathError> {
419 if byte_length > MAX_PATH_BYTES {
420 return Err(PathError::PathTooLong { byte_length });
421 }
422 if depth > MAX_PATH_DEPTH {
423 return Err(PathError::PathTooDeep { depth });
424 }
425 Ok(())
426}
427
428const WINDOWS_RESERVED_CHARACTERS: [char; 8] = [':', '?', '*', '|', '"', '<', '>', '\\'];
439
440fn first_windows_reserved_character(value: &str) -> Option<char> {
443 value
444 .chars()
445 .find(|character| WINDOWS_RESERVED_CHARACTERS.contains(character))
446}
447
448fn is_windows_reserved_device_name(value: &str) -> bool {
451 let stem = value.split('.').next().unwrap_or(value);
452 let stem = stem.trim_end_matches(' ');
453 let upper = stem.to_ascii_uppercase();
454 matches!(upper.as_str(), "CON" | "PRN" | "AUX" | "NUL")
455 || (upper.len() == 4
456 && (upper.starts_with("COM") || upper.starts_with("LPT"))
457 && upper[3..].chars().all(|digit| digit.is_ascii_digit())
458 && &upper[3..] != "0")
459}
460
461impl PathError {
462 pub fn invalid_path_input(&self) -> &str {
464 match self {
465 Self::EmptyPath => "",
466 Self::RelativePath { path }
467 | Self::DotComponent { path }
468 | Self::ParentComponent { path } => path,
469 Self::EmptyDisplayName => "",
470 Self::DisplayNameContainsSeparator { display_name }
471 | Self::ReservedDisplayName { display_name } => display_name,
472 Self::UnportableDisplayName { display_name, .. }
473 | Self::UnportableDisplayNameCharacter { display_name, .. } => display_name,
474 Self::DisplayNameContainsControlCharacter { .. }
478 | Self::DisplayNameTooLong { .. }
479 | Self::FoldedNameKeyTooLong { .. }
480 | Self::PathTooLong { .. }
481 | Self::PathTooDeep { .. } => "",
482 }
483 }
484}
485
486#[cfg(test)]
487mod tests {
488 use super::{AbsolutePath, DisplayName, PathError};
489 use crate::{name_key_for_display_name, NameKey};
490
491 #[test]
492 fn unportable_names_are_rejected() {
493 for name in [
494 " ",
495 "report ",
496 "archive.",
497 "CON",
498 "con.txt",
499 "Com1.log",
500 "lpt9",
501 "aux.files.d",
502 ] {
503 assert!(
504 DisplayName::parse(name).is_err(),
505 "`{name}` should be rejected"
506 );
507 }
508 for name in ["CONSOLE", "com10", "lpt10.txt", ".hidden", "a.b"] {
510 assert!(
511 DisplayName::parse(name).is_ok(),
512 "`{name}` should be accepted"
513 );
514 }
515 }
516
517 #[test]
521 fn windows_reserved_characters_are_rejected() {
522 for (name, character) in [
523 ("c:drive", ':'),
524 ("what?", '?'),
525 ("glob*.txt", '*'),
526 ("a|b", '|'),
527 ("say \"hi\"", '"'),
528 ("<draft>", '<'),
529 ("out>", '>'),
530 ("back\\slash.txt", '\\'),
531 ] {
532 let error = DisplayName::parse(name).expect_err("`{name}` should be rejected");
533 assert_eq!(
534 error,
535 PathError::UnportableDisplayNameCharacter {
536 display_name: name.to_owned(),
537 character,
538 },
539 "`{name}` should name the character it broke on"
540 );
541 let message = error.to_string();
542 assert!(
543 message.contains(name) && message.contains(character),
544 "the diagnostic must name the name and the character, got: {message}"
545 );
546
547 assert_eq!(
549 AbsolutePath::parse(format!("/docs/{name}")),
550 Err(PathError::UnportableDisplayNameCharacter {
551 display_name: name.to_owned(),
552 character,
553 })
554 );
555 }
556
557 assert_eq!(
560 DisplayName::parse("a?b:c"),
561 Err(PathError::UnportableDisplayNameCharacter {
562 display_name: "a?b:c".to_owned(),
563 character: '?',
564 })
565 );
566
567 for name in [
570 "report;final.txt",
571 "hello!.txt",
572 "it's.txt",
573 "a+b=c.txt",
574 "~backup#1.txt",
575 "100%.txt",
576 "a&b.txt",
577 "notes (draft).txt",
578 "list[0].txt",
579 "set{a}.txt",
580 "a,b.txt",
581 "user@host.txt",
582 "a^b$c.txt",
583 ] {
584 assert!(
585 DisplayName::parse(name).is_ok(),
586 "`{name}` should be accepted"
587 );
588 }
589 }
590
591 #[test]
592 fn paths_are_bounded_in_bytes_and_depth() {
593 let deep = format!("/{}", vec!["d"; super::MAX_PATH_DEPTH + 1].join("/"));
594 assert!(matches!(
595 AbsolutePath::parse(&deep),
596 Err(PathError::PathTooDeep { .. })
597 ));
598 let long_component = "a".repeat(200);
599 let mut long = String::new();
600 while long.len() <= super::MAX_PATH_BYTES {
601 long.push('/');
602 long.push_str(&long_component);
603 }
604 assert!(matches!(
605 AbsolutePath::parse(&long),
606 Err(PathError::PathTooLong { .. })
607 ));
608 let fine = format!("/{}", vec!["d"; super::MAX_PATH_DEPTH].join("/"));
609 assert!(AbsolutePath::parse(&fine).is_ok());
610 }
611
612 #[test]
613 fn absolute_path_root_is_valid() {
614 let path = AbsolutePath::parse("/").expect("root should parse");
615
616 assert_eq!(path.as_str(), "/");
617 assert!(path.is_root());
618 assert!(path.components().is_empty());
619 assert!(path.parent().is_none());
620 assert!(path.final_component().is_none());
621 }
622
623 #[test]
624 fn absolute_path_rejects_dot_and_dotdot_components() {
625 assert!(matches!(
626 AbsolutePath::parse("/docs/./a.txt"),
627 Err(PathError::DotComponent { .. })
628 ));
629 assert!(matches!(
630 AbsolutePath::parse("/docs/../a.txt"),
631 Err(PathError::ParentComponent { .. })
632 ));
633 }
634
635 #[test]
636 fn absolute_path_rejects_noncanonical_spellings() {
637 assert_eq!(AbsolutePath::parse("//a"), Err(PathError::EmptyDisplayName));
638 assert_eq!(
639 AbsolutePath::parse("/a//b"),
640 Err(PathError::EmptyDisplayName)
641 );
642 assert_eq!(AbsolutePath::parse("/a/"), Err(PathError::EmptyDisplayName));
643 assert!(matches!(
644 AbsolutePath::parse("a"),
645 Err(PathError::RelativePath { .. })
646 ));
647 assert_eq!(AbsolutePath::parse(""), Err(PathError::EmptyPath));
648 }
649
650 #[test]
651 fn absolute_path_serde_is_a_validated_plain_string() {
652 let path = AbsolutePath::parse("/Docs/ReadMe.TXT").expect("path should parse");
653
654 assert_eq!(
655 serde_json::to_string(&path).expect("serialize path"),
656 r#""/Docs/ReadMe.TXT""#
657 );
658 assert_eq!(
659 serde_json::from_str::<AbsolutePath>(r#""/Docs/ReadMe.TXT""#)
660 .expect("deserialize path"),
661 path
662 );
663 assert!(serde_json::from_str::<AbsolutePath>(r#""relative/path""#).is_err());
664 }
665
666 #[test]
667 fn absolute_path_parent_final_component_and_join_preserve_display_spelling() {
668 let path = AbsolutePath::parse("/Docs/ReadMe.TXT").expect("path should parse");
669 let parent = path.parent().expect("non-root path should have parent");
670
671 assert_eq!(parent.as_str(), "/Docs");
672 assert_eq!(
673 path.final_component()
674 .expect("non-root path has a final component")
675 .as_str(),
676 "ReadMe.TXT"
677 );
678 assert_eq!(
679 parent
680 .join(&DisplayName::parse("Child.TXT").expect("display name should parse"))
681 .as_str(),
682 "/Docs/Child.TXT"
683 );
684 }
685
686 #[test]
687 fn display_name_rejects_invalid_spellings() {
688 assert_eq!(DisplayName::parse(""), Err(PathError::EmptyDisplayName));
689 assert!(matches!(
690 DisplayName::parse("a/b"),
691 Err(PathError::DisplayNameContainsSeparator { .. })
692 ));
693 assert!(matches!(
694 DisplayName::parse("."),
695 Err(PathError::ReservedDisplayName { .. })
696 ));
697 }
698
699 #[test]
700 fn display_name_rejects_control_characters() {
701 assert_eq!(
702 DisplayName::parse("a\u{0}b"),
703 Err(PathError::DisplayNameContainsControlCharacter { code_point: 0 })
704 );
705 assert_eq!(
706 DisplayName::parse("line\nbreak"),
707 Err(PathError::DisplayNameContainsControlCharacter { code_point: 0x0A })
708 );
709 assert_eq!(
710 DisplayName::parse("c1\u{85}"),
711 Err(PathError::DisplayNameContainsControlCharacter { code_point: 0x85 })
712 );
713 DisplayName::parse("bidi\u{202E}name").expect("format characters are allowed");
715 }
716
717 #[test]
718 fn display_name_enforces_the_byte_cap_as_stored() {
719 DisplayName::parse("a".repeat(super::MAX_DISPLAY_NAME_BYTES))
720 .expect("255 bytes is the maximum, inclusive");
721 assert_eq!(
722 DisplayName::parse("a".repeat(super::MAX_DISPLAY_NAME_BYTES + 1)),
723 Err(PathError::DisplayNameTooLong { byte_length: 256 })
724 );
725 assert_eq!(
728 DisplayName::parse("é".repeat(128)),
729 Err(PathError::DisplayNameTooLong { byte_length: 256 })
730 );
731 }
732
733 #[test]
734 fn maximal_casefold_expansion_stays_within_the_name_key_cap() {
735 let display_name =
740 DisplayName::parse("\u{0390}".repeat(127)).expect("maximal expander parses");
741 let key = NameKey::for_display_name(&display_name);
742 assert!(key.as_str().len() <= crate::ids::MAX_NAME_KEY_BYTES);
743 }
744
745 #[test]
746 fn absolute_path_components_satisfy_the_display_name_grammar() {
747 assert!(matches!(
748 AbsolutePath::parse("/docs/bad\u{0}name"),
749 Err(PathError::DisplayNameContainsControlCharacter { code_point: 0 })
750 ));
751 assert!(matches!(
752 AbsolutePath::parse(format!("/docs/{}", "a".repeat(256))),
753 Err(PathError::DisplayNameTooLong { .. })
754 ));
755 }
756
757 #[test]
758 fn name_key_matches_folding_helper() {
759 let display_name = DisplayName::parse("Cafe\u{301}.TXT").expect("display name");
760 let key = NameKey::for_display_name(&display_name);
761
762 assert_eq!(
763 key.as_str(),
764 name_key_for_display_name(display_name.as_str())
765 );
766 }
767}