Skip to main content

grit_lib/
path_validate.rs

1//! Git-compatible path component validation (`verify_path` / tree fsck rules).
2//!
3//! Centralizes NTFS/HFS dot-file checks and tree-entry rules that previously
4//! lived in separate internal modules. Embedders use this before writing index entries,
5//! checking out paths, or building trees; [`ProtectOptions`] mirrors
6//! `core.protectNTFS` and `core.protectHFS`.
7//!
8//! ```rust
9//! use grit_lib::path_validate::{
10//!     validate_path, EntryMode, PathValidationKind, ProtectOptions,
11//! };
12//!
13//! let opts = ProtectOptions::default();
14//! let blob = EntryMode::new(0o100644);
15//! let kind = PathValidationKind::Worktree;
16//! validate_path(b"src/lib.rs", blob, opts, kind).unwrap();
17//! assert!(validate_path(b"GIT~1./hooks", blob, opts, kind).is_err());
18//! ```
19
20use 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/// Git tree/index file mode for validation (symlink vs blob affects dot-file rules).
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
30pub struct EntryMode(u32);
31
32impl EntryMode {
33    /// Wrap a raw Git mode octal value (for example `0o100644`).
34    #[must_use]
35    pub const fn new(mode: u32) -> Self {
36        Self(mode)
37    }
38
39    /// `true` when this entry is a symbolic link (`0o120000`).
40    #[must_use]
41    pub fn is_symlink(self) -> bool {
42        self.0 == 0o120000
43    }
44}
45
46/// Which Git path rules to apply ([`validate_component`] / [`validate_path`]).
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
48pub enum PathValidationKind {
49    /// Object fsck: all symlink dot-file rules; NTFS spellings when [`ProtectOptions`] say so.
50    Fsck,
51    /// Index population, checkout, and staging (Git `verify_path` on this platform).
52    #[default]
53    Worktree,
54}
55
56/// Whether NTFS and HFS+ spelling protections apply (from `core.protectNTFS` / `core.protectHFS`).
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
58pub struct ProtectOptions {
59    /// Reject NTFS `.git` spellings, short names, `\\` segments, and `:` streams.
60    pub protect_ntfs: bool,
61    /// Reject HFS+ Unicode-ignorable `.git` spellings.
62    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    /// Load protection flags from a merged [`ConfigSet`] (no environment reads).
76    ///
77    /// Defaults match Git: `core.protectNTFS` true; `core.protectHFS` true on macOS, false elsewhere.
78    #[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    /// Protection settings used by object fsck (always check both spellings).
90    #[must_use]
91    pub const fn fsck() -> Self {
92        Self {
93            protect_ntfs: true,
94            protect_hfs: true,
95        }
96    }
97}
98
99/// Why a single path component fails validation.
100#[non_exhaustive]
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
102pub enum PathComponentError {
103    /// Zero-length component.
104    #[error("path component is empty")]
105    Empty,
106    /// Component is exactly `.`.
107    #[error("path component is a single dot")]
108    Dot,
109    /// Component is exactly `..`.
110    #[error("path component is parent directory")]
111    DotDot,
112    /// Component names `.git` (ASCII case rules) or `git~1`.
113    #[error("path component is a .git alias")]
114    DotGit,
115    /// A `/` appears inside one component (tree entries must be single segments).
116    #[error("path component contains a slash")]
117    ContainsSlash,
118    /// A NUL byte appears in the component.
119    #[error("path component contains a NUL byte")]
120    ContainsNul,
121    /// HFS+ ignorable code points spell `.git` or a protected dot-file.
122    #[error("path component is an HFS .git alias")]
123    HfsDotGit,
124    /// NTFS trailing space/dot, short name, or stream spelling for `.git`.
125    #[error("path component is an NTFS .git alias")]
126    NtfsDotGit,
127    /// Backslash introduces an NTFS `.git` segment in the component.
128    #[error("path component contains a backslash before a .git alias")]
129    NtfsBackslash,
130    /// DOS drive prefix or NTFS alternate-data-stream syntax in the component.
131    #[error("path component uses a forbidden NTFS drive or stream prefix")]
132    NtfsDriveOrStream,
133    /// Character forbidden on NTFS (`:*?"<>|` or a control character).
134    #[error("path component contains a forbidden NTFS character")]
135    NtfsIllegalCharacter,
136    /// Trailing space or period on a component (except `.` and `..`).
137    #[error("path component ends with a space or period")]
138    NtfsTrailingSpaceOrPeriod,
139    /// DOS device name (`CON`, `AUX`, `COM1`, …) in the component.
140    #[error("path component contains a reserved DOS device name")]
141    NtfsReservedDeviceName,
142    /// Symlink whose name matches `.gitmodules` (HFS or NTFS spelling).
143    #[error("symbolic link must not be named .gitmodules")]
144    SymlinkedDotGitmodules,
145    /// Symlink whose name matches `.gitattributes` (HFS or NTFS spelling).
146    #[error("symbolic link must not be named .gitattributes")]
147    SymlinkedDotGitattributes,
148    /// Symlink whose name matches `.gitignore` (HFS or NTFS spelling).
149    #[error("symbolic link must not be named .gitignore")]
150    SymlinkedDotGitignore,
151    /// Symlink whose name matches `.mailmap` (HFS or NTFS spelling).
152    #[error("symbolic link must not be named .mailmap")]
153    SymlinkedDotMailmap,
154}
155
156/// Semantic problems detected on a tree entry name (fsck aggregates several at once).
157#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
158pub struct TreeEntryNameAudit {
159    /// Empty component.
160    pub empty: bool,
161    /// Component is `.`.
162    pub dot: bool,
163    /// Component is `..`.
164    pub dotdot: bool,
165    /// Slash inside the component.
166    pub slash: bool,
167    /// NUL inside the component.
168    pub nul: bool,
169    /// Any `.git` alias (ASCII, HFS, NTFS, backslash, or `git~1`).
170    pub dotgit: bool,
171}
172
173impl TreeEntryNameAudit {
174    /// Merge another audit into this one (bitwise OR of flags).
175    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
185/// Validate one path component (no `/` splitting).
186///
187/// # Errors
188///
189/// Returns the first [`PathComponentError`] encountered in Git fsck priority order
190/// when multiple rules apply.
191pub 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
242/// Validate a repository-relative path by checking each `/`-separated component.
243///
244/// # Errors
245///
246/// Same as [`validate_component`] for the first failing segment.
247pub 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/// Populate [`TreeEntryNameAudit`] for a tree object entry name (fsck uses all flags).
273#[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    /// Map aggregated flags to a single error (Git fsck reporting order).
300    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/// Returns `true` when `name` should trigger fsck `hasDotgit` for a tree entry.
324#[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
344/// Cheap pre-filter for [`component_is_dotgit_alias`]: NTFS spellings start with `.`, `g`
345/// or `~`, HFS spellings with `.` or an ignorable (non-ASCII) code point, and anything else
346/// can only spell `.git` in a `\`-separated segment.
347fn 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
457/// Windows `core.protectNTFS` rules for one tree/index component.
458///
459/// Tree entry names may embed `\` segment separators on NTFS; each piece is
460/// validated the same way Git does for `verify_path` on Windows.
461fn 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
497/// Rejects trailing space or dot unless the whole piece is `.` or `..`.
498fn 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
513/// Length of a DOS device stem at the start of `piece`, if any.
514fn 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                // On Windows the Win32 segment rules run first and report the trailing dot,
651                // space or `:` instead.
652                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    /// Git `is_valid_win32_path` segment rules (issue #1699); runs on every host.
839    #[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        // Each `\`-separated piece is validated independently (Bugbot #1718).
879        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}