1use bstr::{BStr, ByteSlice};
2
3pub mod component {
5 #[derive(Debug)]
7 #[expect(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 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
42 Some(const { &gix_error::ClassificationMarker::VALIDATION })
43 }
44 }
45
46 #[derive(Debug, Copy, Clone)]
50 pub struct Options {
51 pub protect_windows: bool,
54 pub protect_hfs: bool,
59 pub protect_ntfs: bool,
65 }
66
67 impl Default for Options {
68 fn default() -> Self {
69 Options {
70 protect_windows: true,
71 protect_hfs: true,
72 protect_ntfs: true,
73 }
74 }
75 }
76
77 #[derive(Debug, Copy, Clone, PartialEq, Eq)]
79 pub enum Mode {
80 Symlink,
82 }
83}
84
85pub fn component(
91 input: &BStr,
92 mode: Option<component::Mode>,
93 component::Options {
94 protect_windows,
95 protect_hfs,
96 protect_ntfs,
97 }: component::Options,
98) -> Result<&BStr, component::Error> {
99 if input.is_empty() {
100 return Err(component::Error::Empty);
101 }
102 if input == ".." || input == "." {
103 return Err(component::Error::Relative);
104 }
105 if protect_windows {
106 if input.find_byteset(br"/\").is_some() {
107 return Err(component::Error::PathSeparator);
108 }
109 if input.chars().nth(1) == Some(':') {
110 return Err(component::Error::WindowsPathPrefix);
111 }
112 } else if input.find_byte(b'/').is_some() {
113 return Err(component::Error::PathSeparator);
114 }
115 if protect_hfs {
116 if is_dot_hfs(input, "git") {
117 return Err(component::Error::DotGitDir);
118 }
119 if is_symlink(mode) && is_dot_hfs(input, "gitmodules") {
120 return Err(component::Error::SymlinkedGitModules);
121 }
122 }
123
124 if protect_ntfs {
125 if is_dot_git_ntfs(input) {
126 return Err(component::Error::DotGitDir);
127 }
128 if is_symlink(mode) && is_dot_ntfs(input, "gitmodules", "gi7eba") {
129 return Err(component::Error::SymlinkedGitModules);
130 }
131
132 if protect_windows && let Some(err) = check_win_devices_and_illegal_characters(input) {
133 return Err(err);
134 }
135 }
136
137 if !(protect_hfs | protect_ntfs) {
138 if input.eq_ignore_ascii_case(b".git") {
139 return Err(component::Error::DotGitDir);
140 }
141 if is_symlink(mode) && input.eq_ignore_ascii_case(b".gitmodules") {
142 return Err(component::Error::SymlinkedGitModules);
143 }
144 }
145 Ok(input)
146}
147
148pub fn component_is_windows_device(input: &BStr) -> bool {
154 is_win_device(input)
155}
156
157fn is_win_device(input: &BStr) -> bool {
158 let Some(in3) = input.get(..3) else { return false };
159 if in3.eq_ignore_ascii_case(b"AUX") && is_done_windows(input.get(3..)) {
160 return true;
161 }
162 if in3.eq_ignore_ascii_case(b"NUL") && is_done_windows(input.get(3..)) {
163 return true;
164 }
165 if in3.eq_ignore_ascii_case(b"PRN") && is_done_windows(input.get(3..)) {
166 return true;
167 }
168 if in3.eq_ignore_ascii_case(b"COM")
175 && input.get(3).is_some_and(|n| *n >= b'1' && *n <= b'9')
176 && is_done_windows(input.get(4..))
177 {
178 return true;
179 }
180 if in3.eq_ignore_ascii_case(b"LPT")
181 && input.get(3).is_some_and(u8::is_ascii_digit)
182 && is_done_windows(input.get(4..))
183 {
184 return true;
185 }
186 if in3.eq_ignore_ascii_case(b"CON")
187 && (is_done_windows(input.get(3..))
188 || (input.get(3..6).is_some_and(|n| n.eq_ignore_ascii_case(b"IN$")) && is_done_windows(input.get(6..)))
189 || (input.get(3..7).is_some_and(|n| n.eq_ignore_ascii_case(b"OUT$")) && is_done_windows(input.get(7..))))
190 {
191 return true;
192 }
193 false
194}
195
196fn check_win_devices_and_illegal_characters(input: &BStr) -> Option<component::Error> {
197 if is_win_device(input) {
198 return Some(component::Error::WindowsReservedName);
199 }
200 if input.iter().any(|b| *b < 0x20 || b":<>\"|?*".contains(b)) {
201 return Some(component::Error::WindowsIllegalCharacter);
202 }
203 if input.ends_with(b".") || input.ends_with(b" ") {
204 return Some(component::Error::WindowsIllegalCharacter);
205 }
206 None
207}
208
209fn is_symlink(mode: Option<component::Mode>) -> bool {
210 mode == Some(component::Mode::Symlink)
211}
212
213fn is_dot_hfs(input: &BStr, search_case_insensitive: &str) -> bool {
214 let mut input = input.chars().filter(|c| match *c as u32 {
215 0x200c | 0x200d | 0x200e | 0x200f | 0x202a | 0x202b | 0x202c | 0x202d | 0x202e | 0x206a | 0x206b | 0x206c | 0x206d | 0x206e | 0x206f | 0xfeff => false, _ => true
236 });
237 if input.next() != Some('.') {
238 return false;
239 }
240
241 let mut comp = search_case_insensitive.chars();
242 loop {
243 match (comp.next(), input.next()) {
244 (Some(a), Some(b)) => {
245 if !a.eq_ignore_ascii_case(&b) {
246 return false;
247 }
248 }
249 (None, None) => return true,
250 _ => return false,
251 }
252 }
253}
254
255fn is_dot_git_ntfs(input: &BStr) -> bool {
256 if input.get(..4).is_some_and(|input| input.eq_ignore_ascii_case(b".git")) {
257 return is_done_ntfs(input.get(4..));
258 }
259 if input.get(..5).is_some_and(|input| input.eq_ignore_ascii_case(b"git~1")) {
260 return is_done_ntfs(input.get(5..));
261 }
262 false
263}
264
265fn is_dot_ntfs(input: &BStr, search_case_insensitive: &str, ntfs_shortname_prefix: &str) -> bool {
270 if input.first() == Some(&b'.') {
271 let end_pos = 1 + search_case_insensitive.len();
272 if input
273 .get(1..end_pos)
274 .is_some_and(|input| input.eq_ignore_ascii_case(search_case_insensitive.as_bytes()))
275 {
276 is_done_ntfs(input.get(end_pos..))
277 } else {
278 false
279 }
280 } else {
281 let search_case_insensitive: &[u8] = search_case_insensitive.as_bytes();
282 if search_case_insensitive
283 .get(..6)
284 .zip(input.get(..6))
285 .is_some_and(|(ntfs_prefix, first_6_of_input)| {
286 first_6_of_input.eq_ignore_ascii_case(ntfs_prefix)
287 && input.get(6) == Some(&b'~')
288 && input.get(7).is_some_and(|num| (b'1'..=b'4').contains(num))
291 })
292 {
293 return is_done_ntfs(input.get(8..));
294 }
295
296 let ntfs_shortname_prefix: &[u8] = ntfs_shortname_prefix.as_bytes();
297 let mut saw_tilde = false;
298 let mut pos = 0;
299 while pos < 8 {
300 let Some(b) = input.get(pos).copied() else {
301 return false;
302 };
303 if saw_tilde {
304 if !b.is_ascii_digit() {
305 return false;
306 }
307 } else if b == b'~' {
308 saw_tilde = true;
309 pos += 1;
310 let Some(b) = input.get(pos).copied() else {
311 return false;
312 };
313 if !(b'1'..=b'9').contains(&b) {
314 return false;
315 }
316 } else if pos >= 6
317 || b & 0x80 == 0x80
318 || ntfs_shortname_prefix
319 .get(pos)
320 .is_none_or(|ob| !b.eq_ignore_ascii_case(ob))
321 {
322 return false;
323 }
324 pos += 1;
325 }
326 is_done_ntfs(input.get(pos..))
327 }
328}
329
330fn is_done_ntfs(input: Option<&[u8]>) -> bool {
332 let Some(input) = input else { return true };
334 for b in input.bytes() {
335 if b == b':' {
336 return true;
337 }
338 if b != b' ' && b != b'.' {
339 return false;
340 }
341 }
342 true
343}
344
345fn is_done_windows(input: Option<&[u8]>) -> bool {
347 let Some(input) = input else { return true };
349 let skip = input.bytes().take_while(|b| *b == b' ').count();
350 let Some(next) = input.get(skip) else { return true };
351 *next == b'.' || *next == b':'
352}