1use bstr::{BStr, ByteSlice};
2
3pub mod component {
5 #[derive(Debug)]
7 #[allow(missing_docs)]
8 #[non_exhaustive]
9 pub enum Error {
10 Empty,
11 PathSeparator,
12 WindowsPathPrefix,
13 WindowsReservedName,
14 WindowsIllegalCharacter,
15 DotGitDir,
16 SymlinkedGitModules,
17 Relative,
18 }
19
20 impl std::fmt::Display for Error {
21 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22 match self {
23 Error::Empty => write!(f, "A path component must not be empty"),
24 Error::PathSeparator => write!(f, r"Path separators like / or \ are not allowed"),
25 Error::WindowsPathPrefix => write!(f, "Windows path prefixes are not allowed"),
26 Error::WindowsReservedName => {
27 write!(f, "Windows device-names may have side-effects and are not allowed")
28 }
29 Error::WindowsIllegalCharacter => write!(
30 f,
31 r#"Trailing spaces or dots, and the following characters anywhere, are forbidden in Windows paths, along with non-printable ones: <>:"|?*"#
32 ),
33 Error::DotGitDir => write!(f, "The .git name may never be used"),
34 Error::SymlinkedGitModules => write!(f, "The .gitmodules file must not be a symlink"),
35 Error::Relative => write!(f, "Relative components '.' and '..' are disallowed"),
36 }
37 }
38 }
39
40 impl std::error::Error for Error {}
41
42 #[derive(Debug, Copy, Clone)]
46 pub struct Options {
47 pub protect_windows: bool,
50 pub protect_hfs: bool,
55 pub protect_ntfs: bool,
61 }
62
63 impl Default for Options {
64 fn default() -> Self {
65 Options {
66 protect_windows: true,
67 protect_hfs: true,
68 protect_ntfs: true,
69 }
70 }
71 }
72
73 #[derive(Debug, Copy, Clone, PartialEq, Eq)]
75 pub enum Mode {
76 Symlink,
78 }
79}
80
81pub fn component(
87 input: &BStr,
88 mode: Option<component::Mode>,
89 component::Options {
90 protect_windows,
91 protect_hfs,
92 protect_ntfs,
93 }: component::Options,
94) -> Result<&BStr, component::Error> {
95 if input.is_empty() {
96 return Err(component::Error::Empty);
97 }
98 if input == ".." || input == "." {
99 return Err(component::Error::Relative);
100 }
101 if protect_windows {
102 if input.find_byteset(br"/\").is_some() {
103 return Err(component::Error::PathSeparator);
104 }
105 if input.chars().nth(1) == Some(':') {
106 return Err(component::Error::WindowsPathPrefix);
107 }
108 } else if input.find_byte(b'/').is_some() {
109 return Err(component::Error::PathSeparator);
110 }
111 if protect_hfs {
112 if is_dot_hfs(input, "git") {
113 return Err(component::Error::DotGitDir);
114 }
115 if is_symlink(mode) && is_dot_hfs(input, "gitmodules") {
116 return Err(component::Error::SymlinkedGitModules);
117 }
118 }
119
120 if protect_ntfs {
121 if is_dot_git_ntfs(input) {
122 return Err(component::Error::DotGitDir);
123 }
124 if is_symlink(mode) && is_dot_ntfs(input, "gitmodules", "gi7eba") {
125 return Err(component::Error::SymlinkedGitModules);
126 }
127
128 if protect_windows {
129 if let Some(err) = check_win_devices_and_illegal_characters(input) {
130 return Err(err);
131 }
132 }
133 }
134
135 if !(protect_hfs | protect_ntfs) {
136 if input.eq_ignore_ascii_case(b".git") {
137 return Err(component::Error::DotGitDir);
138 }
139 if is_symlink(mode) && input.eq_ignore_ascii_case(b".gitmodules") {
140 return Err(component::Error::SymlinkedGitModules);
141 }
142 }
143 Ok(input)
144}
145
146pub fn component_is_windows_device(input: &BStr) -> bool {
152 is_win_device(input)
153}
154
155fn is_win_device(input: &BStr) -> bool {
156 let Some(in3) = input.get(..3) else { return false };
157 if in3.eq_ignore_ascii_case(b"AUX") && is_done_windows(input.get(3..)) {
158 return true;
159 }
160 if in3.eq_ignore_ascii_case(b"NUL") && is_done_windows(input.get(3..)) {
161 return true;
162 }
163 if in3.eq_ignore_ascii_case(b"PRN") && is_done_windows(input.get(3..)) {
164 return true;
165 }
166 if in3.eq_ignore_ascii_case(b"COM")
173 && input.get(3).is_some_and(|n| *n >= b'1' && *n <= b'9')
174 && is_done_windows(input.get(4..))
175 {
176 return true;
177 }
178 if in3.eq_ignore_ascii_case(b"LPT")
179 && input.get(3).is_some_and(u8::is_ascii_digit)
180 && is_done_windows(input.get(4..))
181 {
182 return true;
183 }
184 if in3.eq_ignore_ascii_case(b"CON")
185 && (is_done_windows(input.get(3..))
186 || (input.get(3..6).is_some_and(|n| n.eq_ignore_ascii_case(b"IN$")) && is_done_windows(input.get(6..)))
187 || (input.get(3..7).is_some_and(|n| n.eq_ignore_ascii_case(b"OUT$")) && is_done_windows(input.get(7..))))
188 {
189 return true;
190 }
191 false
192}
193
194fn check_win_devices_and_illegal_characters(input: &BStr) -> Option<component::Error> {
195 if is_win_device(input) {
196 return Some(component::Error::WindowsReservedName);
197 }
198 if input.iter().any(|b| *b < 0x20 || b":<>\"|?*".contains(b)) {
199 return Some(component::Error::WindowsIllegalCharacter);
200 }
201 if input.ends_with(b".") || input.ends_with(b" ") {
202 return Some(component::Error::WindowsIllegalCharacter);
203 }
204 None
205}
206
207fn is_symlink(mode: Option<component::Mode>) -> bool {
208 mode == Some(component::Mode::Symlink)
209}
210
211fn is_dot_hfs(input: &BStr, search_case_insensitive: &str) -> bool {
212 let mut input = input.chars().filter(|c| match *c as u32 {
213 0x200c | 0x200d | 0x200e | 0x200f | 0x202a | 0x202b | 0x202c | 0x202d | 0x202e | 0x206a | 0x206b | 0x206c | 0x206d | 0x206e | 0x206f | 0xfeff => false, _ => true
234 });
235 if input.next() != Some('.') {
236 return false;
237 }
238
239 let mut comp = search_case_insensitive.chars();
240 loop {
241 match (comp.next(), input.next()) {
242 (Some(a), Some(b)) => {
243 if !a.eq_ignore_ascii_case(&b) {
244 return false;
245 }
246 }
247 (None, None) => return true,
248 _ => return false,
249 }
250 }
251}
252
253fn is_dot_git_ntfs(input: &BStr) -> bool {
254 if input.get(..4).is_some_and(|input| input.eq_ignore_ascii_case(b".git")) {
255 return is_done_ntfs(input.get(4..));
256 }
257 if input.get(..5).is_some_and(|input| input.eq_ignore_ascii_case(b"git~1")) {
258 return is_done_ntfs(input.get(5..));
259 }
260 false
261}
262
263fn is_dot_ntfs(input: &BStr, search_case_insensitive: &str, ntfs_shortname_prefix: &str) -> bool {
268 if input.first() == Some(&b'.') {
269 let end_pos = 1 + search_case_insensitive.len();
270 if input
271 .get(1..end_pos)
272 .is_some_and(|input| input.eq_ignore_ascii_case(search_case_insensitive.as_bytes()))
273 {
274 is_done_ntfs(input.get(end_pos..))
275 } else {
276 false
277 }
278 } else {
279 let search_case_insensitive: &[u8] = search_case_insensitive.as_bytes();
280 if search_case_insensitive
281 .get(..6)
282 .zip(input.get(..6))
283 .is_some_and(|(ntfs_prefix, first_6_of_input)| {
284 first_6_of_input.eq_ignore_ascii_case(ntfs_prefix)
285 && input.get(6) == Some(&b'~')
286 && input.get(7).is_some_and(|num| (b'1'..=b'4').contains(num))
289 })
290 {
291 return is_done_ntfs(input.get(8..));
292 }
293
294 let ntfs_shortname_prefix: &[u8] = ntfs_shortname_prefix.as_bytes();
295 let mut saw_tilde = false;
296 let mut pos = 0;
297 while pos < 8 {
298 let Some(b) = input.get(pos).copied() else {
299 return false;
300 };
301 if saw_tilde {
302 if !b.is_ascii_digit() {
303 return false;
304 }
305 } else if b == b'~' {
306 saw_tilde = true;
307 pos += 1;
308 let Some(b) = input.get(pos).copied() else {
309 return false;
310 };
311 if !(b'1'..=b'9').contains(&b) {
312 return false;
313 }
314 } else if pos >= 6
315 || b & 0x80 == 0x80
316 || ntfs_shortname_prefix
317 .get(pos)
318 .is_none_or(|ob| !b.eq_ignore_ascii_case(ob))
319 {
320 return false;
321 }
322 pos += 1;
323 }
324 is_done_ntfs(input.get(pos..))
325 }
326}
327
328fn is_done_ntfs(input: Option<&[u8]>) -> bool {
330 let Some(input) = input else { return true };
332 for b in input.bytes() {
333 if b == b':' {
334 return true;
335 }
336 if b != b' ' && b != b'.' {
337 return false;
338 }
339 }
340 true
341}
342
343fn is_done_windows(input: Option<&[u8]>) -> bool {
345 let Some(input) = input else { return true };
347 let skip = input.bytes().take_while(|b| *b == b' ').count();
348 let Some(next) = input.get(skip) else { return true };
349 *next == b'.' || *next == b':'
350}