1use bstr::{BStr, ByteSlice};
2
3pub mod component {
5 #[derive(Debug, thiserror::Error)]
7 #[allow(missing_docs)]
8 pub enum Error {
9 #[error("A path component must not be empty")]
10 Empty,
11 #[error(r"Path separators like / or \ are not allowed")]
12 PathSeparator,
13 #[error("Windows path prefixes are not allowed")]
14 WindowsPathPrefix,
15 #[error("Windows device-names may have side-effects and are not allowed")]
16 WindowsReservedName,
17 #[error(r#"Trailing spaces or dots, and the following characters anywhere, are forbidden in Windows paths, along with non-printable ones: <>:"|?*"#)]
18 WindowsIllegalCharacter,
19 #[error("The .git name may never be used")]
20 DotGitDir,
21 #[error("The .gitmodules file must not be a symlink")]
22 SymlinkedGitModules,
23 }
24
25 #[derive(Debug, Copy, Clone)]
29 pub struct Options {
30 pub protect_windows: bool,
33 pub protect_hfs: bool,
38 pub protect_ntfs: bool,
44 }
45
46 impl Default for Options {
47 fn default() -> Self {
48 Options {
49 protect_windows: true,
50 protect_hfs: true,
51 protect_ntfs: true,
52 }
53 }
54 }
55
56 #[derive(Debug, Copy, Clone, PartialEq, Eq)]
58 pub enum Mode {
59 Symlink,
61 }
62}
63
64pub fn component(
70 input: &BStr,
71 mode: Option<component::Mode>,
72 component::Options {
73 protect_windows,
74 protect_hfs,
75 protect_ntfs,
76 }: component::Options,
77) -> Result<&BStr, component::Error> {
78 if input.is_empty() {
79 return Err(component::Error::Empty);
80 }
81 if protect_windows {
82 if input.find_byteset(br"/\").is_some() {
83 return Err(component::Error::PathSeparator);
84 }
85 if input.chars().nth(1) == Some(':') {
86 return Err(component::Error::WindowsPathPrefix);
87 }
88 } else if input.find_byte(b'/').is_some() {
89 return Err(component::Error::PathSeparator);
90 }
91 if protect_hfs {
92 if is_dot_hfs(input, "git") {
93 return Err(component::Error::DotGitDir);
94 }
95 if is_symlink(mode) && is_dot_hfs(input, "gitmodules") {
96 return Err(component::Error::SymlinkedGitModules);
97 }
98 }
99
100 if protect_ntfs {
101 if is_dot_git_ntfs(input) {
102 return Err(component::Error::DotGitDir);
103 }
104 if is_symlink(mode) && is_dot_ntfs(input, "gitmodules", "gi7eba") {
105 return Err(component::Error::SymlinkedGitModules);
106 }
107
108 if protect_windows {
109 if let Some(err) = check_win_devices_and_illegal_characters(input) {
110 return Err(err);
111 }
112 }
113 }
114
115 if !(protect_hfs | protect_ntfs) {
116 if input.eq_ignore_ascii_case(b".git") {
117 return Err(component::Error::DotGitDir);
118 }
119 if is_symlink(mode) && input.eq_ignore_ascii_case(b".gitmodules") {
120 return Err(component::Error::SymlinkedGitModules);
121 }
122 }
123 Ok(input)
124}
125
126pub fn component_is_windows_device(input: &BStr) -> bool {
132 is_win_device(input)
133}
134
135fn is_win_device(input: &BStr) -> bool {
136 let Some(in3) = input.get(..3) else { return false };
137 if in3.eq_ignore_ascii_case(b"AUX") && is_done_windows(input.get(3..)) {
138 return true;
139 }
140 if in3.eq_ignore_ascii_case(b"NUL") && is_done_windows(input.get(3..)) {
141 return true;
142 }
143 if in3.eq_ignore_ascii_case(b"PRN") && is_done_windows(input.get(3..)) {
144 return true;
145 }
146 if in3.eq_ignore_ascii_case(b"COM")
153 && input.get(3).is_some_and(|n| *n >= b'1' && *n <= b'9')
154 && is_done_windows(input.get(4..))
155 {
156 return true;
157 }
158 if in3.eq_ignore_ascii_case(b"LPT")
159 && input.get(3).is_some_and(u8::is_ascii_digit)
160 && is_done_windows(input.get(4..))
161 {
162 return true;
163 }
164 if in3.eq_ignore_ascii_case(b"CON")
165 && (is_done_windows(input.get(3..))
166 || (input.get(3..6).is_some_and(|n| n.eq_ignore_ascii_case(b"IN$")) && is_done_windows(input.get(6..)))
167 || (input.get(3..7).is_some_and(|n| n.eq_ignore_ascii_case(b"OUT$")) && is_done_windows(input.get(7..))))
168 {
169 return true;
170 }
171 false
172}
173
174fn check_win_devices_and_illegal_characters(input: &BStr) -> Option<component::Error> {
175 if is_win_device(input) {
176 return Some(component::Error::WindowsReservedName);
177 }
178 if input.iter().any(|b| *b < 0x20 || b":<>\"|?*".contains(b)) {
179 return Some(component::Error::WindowsIllegalCharacter);
180 }
181 if input.ends_with(b".") || input.ends_with(b" ") {
182 return Some(component::Error::WindowsIllegalCharacter);
183 }
184 None
185}
186
187fn is_symlink(mode: Option<component::Mode>) -> bool {
188 mode == Some(component::Mode::Symlink)
189}
190
191fn is_dot_hfs(input: &BStr, search_case_insensitive: &str) -> bool {
192 let mut input = input.chars().filter(|c| match *c as u32 {
193 0x200c | 0x200d | 0x200e | 0x200f | 0x202a | 0x202b | 0x202c | 0x202d | 0x202e | 0x206a | 0x206b | 0x206c | 0x206d | 0x206e | 0x206f | 0xfeff => false, _ => true
214 });
215 if input.next() != Some('.') {
216 return false;
217 }
218
219 let mut comp = search_case_insensitive.chars();
220 loop {
221 match (comp.next(), input.next()) {
222 (Some(a), Some(b)) => {
223 if !a.eq_ignore_ascii_case(&b) {
224 return false;
225 }
226 }
227 (None, None) => return true,
228 _ => return false,
229 }
230 }
231}
232
233fn is_dot_git_ntfs(input: &BStr) -> bool {
234 if input.get(..4).is_some_and(|input| input.eq_ignore_ascii_case(b".git")) {
235 return is_done_ntfs(input.get(4..));
236 }
237 if input.get(..5).is_some_and(|input| input.eq_ignore_ascii_case(b"git~1")) {
238 return is_done_ntfs(input.get(5..));
239 }
240 false
241}
242
243fn is_dot_ntfs(input: &BStr, search_case_insensitive: &str, ntfs_shortname_prefix: &str) -> bool {
248 if input.first() == Some(&b'.') {
249 let end_pos = 1 + search_case_insensitive.len();
250 if input
251 .get(1..end_pos)
252 .is_some_and(|input| input.eq_ignore_ascii_case(search_case_insensitive.as_bytes()))
253 {
254 is_done_ntfs(input.get(end_pos..))
255 } else {
256 false
257 }
258 } else {
259 let search_case_insensitive: &[u8] = search_case_insensitive.as_bytes();
260 if search_case_insensitive
261 .get(..6)
262 .zip(input.get(..6))
263 .is_some_and(|(ntfs_prefix, first_6_of_input)| {
264 first_6_of_input.eq_ignore_ascii_case(ntfs_prefix)
265 && input.get(6) == Some(&b'~')
266 && input.get(7).is_some_and(|num| (b'1'..=b'4').contains(num))
269 })
270 {
271 return is_done_ntfs(input.get(8..));
272 }
273
274 let ntfs_shortname_prefix: &[u8] = ntfs_shortname_prefix.as_bytes();
275 let mut saw_tilde = false;
276 let mut pos = 0;
277 while pos < 8 {
278 let Some(b) = input.get(pos).copied() else {
279 return false;
280 };
281 if saw_tilde {
282 if !b.is_ascii_digit() {
283 return false;
284 }
285 } else if b == b'~' {
286 saw_tilde = true;
287 pos += 1;
288 let Some(b) = input.get(pos).copied() else {
289 return false;
290 };
291 if !(b'1'..=b'9').contains(&b) {
292 return false;
293 }
294 } else if pos >= 6
295 || b & 0x80 == 0x80
296 || ntfs_shortname_prefix
297 .get(pos)
298 .map_or(true, |ob| !b.eq_ignore_ascii_case(ob))
299 {
300 return false;
301 }
302 pos += 1;
303 }
304 is_done_ntfs(input.get(pos..))
305 }
306}
307
308fn is_done_ntfs(input: Option<&[u8]>) -> bool {
310 let Some(input) = input else { return true };
312 for b in input.bytes() {
313 if b == b':' {
314 return true;
315 }
316 if b != b' ' && b != b'.' {
317 return false;
318 }
319 }
320 true
321}
322
323fn is_done_windows(input: Option<&[u8]>) -> bool {
325 let Some(input) = input else { return true };
327 let skip = input.bytes().take_while(|b| *b == b' ').count();
328 let Some(next) = input.get(skip) else { return true };
329 *next == b'.' || *next == b':'
330}