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