1use crate::config::ConfigSet;
24use crate::dotfile::{
25 is_hfs_dot_gitattributes, is_hfs_dot_gitignore, is_hfs_dot_gitmodules, is_hfs_dot_mailmap,
26 is_hfs_dotgit, is_ntfs_dot_gitattributes, is_ntfs_dot_gitignore, is_ntfs_dot_gitmodules,
27 is_ntfs_dot_mailmap, is_ntfs_dotgit,
28};
29use thiserror::Error;
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
33pub struct EntryMode(u32);
34
35impl EntryMode {
36 #[must_use]
38 pub const fn new(mode: u32) -> Self {
39 Self(mode)
40 }
41
42 #[must_use]
44 pub fn is_symlink(self) -> bool {
45 self.0 == 0o120000
46 }
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
51pub enum PathValidationKind {
52 Fsck,
54 #[default]
56 Worktree,
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
61pub struct ProtectOptions {
62 pub protect_ntfs: bool,
64 pub protect_hfs: bool,
66}
67
68impl Default for ProtectOptions {
69 fn default() -> Self {
70 Self {
71 protect_ntfs: true,
72 protect_hfs: cfg!(target_os = "macos"),
73 }
74 }
75}
76
77impl ProtectOptions {
78 #[must_use]
82 pub fn from_config(config: &ConfigSet) -> Self {
83 let protect_ntfs = config
84 .get_bool("core.protectNTFS")
85 .or_else(|| config.get_bool("core.protectntfs"))
86 .and_then(|r| r.ok())
87 .unwrap_or(true);
88 let default_hfs = cfg!(target_os = "macos");
89 let protect_hfs = config
90 .get_bool("core.protectHFS")
91 .or_else(|| config.get_bool("core.protecthfs"))
92 .and_then(|r| r.ok())
93 .unwrap_or(default_hfs);
94 Self {
95 protect_ntfs,
96 protect_hfs,
97 }
98 }
99
100 #[must_use]
102 pub const fn fsck() -> Self {
103 Self {
104 protect_ntfs: true,
105 protect_hfs: true,
106 }
107 }
108}
109
110#[non_exhaustive]
112#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
113pub enum PathComponentError {
114 #[error("path component is empty")]
116 Empty,
117 #[error("path component is a single dot")]
119 Dot,
120 #[error("path component is parent directory")]
122 DotDot,
123 #[error("path component is a .git alias")]
125 DotGit,
126 #[error("path component contains a slash")]
128 ContainsSlash,
129 #[error("path component contains a NUL byte")]
131 ContainsNul,
132 #[error("path component is an HFS .git alias")]
134 HfsDotGit,
135 #[error("path component is an NTFS .git alias")]
137 NtfsDotGit,
138 #[error("path component contains a backslash before a .git alias")]
140 NtfsBackslash,
141 #[error("path component uses a forbidden NTFS drive or stream prefix")]
143 NtfsDriveOrStream,
144 #[error("symbolic link must not be named .gitmodules")]
146 SymlinkedDotGitmodules,
147 #[error("symbolic link must not be named .gitattributes")]
149 SymlinkedDotGitattributes,
150 #[error("symbolic link must not be named .gitignore")]
152 SymlinkedDotGitignore,
153 #[error("symbolic link must not be named .mailmap")]
155 SymlinkedDotMailmap,
156}
157
158#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
160pub struct TreeEntryNameAudit {
161 pub empty: bool,
163 pub dot: bool,
165 pub dotdot: bool,
167 pub slash: bool,
169 pub nul: bool,
171 pub dotgit: bool,
173}
174
175impl TreeEntryNameAudit {
176 pub fn absorb(&mut self, other: Self) {
178 self.empty |= other.empty;
179 self.dot |= other.dot;
180 self.dotdot |= other.dotdot;
181 self.slash |= other.slash;
182 self.nul |= other.nul;
183 self.dotgit |= other.dotgit;
184 }
185}
186
187pub fn validate_component(
194 name: &[u8],
195 mode: EntryMode,
196 opts: ProtectOptions,
197 kind: PathValidationKind,
198) -> Result<(), PathComponentError> {
199 if name.is_empty() {
200 return Err(PathComponentError::Empty);
201 }
202 if name.contains(&0) {
203 return Err(PathComponentError::ContainsNul);
204 }
205 if name.contains(&b'/') {
206 return Err(PathComponentError::ContainsSlash);
207 }
208 if let Some(err) = validate_component_symlink_dotfile(name, mode, opts, kind) {
209 return Err(err);
210 }
211 if opts.protect_ntfs && ntfs_path_syntax_rules_apply() {
212 if has_dos_drive_prefix_bytes(name) {
213 return Err(PathComponentError::NtfsDriveOrStream);
214 }
215 if let Ok(name_str) = std::str::from_utf8(name) {
216 if scan_ntfs_backslash_dot_special(name_str, ntfs_backslash_dotdot_tail) {
217 return Err(PathComponentError::NtfsBackslash);
218 }
219 }
220 }
221 let audit = audit_tree_entry_name(name, opts);
222 if audit.empty {
223 return Err(PathComponentError::Empty);
224 }
225 if audit.slash {
226 return Err(PathComponentError::ContainsSlash);
227 }
228 if audit.nul {
229 return Err(PathComponentError::ContainsNul);
230 }
231 if audit.dot {
232 return Err(PathComponentError::Dot);
233 }
234 if audit.dotdot {
235 return Err(PathComponentError::DotDot);
236 }
237 if audit.dotgit {
238 return Err(classify_dotgit_component_error(name, opts));
239 }
240 Ok(())
241}
242
243pub fn validate_path(
249 path: &[u8],
250 mode: EntryMode,
251 opts: ProtectOptions,
252 kind: PathValidationKind,
253) -> Result<(), PathComponentError> {
254 if path.is_empty() {
255 return Err(PathComponentError::Empty);
256 }
257 if path.contains(&0) {
258 return Err(PathComponentError::ContainsNul);
259 }
260 for component in path.split(|&b| b == b'/') {
261 if component.is_empty() {
262 return Err(PathComponentError::Empty);
263 }
264 validate_component(component, mode, opts, kind)?;
265 }
266 Ok(())
267}
268
269fn ntfs_path_syntax_rules_apply() -> bool {
270 cfg!(windows)
271}
272
273#[must_use]
275pub fn audit_tree_entry_name(name: &[u8], opts: ProtectOptions) -> TreeEntryNameAudit {
276 let mut audit = TreeEntryNameAudit::default();
277 if name.is_empty() {
278 audit.empty = true;
279 return audit;
280 }
281 if name.contains(&0) {
282 audit.nul = true;
283 }
284 if name == b"." {
285 audit.dot = true;
286 }
287 if name == b".." {
288 audit.dotdot = true;
289 }
290 if name.contains(&b'/') {
291 audit.slash = true;
292 }
293 if component_is_dotgit_alias(name, opts) {
294 audit.dotgit = true;
295 }
296 audit
297}
298
299impl TreeEntryNameAudit {
300 pub fn first_error(self) -> Result<(), PathComponentError> {
302 if self.empty {
303 return Err(PathComponentError::Empty);
304 }
305 if self.slash {
306 return Err(PathComponentError::ContainsSlash);
307 }
308 if self.nul {
309 return Err(PathComponentError::ContainsNul);
310 }
311 if self.dot {
312 return Err(PathComponentError::Dot);
313 }
314 if self.dotdot {
315 return Err(PathComponentError::DotDot);
316 }
317 if self.dotgit {
318 return Err(PathComponentError::DotGit);
319 }
320 Ok(())
321 }
322}
323
324#[must_use]
326pub fn component_is_dotgit_alias(name: &[u8], opts: ProtectOptions) -> bool {
327 if name.eq_ignore_ascii_case(b".git") {
328 return true;
329 }
330 if name.eq_ignore_ascii_case(b"git~1") && opts.protect_ntfs {
331 return true;
332 }
333 let Ok(name_str) = std::str::from_utf8(name) else {
334 return false;
335 };
336 if opts.protect_hfs && is_hfs_dotgit(name_str) {
337 return true;
338 }
339 if opts.protect_ntfs && (is_ntfs_dotgit(name_str) || scan_ntfs_backslash_dotgit(name_str)) {
340 return true;
341 }
342 false
343}
344
345fn validate_component_symlink_dotfile(
346 name: &[u8],
347 mode: EntryMode,
348 opts: ProtectOptions,
349 kind: PathValidationKind,
350) -> Option<PathComponentError> {
351 if !mode.is_symlink() {
352 return None;
353 }
354 if name.eq_ignore_ascii_case(b".gitmodules") {
355 return Some(PathComponentError::SymlinkedDotGitmodules);
356 }
357 let Ok(name_str) = std::str::from_utf8(name) else {
358 return None;
359 };
360 if dot_gitmodules_spelling(name_str, opts) {
361 return Some(PathComponentError::SymlinkedDotGitmodules);
362 }
363 if kind == PathValidationKind::Worktree {
364 return None;
365 }
366 if name.eq_ignore_ascii_case(b".gitattributes") {
367 return Some(PathComponentError::SymlinkedDotGitattributes);
368 }
369 if name.eq_ignore_ascii_case(b".gitignore") {
370 return Some(PathComponentError::SymlinkedDotGitignore);
371 }
372 if name.eq_ignore_ascii_case(b".mailmap") {
373 return Some(PathComponentError::SymlinkedDotMailmap);
374 }
375 if dot_gitattributes_spelling(name_str, opts) {
376 return Some(PathComponentError::SymlinkedDotGitattributes);
377 }
378 if opts.protect_hfs && is_hfs_dot_gitignore(name_str) {
379 return Some(PathComponentError::SymlinkedDotGitignore);
380 }
381 if opts.protect_hfs && is_hfs_dot_mailmap(name_str) {
382 return Some(PathComponentError::SymlinkedDotMailmap);
383 }
384 if opts.protect_ntfs && is_ntfs_dot_gitignore(name_str) {
385 return Some(PathComponentError::SymlinkedDotGitignore);
386 }
387 if opts.protect_ntfs && is_ntfs_dot_mailmap(name_str) {
388 return Some(PathComponentError::SymlinkedDotMailmap);
389 }
390 None
391}
392
393fn dot_gitmodules_spelling(name: &str, opts: ProtectOptions) -> bool {
394 (opts.protect_hfs && is_hfs_dot_gitmodules(name))
395 || (opts.protect_ntfs && is_ntfs_dot_gitmodules(name))
396 || (opts.protect_ntfs && scan_ntfs_backslash_dot_special(name, is_ntfs_dot_gitmodules))
397}
398
399fn dot_gitattributes_spelling(name: &str, opts: ProtectOptions) -> bool {
400 (opts.protect_hfs && is_hfs_dot_gitattributes(name))
401 || (opts.protect_ntfs && is_ntfs_dot_gitattributes(name))
402 || (opts.protect_ntfs && scan_ntfs_backslash_dot_special(name, is_ntfs_dot_gitattributes))
403}
404
405fn classify_dotgit_component_error(name: &[u8], opts: ProtectOptions) -> PathComponentError {
406 if name.eq_ignore_ascii_case(b".git") || name.eq_ignore_ascii_case(b"git~1") {
407 return PathComponentError::DotGit;
408 }
409 let Ok(name_str) = std::str::from_utf8(name) else {
410 return PathComponentError::DotGit;
411 };
412 if opts.protect_ntfs && scan_ntfs_backslash_dotgit(name_str) {
413 return PathComponentError::NtfsBackslash;
414 }
415 if opts.protect_hfs && is_hfs_dotgit(name_str) {
416 return PathComponentError::HfsDotGit;
417 }
418 if opts.protect_ntfs && is_ntfs_dotgit(name_str) {
419 return PathComponentError::NtfsDotGit;
420 }
421 PathComponentError::DotGit
422}
423
424fn scan_ntfs_backslash_dotgit(name: &str) -> bool {
425 scan_ntfs_backslash_dot_special(name, is_ntfs_dotgit)
426}
427
428fn ntfs_backslash_dotdot_tail(after: &str) -> bool {
429 after == ".." || after.starts_with("..\\") || after.starts_with("../")
430}
431
432fn scan_ntfs_backslash_dot_special(name: &str, mut pred: impl FnMut(&str) -> bool) -> bool {
433 if !name.contains('\\') {
434 return false;
435 }
436 let mut rest = name;
437 while let Some(idx) = rest.find('\\') {
438 let segment = &rest[..idx];
439 if !segment.is_empty() && pred(segment) {
440 return true;
441 }
442 rest = &rest[idx + 1..];
443 }
444 !rest.is_empty() && pred(rest)
445}
446
447fn has_dos_drive_prefix_bytes(name: &[u8]) -> bool {
448 name.len() >= 2 && name[0].is_ascii_alphabetic() && name[1] == b':'
449}
450
451#[cfg(test)]
452mod tests {
453 use super::*;
454
455 const BLOB: EntryMode = EntryMode::new(0o100644);
456 const SYMLINK: EntryMode = EntryMode::new(0o120000);
457 const FSCK: ProtectOptions = ProtectOptions::fsck();
458 const OFF: ProtectOptions = ProtectOptions {
459 protect_ntfs: false,
460 protect_hfs: false,
461 };
462 const FSCK_KIND: PathValidationKind = PathValidationKind::Fsck;
463 const WT_KIND: PathValidationKind = PathValidationKind::Worktree;
464
465 #[test]
466 fn empty_dot_and_dotdot_rejected() {
467 assert_eq!(
468 validate_component(b"", BLOB, FSCK, FSCK_KIND).unwrap_err(),
469 PathComponentError::Empty
470 );
471 assert_eq!(
472 validate_component(b".", BLOB, FSCK, FSCK_KIND).unwrap_err(),
473 PathComponentError::Dot
474 );
475 assert_eq!(
476 validate_component(b"..", BLOB, FSCK, FSCK_KIND).unwrap_err(),
477 PathComponentError::DotDot
478 );
479 }
480
481 #[test]
482 fn slash_and_nul_in_component() {
483 assert_eq!(
484 validate_component(b"foo/bar", BLOB, FSCK, FSCK_KIND).unwrap_err(),
485 PathComponentError::ContainsSlash
486 );
487 assert_eq!(
488 validate_component(b"foo\0bar", BLOB, FSCK, FSCK_KIND).unwrap_err(),
489 PathComponentError::ContainsNul
490 );
491 }
492
493 #[test]
494 fn ascii_dotgit_and_git_tilde_one() {
495 for name in [b".git".as_slice(), b".GIT".as_slice()] {
496 assert_eq!(
497 validate_component(name, BLOB, OFF, FSCK_KIND).unwrap_err(),
498 PathComponentError::DotGit
499 );
500 }
501 for name in [b"git~1".as_slice(), b"GIT~1".as_slice()] {
502 assert_eq!(
503 validate_component(name, BLOB, FSCK, FSCK_KIND).unwrap_err(),
504 PathComponentError::DotGit
505 );
506 assert!(validate_component(name, BLOB, OFF, FSCK_KIND).is_ok());
507 }
508 assert!(validate_component(b"git~2", BLOB, OFF, FSCK_KIND).is_ok());
509 }
510
511 #[test]
512 fn hfs_dotgit_unicode_ignored_codepoints() {
513 let name = ".gI\u{200c}T";
514 assert_eq!(
515 validate_component(name.as_bytes(), BLOB, FSCK, FSCK_KIND).unwrap_err(),
516 PathComponentError::HfsDotGit
517 );
518 assert!(validate_component(name.as_bytes(), BLOB, OFF, FSCK_KIND).is_ok());
519 }
520
521 #[test]
522 fn ntfs_dotgit_trailing_dot_and_spaces() {
523 assert_eq!(
524 validate_component(b".git.", BLOB, FSCK, FSCK_KIND).unwrap_err(),
525 PathComponentError::NtfsDotGit
526 );
527 assert_eq!(
528 validate_component(b".git ", BLOB, FSCK, FSCK_KIND).unwrap_err(),
529 PathComponentError::NtfsDotGit
530 );
531 }
532
533 #[test]
534 fn ntfs_backslash_dotgit_segment() {
535 assert_eq!(
536 validate_component(b"foo\\.git", BLOB, FSCK, FSCK_KIND).unwrap_err(),
537 PathComponentError::NtfsBackslash
538 );
539 for name in [b".git\\config".as_slice(), b".git\\", b".GIT\\config"] {
540 assert_eq!(
541 validate_component(name, BLOB, FSCK, WT_KIND).unwrap_err(),
542 PathComponentError::NtfsBackslash,
543 "reject {name:?}"
544 );
545 }
546 if cfg!(windows) {
547 assert_eq!(
548 validate_component(br"a\..\x", BLOB, FSCK, FSCK_KIND).unwrap_err(),
549 PathComponentError::NtfsBackslash
550 );
551 } else {
552 assert!(validate_component(br"a\..\x", BLOB, FSCK, FSCK_KIND).is_ok());
553 }
554 assert!(validate_component(br"a\..\x", BLOB, OFF, FSCK_KIND).is_ok());
555 }
556
557 #[test]
558 fn ntfs_alternate_stream_dotgit() {
559 assert_eq!(
560 validate_component(b".git::$INDEX_ALLOCATION", BLOB, FSCK, FSCK_KIND).unwrap_err(),
561 PathComponentError::NtfsDotGit
562 );
563 }
564
565 #[test]
566 fn ntfs_dos_drive_prefix_in_component() {
567 if cfg!(windows) {
568 assert_eq!(
569 validate_component(b"C:foo", BLOB, FSCK, FSCK_KIND).unwrap_err(),
570 PathComponentError::NtfsDriveOrStream
571 );
572 } else {
573 assert!(validate_component(b"C:foo", BLOB, FSCK, FSCK_KIND).is_ok());
574 assert!(validate_component(b"C:foo", BLOB, FSCK, WT_KIND).is_ok());
575 }
576 }
577
578 #[test]
579 fn issue_1037_multibyte_at_byte_six_no_panic() {
580 for name in ["abcde日.txt", "ab日本.txt", "file0日本.txt"] {
581 assert!(validate_component(name.as_bytes(), BLOB, FSCK, FSCK_KIND).is_ok());
582 assert!(!audit_tree_entry_name(name.as_bytes(), FSCK).dotgit);
583 }
584 }
585
586 #[test]
587 fn symlink_dot_special_files() {
588 assert_eq!(
589 validate_component(b".gitmodules", SYMLINK, FSCK, FSCK_KIND).unwrap_err(),
590 PathComponentError::SymlinkedDotGitmodules
591 );
592 assert_eq!(
593 validate_component(b".gitattributes", SYMLINK, FSCK, FSCK_KIND).unwrap_err(),
594 PathComponentError::SymlinkedDotGitattributes
595 );
596 assert_eq!(
597 validate_component(b".gitignore", SYMLINK, FSCK, FSCK_KIND).unwrap_err(),
598 PathComponentError::SymlinkedDotGitignore
599 );
600 assert_eq!(
601 validate_component(b".mailmap", SYMLINK, FSCK, FSCK_KIND).unwrap_err(),
602 PathComponentError::SymlinkedDotMailmap
603 );
604 assert!(validate_component(b".gitmodules", BLOB, FSCK, FSCK_KIND).is_ok());
605 assert!(validate_component(b".gitattributes", SYMLINK, FSCK, WT_KIND).is_ok());
606 }
607
608 #[test]
609 fn symlink_ascii_dot_special_banned_when_protect_flags_off() {
610 assert_eq!(
611 validate_component(b".gitmodules", SYMLINK, OFF, FSCK_KIND).unwrap_err(),
612 PathComponentError::SymlinkedDotGitmodules
613 );
614 assert_eq!(
615 validate_component(b".gitignore", SYMLINK, OFF, FSCK_KIND).unwrap_err(),
616 PathComponentError::SymlinkedDotGitignore
617 );
618 }
619
620 #[test]
621 fn dos_drive_prefix_on_non_utf8_bytes() {
622 if cfg!(windows) {
623 assert_eq!(
624 validate_component(b"C:\xff", BLOB, FSCK, FSCK_KIND).unwrap_err(),
625 PathComponentError::NtfsDriveOrStream
626 );
627 } else {
628 assert!(validate_component(b"C:\xff", BLOB, FSCK, FSCK_KIND).is_ok());
629 }
630 }
631
632 #[test]
633 fn validate_path_rejects_empty_slash_delimited_components() {
634 for path in [
635 b"/a".as_slice(),
636 b"a/".as_slice(),
637 b"a//b".as_slice(),
638 b"//a".as_slice(),
639 ] {
640 assert_eq!(
641 validate_path(path, BLOB, FSCK, FSCK_KIND).unwrap_err(),
642 PathComponentError::Empty,
643 "path {:?} should be rejected",
644 path
645 );
646 }
647 assert!(validate_path(b"a/b", BLOB, FSCK, FSCK_KIND).is_ok());
648 }
649
650 #[test]
651 fn validate_path_splits_on_slash() {
652 assert!(validate_path(b"dir/file.txt", BLOB, FSCK, FSCK_KIND).is_ok());
653 assert_eq!(
654 validate_path(b"dir/.git/file", BLOB, FSCK, FSCK_KIND).unwrap_err(),
655 PathComponentError::DotGit
656 );
657 }
658
659 #[test]
660 fn protect_options_from_config_defaults() {
661 let cfg = ConfigSet::new();
662 let opts = ProtectOptions::from_config(&cfg);
663 assert!(opts.protect_ntfs);
664 assert_eq!(opts.protect_hfs, cfg!(target_os = "macos"));
665 }
666
667 #[test]
668 fn audit_matches_validate_for_tree_cases() {
669 let audit = audit_tree_entry_name(b".git", FSCK);
670 assert!(audit.dotgit);
671 assert_eq!(audit.first_error().unwrap_err(), PathComponentError::DotGit);
672 }
673}