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