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