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::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]
525 fn windows_reserved_characters_are_rejected() {
526 for (name, character) in [
527 ("c:drive", ':'),
528 ("what?", '?'),
529 ("glob*.txt", '*'),
530 ("a|b", '|'),
531 ("say \"hi\"", '"'),
532 ("<draft>", '<'),
533 ("out>", '>'),
534 ("back\\slash.txt", '\\'),
535 ] {
536 let error = DisplayName::parse(name).expect_err("`{name}` should be rejected");
537 assert_eq!(
538 error,
539 PathError::UnportableDisplayNameCharacter {
540 display_name: name.to_owned(),
541 character,
542 },
543 "`{name}` should name the character it broke on"
544 );
545 let message = error.to_string();
546 assert!(
547 message.contains(name) && message.contains(character),
548 "the diagnostic must name the name and the character, got: {message}"
549 );
550
551 assert_eq!(
553 AbsolutePath::parse(format!("/docs/{name}")),
554 Err(PathError::UnportableDisplayNameCharacter {
555 display_name: name.to_owned(),
556 character,
557 })
558 );
559 }
560
561 assert_eq!(
564 DisplayName::parse("a?b:c"),
565 Err(PathError::UnportableDisplayNameCharacter {
566 display_name: "a?b:c".to_owned(),
567 character: '?',
568 })
569 );
570
571 for name in [
574 "report;final.txt",
575 "hello!.txt",
576 "it's.txt",
577 "a+b=c.txt",
578 "~backup#1.txt",
579 "100%.txt",
580 "a&b.txt",
581 "notes (draft).txt",
582 "list[0].txt",
583 "set{a}.txt",
584 "a,b.txt",
585 "user@host.txt",
586 "a^b$c.txt",
587 ] {
588 assert!(
589 DisplayName::parse(name).is_ok(),
590 "`{name}` should be accepted"
591 );
592 }
593 }
594
595 #[test]
596 fn paths_are_bounded_in_bytes_and_depth() {
597 let deep = format!("/{}", vec!["d"; super::MAX_PATH_DEPTH + 1].join("/"));
598 assert!(matches!(
599 AbsolutePath::parse(&deep),
600 Err(PathError::PathTooDeep { .. })
601 ));
602 let long_component = "a".repeat(200);
603 let mut long = String::new();
604 while long.len() <= super::MAX_PATH_BYTES {
605 long.push('/');
606 long.push_str(&long_component);
607 }
608 assert!(matches!(
609 AbsolutePath::parse(&long),
610 Err(PathError::PathTooLong { .. })
611 ));
612 let fine = format!("/{}", vec!["d"; super::MAX_PATH_DEPTH].join("/"));
613 assert!(AbsolutePath::parse(&fine).is_ok());
614 }
615
616 #[test]
617 fn absolute_path_root_is_valid() {
618 let path = AbsolutePath::parse("/").expect("root should parse");
619
620 assert_eq!(path.as_str(), "/");
621 assert!(path.is_root());
622 assert!(path.components().is_empty());
623 assert!(path.parent().is_none());
624 assert!(path.final_component().is_none());
625 }
626
627 #[test]
628 fn absolute_path_rejects_dot_and_dotdot_components() {
629 assert!(matches!(
630 AbsolutePath::parse("/docs/./a.txt"),
631 Err(PathError::DotComponent { .. })
632 ));
633 assert!(matches!(
634 AbsolutePath::parse("/docs/../a.txt"),
635 Err(PathError::ParentComponent { .. })
636 ));
637 }
638
639 #[test]
640 fn absolute_path_rejects_noncanonical_spellings() {
641 assert_eq!(AbsolutePath::parse("//a"), Err(PathError::EmptyDisplayName));
642 assert_eq!(
643 AbsolutePath::parse("/a//b"),
644 Err(PathError::EmptyDisplayName)
645 );
646 assert_eq!(AbsolutePath::parse("/a/"), Err(PathError::EmptyDisplayName));
647 assert!(matches!(
648 AbsolutePath::parse("a"),
649 Err(PathError::RelativePath { .. })
650 ));
651 assert_eq!(AbsolutePath::parse(""), Err(PathError::EmptyPath));
652 }
653
654 #[test]
655 fn absolute_path_serde_is_a_validated_plain_string() {
656 let path = AbsolutePath::parse("/Docs/ReadMe.TXT").expect("path should parse");
657
658 assert_eq!(
659 serde_json::to_string(&path).expect("serialize path"),
660 r#""/Docs/ReadMe.TXT""#
661 );
662 assert_eq!(
663 serde_json::from_str::<AbsolutePath>(r#""/Docs/ReadMe.TXT""#)
664 .expect("deserialize path"),
665 path
666 );
667 assert!(serde_json::from_str::<AbsolutePath>(r#""relative/path""#).is_err());
668 }
669
670 #[test]
671 fn absolute_path_parent_final_component_and_join_preserve_display_spelling() {
672 let path = AbsolutePath::parse("/Docs/ReadMe.TXT").expect("path should parse");
673 let parent = path.parent().expect("non-root path should have parent");
674
675 assert_eq!(parent.as_str(), "/Docs");
676 assert_eq!(
677 path.final_component()
678 .expect("non-root path has a final component")
679 .as_str(),
680 "ReadMe.TXT"
681 );
682 assert_eq!(
683 parent
684 .join(&DisplayName::parse("Child.TXT").expect("display name should parse"))
685 .as_str(),
686 "/Docs/Child.TXT"
687 );
688 }
689
690 #[test]
691 fn display_name_rejects_invalid_spellings() {
692 assert_eq!(DisplayName::parse(""), Err(PathError::EmptyDisplayName));
693 assert!(matches!(
694 DisplayName::parse("a/b"),
695 Err(PathError::DisplayNameContainsSeparator { .. })
696 ));
697 assert!(matches!(
698 DisplayName::parse("."),
699 Err(PathError::ReservedDisplayName { .. })
700 ));
701 }
702
703 #[test]
704 fn display_name_rejects_control_characters() {
705 assert_eq!(
706 DisplayName::parse("a\u{0}b"),
707 Err(PathError::DisplayNameContainsControlCharacter { code_point: 0 })
708 );
709 assert_eq!(
710 DisplayName::parse("line\nbreak"),
711 Err(PathError::DisplayNameContainsControlCharacter { code_point: 0x0A })
712 );
713 assert_eq!(
714 DisplayName::parse("c1\u{85}"),
715 Err(PathError::DisplayNameContainsControlCharacter { code_point: 0x85 })
716 );
717 DisplayName::parse("bidi\u{202E}name").expect("format characters are allowed");
719 }
720
721 #[test]
722 fn display_name_enforces_the_byte_cap_as_stored() {
723 DisplayName::parse("a".repeat(super::MAX_DISPLAY_NAME_BYTES))
724 .expect("255 bytes is the maximum, inclusive");
725 assert_eq!(
726 DisplayName::parse("a".repeat(super::MAX_DISPLAY_NAME_BYTES + 1)),
727 Err(PathError::DisplayNameTooLong { byte_length: 256 })
728 );
729 assert_eq!(
732 DisplayName::parse("é".repeat(128)),
733 Err(PathError::DisplayNameTooLong { byte_length: 256 })
734 );
735 }
736
737 #[test]
738 fn maximal_casefold_expansion_stays_within_the_name_key_cap() {
739 let display_name =
744 DisplayName::parse("\u{0390}".repeat(127)).expect("maximal expander parses");
745 let key = NameKey::for_display_name(&display_name);
746 assert!(key.as_str().len() <= crate::ids::MAX_NAME_KEY_BYTES);
747 }
748
749 #[test]
750 fn absolute_path_components_satisfy_the_display_name_grammar() {
751 assert!(matches!(
752 AbsolutePath::parse("/docs/bad\u{0}name"),
753 Err(PathError::DisplayNameContainsControlCharacter { code_point: 0 })
754 ));
755 assert!(matches!(
756 AbsolutePath::parse(format!("/docs/{}", "a".repeat(256))),
757 Err(PathError::DisplayNameTooLong { .. })
758 ));
759 }
760}