1use a7n_proto::limits;
4use unicode_normalization::is_nfc;
5
6#[derive(Debug, thiserror::Error, PartialEq, Eq)]
7pub enum PathError {
8 #[error("empty path")]
9 Empty,
10 #[error("absolute path")]
11 Absolute,
12 #[error("backslash in path")]
13 Backslash,
14 #[error("drive prefix in path")]
15 DrivePrefix,
16 #[error("path exceeds byte limit")]
17 TooLong,
18 #[error("path is not NFC")]
19 NotNfc,
20 #[error("empty segment")]
21 EmptySegment,
22 #[error("dot or parent segment")]
23 DotSegment,
24 #[error("control character in segment")]
25 Control,
26 #[error("reserved device name")]
27 Reserved,
28 #[error("segment ends with a dot or space")]
29 TrailingDotOrSpace,
30 #[error("forbidden character in segment")]
31 ForbiddenCharacter,
32 #[error("segment exceeds byte limit")]
33 SegmentTooLong,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct ManifestPath(String);
39
40impl ManifestPath {
41 #[must_use]
42 pub fn as_str(&self) -> &str {
43 &self.0
44 }
45
46 pub fn segments(&self) -> impl Iterator<Item = &str> {
47 self.0.split('/')
48 }
49}
50
51impl TryFrom<&str> for ManifestPath {
52 type Error = PathError;
53
54 fn try_from(path: &str) -> Result<Self, Self::Error> {
55 if path.is_empty() {
56 return Err(PathError::Empty);
57 }
58 if path.len() > limits::MANIFEST_PATH_MAX {
59 return Err(PathError::TooLong);
60 }
61 if path.starts_with('/') {
62 return Err(PathError::Absolute);
63 }
64 if path.contains('\\') {
65 return Err(PathError::Backslash);
66 }
67 if path.as_bytes().get(1) == Some(&b':') && path.as_bytes()[0].is_ascii_alphabetic() {
68 return Err(PathError::DrivePrefix);
69 }
70 if !is_nfc(path) {
71 return Err(PathError::NotNfc);
72 }
73 for segment in path.split('/') {
74 validate_segment(segment)?;
75 }
76 Ok(Self(path.to_owned()))
77 }
78}
79
80fn validate_segment(segment: &str) -> Result<(), PathError> {
81 if segment.is_empty() {
82 return Err(PathError::EmptySegment);
83 }
84 if segment.len() > limits::MANIFEST_SEGMENT_MAX {
85 return Err(PathError::SegmentTooLong);
86 }
87 if matches!(segment, "." | "..") {
88 return Err(PathError::DotSegment);
89 }
90 if segment.chars().any(char::is_control) {
91 return Err(PathError::Control);
92 }
93 if segment.ends_with(['.', ' ']) {
94 return Err(PathError::TrailingDotOrSpace);
95 }
96 if segment.contains(['<', '>', ':', '"', '|', '?', '*']) {
97 return Err(PathError::ForbiddenCharacter);
98 }
99 if reserved_name(segment) {
100 return Err(PathError::Reserved);
101 }
102 Ok(())
103}
104
105fn reserved_name(segment: &str) -> bool {
106 let stem = segment.split('.').next().unwrap_or_default().to_uppercase();
107 if matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL") {
108 return true;
109 }
110 stem.strip_prefix("COM")
111 .or_else(|| stem.strip_prefix("LPT"))
112 .is_some_and(|suffix| {
113 matches!(
114 suffix,
115 "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "¹" | "²" | "³"
116 )
117 })
118}