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//! validate_path(
15//!     b"src/lib.rs",
16//!     EntryMode::new(0o100644),
17//!     opts,
18//!     PathValidationKind::Worktree,
19//! )
20//! .unwrap();
21//! ```
22
23use 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/// Git tree/index file mode for validation (symlink vs blob affects dot-file rules).
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
33pub struct EntryMode(u32);
34
35impl EntryMode {
36    /// Wrap a raw Git mode octal value (for example `0o100644`).
37    #[must_use]
38    pub const fn new(mode: u32) -> Self {
39        Self(mode)
40    }
41
42    /// `true` when this entry is a symbolic link (`0o120000`).
43    #[must_use]
44    pub fn is_symlink(self) -> bool {
45        self.0 == 0o120000
46    }
47}
48
49/// Which Git path rules to apply ([`validate_component`] / [`validate_path`]).
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
51pub enum PathValidationKind {
52    /// Object fsck: all symlink dot-file rules; NTFS spellings when [`ProtectOptions`] say so.
53    Fsck,
54    /// Index population, checkout, and staging (Git `verify_path` on this platform).
55    #[default]
56    Worktree,
57}
58
59/// Whether NTFS and HFS+ spelling protections apply (from `core.protectNTFS` / `core.protectHFS`).
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
61pub struct ProtectOptions {
62    /// Reject NTFS `.git` spellings, short names, `\\` segments, and `:` streams.
63    pub protect_ntfs: bool,
64    /// Reject HFS+ Unicode-ignorable `.git` spellings.
65    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    /// Load protection flags from a merged [`ConfigSet`] (no environment reads).
79    ///
80    /// Defaults match Git: `core.protectNTFS` true; `core.protectHFS` true on macOS, false elsewhere.
81    #[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    /// Protection settings used by object fsck (always check both spellings).
101    #[must_use]
102    pub const fn fsck() -> Self {
103        Self {
104            protect_ntfs: true,
105            protect_hfs: true,
106        }
107    }
108}
109
110/// Why a single path component fails validation.
111#[non_exhaustive]
112#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
113pub enum PathComponentError {
114    /// Zero-length component.
115    #[error("path component is empty")]
116    Empty,
117    /// Component is exactly `.`.
118    #[error("path component is a single dot")]
119    Dot,
120    /// Component is exactly `..`.
121    #[error("path component is parent directory")]
122    DotDot,
123    /// Component names `.git` (ASCII case rules) or `git~1`.
124    #[error("path component is a .git alias")]
125    DotGit,
126    /// A `/` appears inside one component (tree entries must be single segments).
127    #[error("path component contains a slash")]
128    ContainsSlash,
129    /// A NUL byte appears in the component.
130    #[error("path component contains a NUL byte")]
131    ContainsNul,
132    /// HFS+ ignorable code points spell `.git` or a protected dot-file.
133    #[error("path component is an HFS .git alias")]
134    HfsDotGit,
135    /// NTFS trailing space/dot, short name, or stream spelling for `.git`.
136    #[error("path component is an NTFS .git alias")]
137    NtfsDotGit,
138    /// Backslash introduces an NTFS `.git` segment in the component.
139    #[error("path component contains a backslash before a .git alias")]
140    NtfsBackslash,
141    /// DOS drive prefix or NTFS alternate-data-stream syntax in the component.
142    #[error("path component uses a forbidden NTFS drive or stream prefix")]
143    NtfsDriveOrStream,
144    /// Symlink whose name matches `.gitmodules` (HFS or NTFS spelling).
145    #[error("symbolic link must not be named .gitmodules")]
146    SymlinkedDotGitmodules,
147    /// Symlink whose name matches `.gitattributes` (HFS or NTFS spelling).
148    #[error("symbolic link must not be named .gitattributes")]
149    SymlinkedDotGitattributes,
150    /// Symlink whose name matches `.gitignore` (HFS or NTFS spelling).
151    #[error("symbolic link must not be named .gitignore")]
152    SymlinkedDotGitignore,
153    /// Symlink whose name matches `.mailmap` (HFS or NTFS spelling).
154    #[error("symbolic link must not be named .mailmap")]
155    SymlinkedDotMailmap,
156}
157
158/// Semantic problems detected on a tree entry name (fsck aggregates several at once).
159#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
160pub struct TreeEntryNameAudit {
161    /// Empty component.
162    pub empty: bool,
163    /// Component is `.`.
164    pub dot: bool,
165    /// Component is `..`.
166    pub dotdot: bool,
167    /// Slash inside the component.
168    pub slash: bool,
169    /// NUL inside the component.
170    pub nul: bool,
171    /// Any `.git` alias (ASCII, HFS, NTFS, backslash, or `git~1`).
172    pub dotgit: bool,
173}
174
175impl TreeEntryNameAudit {
176    /// Merge another audit into this one (bitwise OR of flags).
177    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
187/// Validate one path component (no `/` splitting).
188///
189/// # Errors
190///
191/// Returns the first [`PathComponentError`] encountered in Git fsck priority order
192/// when multiple rules apply.
193pub 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
243/// Validate a repository-relative path by checking each `/`-separated component.
244///
245/// # Errors
246///
247/// Same as [`validate_component`] for the first failing segment.
248pub 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/// Populate [`TreeEntryNameAudit`] for a tree object entry name (fsck uses all flags).
274#[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    /// Map aggregated flags to a single error (Git fsck reporting order).
301    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/// Returns `true` when `name` should trigger fsck `hasDotgit` for a tree entry.
325#[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}