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