1use crate::config::ConfigSet;
21use crate::dotfile::{
22 is_hfs_dot_gitattributes, is_hfs_dot_gitignore, is_hfs_dot_gitmodules, is_hfs_dot_mailmap,
23 is_hfs_dotgit, is_ntfs_dot_gitattributes, is_ntfs_dot_gitignore, is_ntfs_dot_gitmodules,
24 is_ntfs_dot_mailmap, is_ntfs_dotgit,
25};
26use thiserror::Error;
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
30pub struct EntryMode(u32);
31
32impl EntryMode {
33 #[must_use]
35 pub const fn new(mode: u32) -> Self {
36 Self(mode)
37 }
38
39 #[must_use]
41 pub fn is_symlink(self) -> bool {
42 self.0 == 0o120000
43 }
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
48pub enum PathValidationKind {
49 Fsck,
51 #[default]
53 Worktree,
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
58pub struct ProtectOptions {
59 pub protect_ntfs: bool,
61 pub protect_hfs: bool,
63}
64
65impl Default for ProtectOptions {
66 fn default() -> Self {
67 Self {
68 protect_ntfs: true,
69 protect_hfs: cfg!(target_os = "macos"),
70 }
71 }
72}
73
74impl ProtectOptions {
75 #[must_use]
79 pub fn from_config(config: &ConfigSet) -> Self {
80 let protect_ntfs = config
81 .get_bool("core.protectNTFS")
82 .or_else(|| config.get_bool("core.protectntfs"))
83 .and_then(|r| r.ok())
84 .unwrap_or(true);
85 let default_hfs = cfg!(target_os = "macos");
86 let protect_hfs = config
87 .get_bool("core.protectHFS")
88 .or_else(|| config.get_bool("core.protecthfs"))
89 .and_then(|r| r.ok())
90 .unwrap_or(default_hfs);
91 Self {
92 protect_ntfs,
93 protect_hfs,
94 }
95 }
96
97 #[must_use]
99 pub const fn fsck() -> Self {
100 Self {
101 protect_ntfs: true,
102 protect_hfs: true,
103 }
104 }
105}
106
107#[non_exhaustive]
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
110pub enum PathComponentError {
111 #[error("path component is empty")]
113 Empty,
114 #[error("path component is a single dot")]
116 Dot,
117 #[error("path component is parent directory")]
119 DotDot,
120 #[error("path component is a .git alias")]
122 DotGit,
123 #[error("path component contains a slash")]
125 ContainsSlash,
126 #[error("path component contains a NUL byte")]
128 ContainsNul,
129 #[error("path component is an HFS .git alias")]
131 HfsDotGit,
132 #[error("path component is an NTFS .git alias")]
134 NtfsDotGit,
135 #[error("path component contains a backslash before a .git alias")]
137 NtfsBackslash,
138 #[error("path component uses a forbidden NTFS drive or stream prefix")]
140 NtfsDriveOrStream,
141 #[error("path component contains a forbidden NTFS character")]
143 NtfsIllegalCharacter,
144 #[error("path component ends with a space or period")]
146 NtfsTrailingSpaceOrPeriod,
147 #[error("path component contains a reserved DOS device name")]
149 NtfsReservedDeviceName,
150 #[error("symbolic link must not be named .gitmodules")]
152 SymlinkedDotGitmodules,
153 #[error("symbolic link must not be named .gitattributes")]
155 SymlinkedDotGitattributes,
156 #[error("symbolic link must not be named .gitignore")]
158 SymlinkedDotGitignore,
159 #[error("symbolic link must not be named .mailmap")]
161 SymlinkedDotMailmap,
162}
163
164#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
166pub struct TreeEntryNameAudit {
167 pub empty: bool,
169 pub dot: bool,
171 pub dotdot: bool,
173 pub slash: bool,
175 pub nul: bool,
177 pub dotgit: bool,
179}
180
181impl TreeEntryNameAudit {
182 pub fn absorb(&mut self, other: Self) {
184 self.empty |= other.empty;
185 self.dot |= other.dot;
186 self.dotdot |= other.dotdot;
187 self.slash |= other.slash;
188 self.nul |= other.nul;
189 self.dotgit |= other.dotgit;
190 }
191}
192
193pub fn validate_component(
200 name: &[u8],
201 mode: EntryMode,
202 opts: ProtectOptions,
203 kind: PathValidationKind,
204) -> Result<(), PathComponentError> {
205 if name.is_empty() {
206 return Err(PathComponentError::Empty);
207 }
208 if name.contains(&0) {
209 return Err(PathComponentError::ContainsNul);
210 }
211 if name.contains(&b'/') {
212 return Err(PathComponentError::ContainsSlash);
213 }
214 if let Some(err) = validate_component_symlink_dotfile(name, mode, opts, kind) {
215 return Err(err);
216 }
217 if opts.protect_ntfs && ntfs_path_syntax_rules_apply() {
218 if has_dos_drive_prefix_bytes(name) {
219 return Err(PathComponentError::NtfsDriveOrStream);
220 }
221 validate_win32_path_segment(name)?;
222 if let Ok(name_str) = std::str::from_utf8(name) {
223 if scan_ntfs_backslash_dot_special(name_str, ntfs_backslash_dotdot_tail) {
224 return Err(PathComponentError::NtfsBackslash);
225 }
226 }
227 }
228 let audit = audit_tree_entry_name(name, opts);
229 if audit.empty {
230 return Err(PathComponentError::Empty);
231 }
232 if audit.slash {
233 return Err(PathComponentError::ContainsSlash);
234 }
235 if audit.nul {
236 return Err(PathComponentError::ContainsNul);
237 }
238 if audit.dot {
239 return Err(PathComponentError::Dot);
240 }
241 if audit.dotdot {
242 return Err(PathComponentError::DotDot);
243 }
244 if audit.dotgit {
245 return Err(classify_dotgit_component_error(name, opts));
246 }
247 Ok(())
248}
249
250pub fn validate_path(
256 path: &[u8],
257 mode: EntryMode,
258 opts: ProtectOptions,
259 kind: PathValidationKind,
260) -> Result<(), PathComponentError> {
261 if path.is_empty() {
262 return Err(PathComponentError::Empty);
263 }
264 if path.contains(&0) {
265 return Err(PathComponentError::ContainsNul);
266 }
267 for component in path.split(|&b| b == b'/') {
268 if component.is_empty() {
269 return Err(PathComponentError::Empty);
270 }
271 validate_component(component, mode, opts, kind)?;
272 }
273 Ok(())
274}
275
276fn ntfs_path_syntax_rules_apply() -> bool {
277 cfg!(windows)
278}
279
280#[must_use]
282pub fn audit_tree_entry_name(name: &[u8], opts: ProtectOptions) -> TreeEntryNameAudit {
283 let mut audit = TreeEntryNameAudit::default();
284 if name.is_empty() {
285 audit.empty = true;
286 return audit;
287 }
288 if name.contains(&0) {
289 audit.nul = true;
290 }
291 if name == b"." {
292 audit.dot = true;
293 }
294 if name == b".." {
295 audit.dotdot = true;
296 }
297 if name.contains(&b'/') {
298 audit.slash = true;
299 }
300 if component_is_dotgit_alias(name, opts) {
301 audit.dotgit = true;
302 }
303 audit
304}
305
306impl TreeEntryNameAudit {
307 pub fn first_error(self) -> Result<(), PathComponentError> {
309 if self.empty {
310 return Err(PathComponentError::Empty);
311 }
312 if self.slash {
313 return Err(PathComponentError::ContainsSlash);
314 }
315 if self.nul {
316 return Err(PathComponentError::ContainsNul);
317 }
318 if self.dot {
319 return Err(PathComponentError::Dot);
320 }
321 if self.dotdot {
322 return Err(PathComponentError::DotDot);
323 }
324 if self.dotgit {
325 return Err(PathComponentError::DotGit);
326 }
327 Ok(())
328 }
329}
330
331#[must_use]
333pub fn component_is_dotgit_alias(name: &[u8], opts: ProtectOptions) -> bool {
334 if name.eq_ignore_ascii_case(b".git") {
335 return true;
336 }
337 if !may_alias_dotgit(name) {
338 return false;
339 }
340 let Ok(name_str) = std::str::from_utf8(name) else {
341 return false;
342 };
343 if opts.protect_hfs && is_hfs_dotgit(name_str) {
344 return true;
345 }
346 if opts.protect_ntfs && (is_ntfs_dotgit(name_str) || scan_ntfs_backslash_dotgit(name_str)) {
347 return true;
348 }
349 false
350}
351
352fn may_alias_dotgit(name: &[u8]) -> bool {
356 matches!(name.first(), Some(b'.' | b'g' | b'G' | b'~' | 0x80..)) || name.contains(&b'\\')
357}
358
359fn validate_component_symlink_dotfile(
360 name: &[u8],
361 mode: EntryMode,
362 opts: ProtectOptions,
363 kind: PathValidationKind,
364) -> Option<PathComponentError> {
365 if !mode.is_symlink() {
366 return None;
367 }
368 if name.eq_ignore_ascii_case(b".gitmodules") {
369 return Some(PathComponentError::SymlinkedDotGitmodules);
370 }
371 let Ok(name_str) = std::str::from_utf8(name) else {
372 return None;
373 };
374 if dot_gitmodules_spelling(name_str, opts) {
375 return Some(PathComponentError::SymlinkedDotGitmodules);
376 }
377 if kind == PathValidationKind::Worktree {
378 return None;
379 }
380 if name.eq_ignore_ascii_case(b".gitattributes") {
381 return Some(PathComponentError::SymlinkedDotGitattributes);
382 }
383 if name.eq_ignore_ascii_case(b".gitignore") {
384 return Some(PathComponentError::SymlinkedDotGitignore);
385 }
386 if name.eq_ignore_ascii_case(b".mailmap") {
387 return Some(PathComponentError::SymlinkedDotMailmap);
388 }
389 if dot_gitattributes_spelling(name_str, opts) {
390 return Some(PathComponentError::SymlinkedDotGitattributes);
391 }
392 if opts.protect_hfs && is_hfs_dot_gitignore(name_str) {
393 return Some(PathComponentError::SymlinkedDotGitignore);
394 }
395 if opts.protect_hfs && is_hfs_dot_mailmap(name_str) {
396 return Some(PathComponentError::SymlinkedDotMailmap);
397 }
398 if opts.protect_ntfs && is_ntfs_dot_gitignore(name_str) {
399 return Some(PathComponentError::SymlinkedDotGitignore);
400 }
401 if opts.protect_ntfs && is_ntfs_dot_mailmap(name_str) {
402 return Some(PathComponentError::SymlinkedDotMailmap);
403 }
404 None
405}
406
407fn dot_gitmodules_spelling(name: &str, opts: ProtectOptions) -> bool {
408 (opts.protect_hfs && is_hfs_dot_gitmodules(name))
409 || (opts.protect_ntfs && is_ntfs_dot_gitmodules(name))
410 || (opts.protect_ntfs && scan_ntfs_backslash_dot_special(name, is_ntfs_dot_gitmodules))
411}
412
413fn dot_gitattributes_spelling(name: &str, opts: ProtectOptions) -> bool {
414 (opts.protect_hfs && is_hfs_dot_gitattributes(name))
415 || (opts.protect_ntfs && is_ntfs_dot_gitattributes(name))
416 || (opts.protect_ntfs && scan_ntfs_backslash_dot_special(name, is_ntfs_dot_gitattributes))
417}
418
419fn classify_dotgit_component_error(name: &[u8], opts: ProtectOptions) -> PathComponentError {
420 if name.eq_ignore_ascii_case(b".git") || name.eq_ignore_ascii_case(b"git~1") {
421 return PathComponentError::DotGit;
422 }
423 let Ok(name_str) = std::str::from_utf8(name) else {
424 return PathComponentError::DotGit;
425 };
426 if opts.protect_ntfs && scan_ntfs_backslash_dotgit(name_str) {
427 return PathComponentError::NtfsBackslash;
428 }
429 if opts.protect_hfs && is_hfs_dotgit(name_str) {
430 return PathComponentError::HfsDotGit;
431 }
432 if opts.protect_ntfs && is_ntfs_dotgit(name_str) {
433 return PathComponentError::NtfsDotGit;
434 }
435 PathComponentError::DotGit
436}
437
438fn scan_ntfs_backslash_dotgit(name: &str) -> bool {
439 scan_ntfs_backslash_dot_special(name, is_ntfs_dotgit)
440}
441
442fn ntfs_backslash_dotdot_tail(after: &str) -> bool {
443 after == ".." || after.starts_with("..\\") || after.starts_with("../")
444}
445
446fn scan_ntfs_backslash_dot_special(name: &str, mut pred: impl FnMut(&str) -> bool) -> bool {
447 if !name.contains('\\') {
448 return false;
449 }
450 let mut rest = name;
451 while let Some(idx) = rest.find('\\') {
452 let segment = &rest[..idx];
453 if !segment.is_empty() && pred(segment) {
454 return true;
455 }
456 rest = &rest[idx + 1..];
457 }
458 !rest.is_empty() && pred(rest)
459}
460
461fn has_dos_drive_prefix_bytes(name: &[u8]) -> bool {
462 name.len() >= 2 && name[0].is_ascii_alphabetic() && name[1] == b':'
463}
464
465fn validate_win32_path_segment(path: &[u8]) -> Result<(), PathComponentError> {
470 if path.is_empty() {
471 return Ok(());
472 }
473 let mut start = 0usize;
474 for (offset, &byte) in path.iter().enumerate() {
475 if byte == b'\\' || byte == b'/' {
476 validate_win32_path_piece(&path[start..offset])?;
477 start = offset + 1;
478 }
479 }
480 validate_win32_path_piece(&path[start..])
481}
482
483fn validate_win32_path_piece(piece: &[u8]) -> Result<(), PathComponentError> {
484 if piece.is_empty() {
485 return Ok(());
486 }
487 if piece.iter().any(|&b| win32_forbidden_path_byte(b)) {
488 return Err(PathComponentError::NtfsIllegalCharacter);
489 }
490 if win32_piece_has_bad_trailing_space_or_dot(piece) {
491 return Err(PathComponentError::NtfsTrailingSpaceOrPeriod);
492 }
493 if win32_piece_starts_with_reserved_device(piece) {
494 return Err(PathComponentError::NtfsReservedDeviceName);
495 }
496 Ok(())
497}
498
499const WIN32_FORBIDDEN_BYTES: [u8; 7] = [b':', b'<', b'>', 0x22, b'|', b'?', b'*'];
500
501fn win32_forbidden_path_byte(byte: u8) -> bool {
502 WIN32_FORBIDDEN_BYTES.contains(&byte) || (byte > 0 && byte < 0x20)
503}
504
505fn win32_piece_has_bad_trailing_space_or_dot(piece: &[u8]) -> bool {
507 if piece == b"." || piece == b".." {
508 return false;
509 }
510 matches!(piece.last(), Some(b' ' | b'.'))
511}
512
513fn win32_ascii_prefix_eq(piece: &[u8], prefix: &[u8]) -> bool {
514 piece.len() >= prefix.len()
515 && piece
516 .iter()
517 .zip(prefix.iter())
518 .all(|(a, b)| a.eq_ignore_ascii_case(b))
519}
520
521fn win32_device_stem_len(piece: &[u8]) -> Option<usize> {
523 const THREE: &[&[u8]] = &[b"aux", b"prn", b"nul"];
524 for stem in THREE {
525 if win32_ascii_prefix_eq(piece, stem) {
526 return Some(stem.len());
527 }
528 }
529 if win32_ascii_prefix_eq(piece, b"conin$") {
530 return Some(6);
531 }
532 if win32_ascii_prefix_eq(piece, b"conout$") {
533 return Some(7);
534 }
535 if piece.len() >= 3 && win32_ascii_prefix_eq(piece, b"con") {
536 return Some(3);
537 }
538 if piece.len() >= 4
539 && win32_ascii_prefix_eq(&piece[..3], b"com")
540 && piece[3].is_ascii_digit()
541 && (b'1'..=b'9').contains(&piece[3])
542 {
543 return Some(4);
544 }
545 if piece.len() >= 4 && win32_ascii_prefix_eq(&piece[..3], b"lpt") && piece[3].is_ascii_digit() {
546 return Some(4);
547 }
548 None
549}
550
551fn win32_piece_starts_with_reserved_device(piece: &[u8]) -> bool {
552 let Some(stem_len) = win32_device_stem_len(piece) else {
553 return false;
554 };
555 let mut tail = stem_len;
556 while tail < piece.len() && piece[tail] == b' ' {
557 tail += 1;
558 }
559 match piece.get(tail) {
560 None => true,
561 Some(b'.' | b':' | b'/' | b'\\') => true,
562 Some(_) => false,
563 }
564}
565
566#[cfg(test)]
567mod tests {
568 use super::*;
569
570 const BLOB: EntryMode = EntryMode::new(0o100644);
571 const SYMLINK: EntryMode = EntryMode::new(0o120000);
572 const FSCK: ProtectOptions = ProtectOptions::fsck();
573 const OFF: ProtectOptions = ProtectOptions {
574 protect_ntfs: false,
575 protect_hfs: false,
576 };
577 const FSCK_KIND: PathValidationKind = PathValidationKind::Fsck;
578 const WT_KIND: PathValidationKind = PathValidationKind::Worktree;
579
580 #[test]
581 fn empty_dot_and_dotdot_rejected() {
582 assert_eq!(
583 validate_component(b"", BLOB, FSCK, FSCK_KIND).unwrap_err(),
584 PathComponentError::Empty
585 );
586 assert_eq!(
587 validate_component(b".", BLOB, FSCK, FSCK_KIND).unwrap_err(),
588 PathComponentError::Dot
589 );
590 assert_eq!(
591 validate_component(b"..", BLOB, FSCK, FSCK_KIND).unwrap_err(),
592 PathComponentError::DotDot
593 );
594 }
595
596 #[test]
597 fn slash_and_nul_in_component() {
598 assert_eq!(
599 validate_component(b"foo/bar", BLOB, FSCK, FSCK_KIND).unwrap_err(),
600 PathComponentError::ContainsSlash
601 );
602 assert_eq!(
603 validate_component(b"foo\0bar", BLOB, FSCK, FSCK_KIND).unwrap_err(),
604 PathComponentError::ContainsNul
605 );
606 }
607
608 #[test]
609 fn ascii_dotgit_and_git_tilde_one() {
610 for name in [b".git".as_slice(), b".GIT".as_slice()] {
611 assert_eq!(
612 validate_component(name, BLOB, OFF, FSCK_KIND).unwrap_err(),
613 PathComponentError::DotGit
614 );
615 }
616 for name in [b"git~1".as_slice(), b"GIT~1".as_slice()] {
617 assert_eq!(
618 validate_component(name, BLOB, FSCK, FSCK_KIND).unwrap_err(),
619 PathComponentError::DotGit
620 );
621 assert!(validate_component(name, BLOB, OFF, FSCK_KIND).is_ok());
622 }
623 assert!(validate_component(b"git~2", BLOB, OFF, FSCK_KIND).is_ok());
624 }
625
626 #[test]
627 fn hfs_dotgit_unicode_ignored_codepoints() {
628 let name = ".gI\u{200c}T";
629 assert_eq!(
630 validate_component(name.as_bytes(), BLOB, FSCK, FSCK_KIND).unwrap_err(),
631 PathComponentError::HfsDotGit
632 );
633 assert!(validate_component(name.as_bytes(), BLOB, OFF, FSCK_KIND).is_ok());
634 }
635
636 #[test]
637 fn ntfs_dotgit_trailing_dot_and_spaces() {
638 assert_eq!(
639 validate_component(b".git.", BLOB, FSCK, FSCK_KIND).unwrap_err(),
640 PathComponentError::NtfsDotGit
641 );
642 assert_eq!(
643 validate_component(b".git ", BLOB, FSCK, FSCK_KIND).unwrap_err(),
644 PathComponentError::NtfsDotGit
645 );
646 }
647
648 #[test]
649 fn ntfs_git_tilde_one_with_trailing_spaces_or_periods() {
650 for name in [
651 b"GIT~1.".as_slice(),
652 b"git~1 ",
653 b"git~1...",
654 b"git~1::$DATA",
655 ] {
656 for kind in [FSCK_KIND, WT_KIND] {
657 let err = validate_component(name, BLOB, FSCK, kind).unwrap_err();
658 if !cfg!(windows) {
661 assert_eq!(err, PathComponentError::NtfsDotGit, "reject {name:?}");
662 }
663 }
664 assert!(audit_tree_entry_name(name, FSCK).dotgit);
665 if !cfg!(windows) {
666 assert!(validate_component(name, BLOB, OFF, FSCK_KIND).is_ok());
667 assert!(validate_component(name, BLOB, OFF, WT_KIND).is_ok());
668 }
669 }
670 assert!(validate_path(b"GIT~1./marker.txt", BLOB, FSCK, WT_KIND).is_err());
671 assert!(validate_component(b"git~1.txt", BLOB, FSCK, WT_KIND).is_ok());
672 }
673
674 #[test]
675 fn dotgit_prefilter_admits_every_alias_spelling() {
676 for name in [
677 ".git".as_bytes(),
678 b".GIT.",
679 b"git~1",
680 b"GIT~1 .",
681 b"g7c2~123",
682 b"~1234567",
683 ".gI\u{200c}T".as_bytes(),
684 "\u{200c}.git".as_bytes(),
685 b"foo\\.git",
686 b"x\\git~1",
687 ] {
688 assert!(may_alias_dotgit(name), "pre-filter must admit {name:?}");
689 assert!(
690 component_is_dotgit_alias(name, FSCK),
691 "{name:?} is a .git alias"
692 );
693 }
694 for name in [
695 b"src".as_slice(),
696 b"README.md",
697 b"Cargo.toml",
698 b"_git",
699 b"1git~1",
700 ] {
701 assert!(!may_alias_dotgit(name), "pre-filter skips {name:?}");
702 }
703 }
704
705 #[test]
706 fn ntfs_backslash_dotgit_segment() {
707 assert_eq!(
708 validate_component(b"foo\\.git", BLOB, FSCK, FSCK_KIND).unwrap_err(),
709 PathComponentError::NtfsBackslash
710 );
711 for name in [b".git\\config".as_slice(), b".git\\", b".GIT\\config"] {
712 assert_eq!(
713 validate_component(name, BLOB, FSCK, WT_KIND).unwrap_err(),
714 PathComponentError::NtfsBackslash,
715 "reject {name:?}"
716 );
717 }
718 if cfg!(windows) {
719 assert_eq!(
720 validate_component(br"a\..\x", BLOB, FSCK, FSCK_KIND).unwrap_err(),
721 PathComponentError::NtfsBackslash
722 );
723 } else {
724 assert!(validate_component(br"a\..\x", BLOB, FSCK, FSCK_KIND).is_ok());
725 }
726 assert!(validate_component(br"a\..\x", BLOB, OFF, FSCK_KIND).is_ok());
727 }
728
729 #[test]
730 fn ntfs_alternate_stream_dotgit() {
731 assert_eq!(
732 validate_component(b".git::$INDEX_ALLOCATION", BLOB, FSCK, FSCK_KIND).unwrap_err(),
733 PathComponentError::NtfsDotGit
734 );
735 }
736
737 #[test]
738 fn ntfs_dos_drive_prefix_in_component() {
739 if cfg!(windows) {
740 assert_eq!(
741 validate_component(b"C:foo", BLOB, FSCK, FSCK_KIND).unwrap_err(),
742 PathComponentError::NtfsDriveOrStream
743 );
744 } else {
745 assert!(validate_component(b"C:foo", BLOB, FSCK, FSCK_KIND).is_ok());
746 assert!(validate_component(b"C:foo", BLOB, FSCK, WT_KIND).is_ok());
747 }
748 }
749
750 #[test]
751 fn issue_1037_multibyte_at_byte_six_no_panic() {
752 for name in ["abcde日.txt", "ab日本.txt", "file0日本.txt"] {
753 assert!(validate_component(name.as_bytes(), BLOB, FSCK, FSCK_KIND).is_ok());
754 assert!(!audit_tree_entry_name(name.as_bytes(), FSCK).dotgit);
755 }
756 }
757
758 #[test]
759 fn symlink_dot_special_files() {
760 assert_eq!(
761 validate_component(b".gitmodules", SYMLINK, FSCK, FSCK_KIND).unwrap_err(),
762 PathComponentError::SymlinkedDotGitmodules
763 );
764 assert_eq!(
765 validate_component(b".gitattributes", SYMLINK, FSCK, FSCK_KIND).unwrap_err(),
766 PathComponentError::SymlinkedDotGitattributes
767 );
768 assert_eq!(
769 validate_component(b".gitignore", SYMLINK, FSCK, FSCK_KIND).unwrap_err(),
770 PathComponentError::SymlinkedDotGitignore
771 );
772 assert_eq!(
773 validate_component(b".mailmap", SYMLINK, FSCK, FSCK_KIND).unwrap_err(),
774 PathComponentError::SymlinkedDotMailmap
775 );
776 assert!(validate_component(b".gitmodules", BLOB, FSCK, FSCK_KIND).is_ok());
777 assert!(validate_component(b".gitattributes", SYMLINK, FSCK, WT_KIND).is_ok());
778 }
779
780 #[test]
781 fn symlink_ascii_dot_special_banned_when_protect_flags_off() {
782 assert_eq!(
783 validate_component(b".gitmodules", SYMLINK, OFF, FSCK_KIND).unwrap_err(),
784 PathComponentError::SymlinkedDotGitmodules
785 );
786 assert_eq!(
787 validate_component(b".gitignore", SYMLINK, OFF, FSCK_KIND).unwrap_err(),
788 PathComponentError::SymlinkedDotGitignore
789 );
790 }
791
792 #[test]
793 fn dos_drive_prefix_on_non_utf8_bytes() {
794 if cfg!(windows) {
795 assert_eq!(
796 validate_component(b"C:\xff", BLOB, FSCK, FSCK_KIND).unwrap_err(),
797 PathComponentError::NtfsDriveOrStream
798 );
799 } else {
800 assert!(validate_component(b"C:\xff", BLOB, FSCK, FSCK_KIND).is_ok());
801 }
802 }
803
804 #[test]
805 fn validate_path_rejects_empty_slash_delimited_components() {
806 for path in [
807 b"/a".as_slice(),
808 b"a/".as_slice(),
809 b"a//b".as_slice(),
810 b"//a".as_slice(),
811 ] {
812 assert_eq!(
813 validate_path(path, BLOB, FSCK, FSCK_KIND).unwrap_err(),
814 PathComponentError::Empty,
815 "path {:?} should be rejected",
816 path
817 );
818 }
819 assert!(validate_path(b"a/b", BLOB, FSCK, FSCK_KIND).is_ok());
820 }
821
822 #[test]
823 fn validate_path_splits_on_slash() {
824 assert!(validate_path(b"dir/file.txt", BLOB, FSCK, FSCK_KIND).is_ok());
825 assert_eq!(
826 validate_path(b"dir/.git/file", BLOB, FSCK, FSCK_KIND).unwrap_err(),
827 PathComponentError::DotGit
828 );
829 }
830
831 #[test]
832 fn protect_options_from_config_defaults() {
833 let cfg = ConfigSet::new();
834 let opts = ProtectOptions::from_config(&cfg);
835 assert!(opts.protect_ntfs);
836 assert_eq!(opts.protect_hfs, cfg!(target_os = "macos"));
837 }
838
839 #[test]
840 fn audit_matches_validate_for_tree_cases() {
841 let audit = audit_tree_entry_name(b".git", FSCK);
842 assert!(audit.dotgit);
843 assert_eq!(audit.first_error().unwrap_err(), PathComponentError::DotGit);
844 }
845
846 #[test]
848 fn issue_1699_win32_path_segment_matrix() {
849 let illegal_char = |name: &[u8]| {
850 assert_eq!(
851 validate_win32_path_segment(name).unwrap_err(),
852 PathComponentError::NtfsIllegalCharacter,
853 "{name:?}"
854 );
855 };
856 let trailing = |name: &[u8]| {
857 assert_eq!(
858 validate_win32_path_segment(name).unwrap_err(),
859 PathComponentError::NtfsTrailingSpaceOrPeriod,
860 "{name:?}"
861 );
862 };
863 let reserved = |name: &[u8]| {
864 assert_eq!(
865 validate_win32_path_segment(name).unwrap_err(),
866 PathComponentError::NtfsReservedDeviceName,
867 "{name:?}"
868 );
869 };
870
871 illegal_char(b".gitmodules::$DATA");
872 illegal_char(b"hidden.txt:payload");
873 illegal_char(b"evil:stream");
874 trailing(b"README.");
875 trailing(b"sp ");
876 reserved(b"aux.txt");
877 reserved(b"AUX");
878 reserved(b"com1");
879 reserved(b"CONIN$");
880 reserved(b"lpt9");
881
882 assert!(validate_win32_path_segment(b"README.md").is_ok());
883 assert!(validate_win32_path_segment(b".gitmodules").is_ok());
884 assert!(validate_win32_path_segment(b"hidden.txt").is_ok());
885
886 assert_eq!(
888 validate_win32_path_segment(b"legit\\aux.txt").unwrap_err(),
889 PathComponentError::NtfsReservedDeviceName
890 );
891 assert_eq!(
892 validate_win32_path_segment(b"legit\\sp ").unwrap_err(),
893 PathComponentError::NtfsTrailingSpaceOrPeriod
894 );
895 assert!(validate_win32_path_segment(b"legit\\subdir").is_ok());
896 }
897
898 #[test]
899 fn issue_1699_validate_component_on_windows() {
900 if !cfg!(windows) {
901 return;
902 }
903 let opts = FSCK;
904 let err = |name: &[u8]| validate_component(name, BLOB, opts, WT_KIND).unwrap_err();
905 assert_eq!(
906 err(b".gitmodules::$DATA"),
907 PathComponentError::NtfsIllegalCharacter
908 );
909 assert_eq!(
910 err(b"README."),
911 PathComponentError::NtfsTrailingSpaceOrPeriod
912 );
913 assert_eq!(err(b"aux.txt"), PathComponentError::NtfsReservedDeviceName);
914 }
915}