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