1use std::path::Path;
15
16use crate::config::ConfigSet;
17use crate::error::Error;
18
19#[derive(Clone, Copy, Debug)]
21pub struct PathProtection {
22 pub protect_hfs: bool,
24 pub protect_ntfs: bool,
26}
27
28impl PathProtection {
29 #[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 #[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
59pub 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
76pub 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 if name == b"." || name == b".." {
96 return Err(reject());
97 }
98
99 if name == b".git" {
101 return Err(reject());
102 }
103
104 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 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 if name.eq_ignore_ascii_case(b"git~1") {
130 return Err(reject());
131 }
132 if name.contains(&b'\\') {
135 return Err(reject());
136 }
137 if ntfs_equivalent_to_dotgit(name) {
140 return Err(reject());
141 }
142 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 let name = ".gi\u{200c}t".as_bytes();
234 assert!(verify_path_component(name, both(), false).is_err());
235 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 assert!(verify_path(b".Git/config", both(), false).is_err());
247 assert!(verify_path(".gi\u{200c}t/config".as_bytes(), both(), false).is_err());
249 }
250
251 #[test]
252 fn allows_normal_paths() {
253 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 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 assert!(verify_path_component(b".gitmodules", both(), true).is_err());
275 assert!(verify_path_component(b".gitmodules", both(), false).is_ok());
276 }
277}