Skip to main content

grit_lib/
path.rs

1//! Git-compatible verification of tree/index path components.
2//!
3//! Ports Git's `verify_path()` / `verify_dotfile()` (`read-cache.c`) plus the
4//! HFS+ and NTFS `.git` equivalence checks from `path.c`. These guard against
5//! malicious trees whose entries name `.git` (or a filesystem-folding alias of
6//! it) and would otherwise let a `checkout`/`clone` write into the repository's
7//! own `.git` directory (CVE-2014-9390).
8//!
9//! The same primitives are used both when reading a tree into the index
10//! (`read-tree`) and when writing index entries out to the working tree
11//! (`checkout`), so that the byte-writing path is protected even if a crafted
12//! tree bypassed `read-tree`.
13
14use std::path::Path;
15
16use crate::config::ConfigSet;
17use crate::error::Error;
18
19/// Path protection settings from `core.protectHFS` / `core.protectNTFS`.
20#[derive(Clone, Copy, Debug)]
21pub struct PathProtection {
22    /// Reject paths that HFS+ would fold onto `.git` (`core.protectHFS`).
23    pub protect_hfs: bool,
24    /// Reject paths that NTFS would fold onto `.git` (`core.protectNTFS`).
25    pub protect_ntfs: bool,
26}
27
28impl PathProtection {
29    /// Load protection settings from the repository config at `git_dir`,
30    /// applying Git's platform defaults when unset.
31    #[must_use]
32    pub fn load(git_dir: &Path) -> Self {
33        let config = ConfigSet::load(Some(git_dir), true).unwrap_or_else(|_| ConfigSet::new());
34        Self::from_config(&config)
35    }
36
37    /// Resolve protection settings from an already-loaded [`ConfigSet`].
38    ///
39    /// Mirrors Git's defaults (`environment.c`): `core.protectHFS` defaults on
40    /// for Apple platforms, `core.protectNTFS` defaults on everywhere
41    /// (`PROTECT_NTFS_DEFAULT`). An explicit config value overrides the default.
42    #[must_use]
43    pub fn from_config(config: &ConfigSet) -> Self {
44        let protect_hfs = config
45            .get("core.protectHFS")
46            .map_or(cfg!(target_os = "macos"), |v| {
47                v.eq_ignore_ascii_case("true")
48            });
49        let protect_ntfs = config
50            .get("core.protectNTFS")
51            .is_none_or(|v| v.eq_ignore_ascii_case("true"));
52        Self {
53            protect_hfs,
54            protect_ntfs,
55        }
56    }
57}
58
59/// Verify every `/`-separated component of `path` is safe to materialize.
60///
61/// `path` is a repository-relative slash-separated tree/index path. Each
62/// component is checked with [`verify_path_component`]. `is_symlink` is the
63/// mode of the leaf entry being written and gates the `.gitmodules` checks,
64/// matching Git's `verify_path()`.
65///
66/// # Errors
67///
68/// Returns [`Error::InvalidPath`] for the first forbidden component.
69pub fn verify_path(path: &[u8], prot: PathProtection, is_symlink: bool) -> Result<(), Error> {
70    for component in path.split(|b| *b == b'/') {
71        verify_path_component(component, prot, is_symlink)?;
72    }
73    Ok(())
74}
75
76/// Check whether a single path component (file or directory name) is forbidden.
77///
78/// Mirrors Git's `verify_path()` / `verify_dotfile()`: `.`, `..`, and `.git`
79/// (plus its HFS/NTFS folds) are always rejected; an HFS/NTFS-folded
80/// `.gitmodules` is rejected only when the entry is a symlink (CVE-2018-11235).
81/// Regular files named `.gitignore`, `.gitmodules`, `.mailmap`, etc. are
82/// allowed — Git does not reject them in `verify_path`.
83///
84/// # Errors
85///
86/// Returns [`Error::InvalidPath`] when the name is rejected.
87pub fn verify_path_component(
88    name: &[u8],
89    prot: PathProtection,
90    is_symlink: bool,
91) -> Result<(), Error> {
92    let reject = || Error::InvalidPath(String::from_utf8_lossy(name).into_owned());
93
94    // Always reject "." and ".."
95    if name == b"." || name == b".." {
96        return Err(reject());
97    }
98
99    // Always reject ".git" (exact lowercase — matches C git's verify_dotfile)
100    if name == b".git" {
101        return Err(reject());
102    }
103
104    // HFS / NTFS case-insensitive ".git" checks.
105    if (prot.protect_hfs || prot.protect_ntfs)
106        && name.len() == 4
107        && name[0] == b'.'
108        && name[1..].eq_ignore_ascii_case(b"git")
109    {
110        return Err(reject());
111    }
112    if prot.protect_hfs {
113        if hfs_equivalent_to_dotgit(name) {
114            return Err(reject());
115        }
116        // A symlink whose name folds to `.gitmodules` on HFS+ is the
117        // CVE-2018-11235 vector; reject it (Git: `is_hfs_dotgitmodules`).
118        if is_symlink {
119            if let Ok(s) = std::str::from_utf8(name) {
120                if crate::dotfile::is_hfs_dot_gitmodules(s) {
121                    return Err(reject());
122                }
123            }
124        }
125    }
126
127    if prot.protect_ntfs {
128        // NTFS short-name check: "git~1" (case-insensitive)
129        if name.eq_ignore_ascii_case(b"git~1") {
130            return Err(reject());
131        }
132        // Backslashes are treated as path separators on NTFS, so reject
133        // confusing names that rely on '\' being a regular byte.
134        if name.contains(&b'\\') {
135            return Err(reject());
136        }
137        // Reject NTFS-equivalent ".git" names such as ".git ", ".git...",
138        // and alternate stream forms like ".git...:stream".
139        if ntfs_equivalent_to_dotgit(name) {
140            return Err(reject());
141        }
142        // Symlink folding to `.gitmodules` on NTFS (CVE-2018-11235).
143        if is_symlink {
144            if let Ok(s) = std::str::from_utf8(name) {
145                if crate::dotfile::is_ntfs_dot_gitmodules(s) {
146                    return Err(reject());
147                }
148            }
149        }
150    }
151
152    Ok(())
153}
154
155fn ntfs_equivalent_to_dotgit(name: &[u8]) -> bool {
156    if name.len() < 4 || !name[..4].eq_ignore_ascii_case(b".git") {
157        return false;
158    }
159
160    let rest = &name[4..];
161    if rest.is_empty() {
162        return true;
163    }
164
165    let head = rest.split(|b| *b == b':').next().unwrap_or(rest);
166    let mut trimmed_len = head.len();
167    while trimmed_len > 0 && matches!(head[trimmed_len - 1], b'.' | b' ') {
168        trimmed_len -= 1;
169    }
170
171    trimmed_len == 0
172}
173
174fn hfs_equivalent_to_dotgit(name: &[u8]) -> bool {
175    let Ok(path) = std::str::from_utf8(name) else {
176        return false;
177    };
178
179    let folded: String = path
180        .chars()
181        .filter(|ch| !matches!(*ch, '\u{200c}' | '\u{200d}'))
182        .flat_map(char::to_lowercase)
183        .collect();
184    folded == ".git"
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    fn both() -> PathProtection {
192        PathProtection {
193            protect_hfs: true,
194            protect_ntfs: true,
195        }
196    }
197
198    fn off() -> PathProtection {
199        PathProtection {
200            protect_hfs: false,
201            protect_ntfs: false,
202        }
203    }
204
205    #[test]
206    fn rejects_dotgit_always() {
207        assert!(verify_path_component(b".git", off(), false).is_err());
208        assert!(verify_path_component(b".", off(), false).is_err());
209        assert!(verify_path_component(b"..", off(), false).is_err());
210    }
211
212    #[test]
213    fn rejects_case_and_alias_under_protection() {
214        for name in [
215            &b".Git"[..],
216            b".GIT",
217            b"git~1",
218            b".git ",
219            b".git...",
220            b".git\\foo",
221        ] {
222            assert!(
223                verify_path_component(name, both(), false).is_err(),
224                "expected rejection of {:?}",
225                String::from_utf8_lossy(name)
226            );
227        }
228    }
229
230    #[test]
231    fn rejects_hfs_ignorable_dotgit() {
232        // ".gi‌t" folds to ".git" on HFS+.
233        let name = ".gi\u{200c}t".as_bytes();
234        assert!(verify_path_component(name, both(), false).is_err());
235        // Allowed when HFS protection is off (only NTFS on).
236        let ntfs_only = PathProtection {
237            protect_hfs: false,
238            protect_ntfs: true,
239        };
240        assert!(verify_path_component(name, ntfs_only, false).is_ok());
241    }
242
243    #[test]
244    fn rejects_dotgit_directory_component() {
245        // A crafted ".Git/config" path: the directory component is rejected.
246        assert!(verify_path(b".Git/config", both(), false).is_err());
247        // The HFS ignorable-codepoint alias as a directory component is rejected too.
248        assert!(verify_path(".gi\u{200c}t/config".as_bytes(), both(), false).is_err());
249    }
250
251    #[test]
252    fn allows_normal_paths() {
253        // Regular dotfiles are allowed even with both protections on — Git does
254        // not reject these in verify_path (only symlinked `.gitmodules` folds).
255        assert!(verify_path(b"src/main.rs", both(), false).is_ok());
256        assert!(verify_path(b".gitconfig", both(), false).is_ok());
257        assert!(verify_path(b".gitignore", both(), false).is_ok());
258        assert!(verify_path(b".gitmodules", both(), false).is_ok());
259        assert!(verify_path(b".gitattributes", both(), false).is_ok());
260        assert!(verify_path(b".mailmap", both(), false).is_ok());
261        assert!(verify_path(b".github/workflows/ci.yml", both(), false).is_ok());
262        assert!(verify_path_component(b"gitconfig", both(), false).is_ok());
263    }
264
265    #[test]
266    fn rejects_symlinked_gitmodules_fold() {
267        // ".gitmodules" folded via an HFS ignorable codepoint, as a symlink, is
268        // the CVE-2018-11235 vector and must be rejected — but only as a symlink.
269        let name = ".gitmodule\u{200c}s".as_bytes();
270        assert!(verify_path_component(name, both(), true).is_err());
271        assert!(verify_path_component(name, both(), false).is_ok());
272        // An exact ".gitmodules" symlink is the classic vector and is also rejected,
273        // while a regular file of the same name is allowed.
274        assert!(verify_path_component(b".gitmodules", both(), true).is_err());
275        assert!(verify_path_component(b".gitmodules", both(), false).is_ok());
276    }
277}