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