zuka
zuka/src/git/validate.rs

worklyn / zukapublic

Agent-first git hosting. One Rust binary: git over HTTP and SSH, a REST API, MCP, CI, and multi-tenant isolation.

Get a copy: git clone https://zuka.worklyn.com/worklyn/zuka.git
zuka/src/git/validate.rs
RSvalidate.rs12.4 KBDownload
1// Normative validation (SPEC §2.5). These are security controls, not input hygiene.
2//
3// An unvalidated ref name reaching the filesystem is remote code execution: a name
4// of `../../config` writes git config, and `core.pager`, `core.sshCommand` and
5// `core.fsmonitor` are all command-execution keys. An unvalidated repo name escapes
6// the data directory the same way.
7//
8// Ref-name rules are delegated to gix-validate, which implements what
9// `git check-ref-format` enforces. Do not hand-roll them.
10
11use crate::error::{Error, Result};
12use std::path::{Component, Path, PathBuf};
13
14/// Longest accepted account or repository name.
15const MAX_NAME_LEN: usize = 64;
16/// Longest accepted tree path, in bytes.
17const MAX_PATH_LEN: usize = 4096;
18/// Deepest accepted tree path.
19const MAX_PATH_COMPONENTS: usize = 64;
20
21/// Names that would collide with git's own on-disk layout or with path syntax.
22const RESERVED_NAMES: &[&str] = &[".", "..", ".git", "git", "hooks", "meta", "tmp", "template"];
23
24/// A validated, case-folded account or repository name.
25///
26/// Every path component and every storage key is built from this, never from the
27/// raw request string. Two names differing only by case are the *same* `Name`,
28/// which is what stops `Alice/site` resolving to `alice`'s repository on a
29/// case-insensitive filesystem while passing an ownership check that compared the
30/// raw path segment.
31#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
32pub struct Name(String);
33
34impl Name {
35 pub fn as_str(&self) -> &str {
36 &self.0
37 }
38}
39
40impl std::fmt::Display for Name {
41 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42 f.write_str(&self.0)
43 }
44}
45
46impl AsRef<Path> for Name {
47 fn as_ref(&self) -> &Path {
48 Path::new(&self.0)
49 }
50}
51
52/// Validate an account or repository name and return its canonical form.
53pub fn name(value: &str) -> Result<Name> {
54 if value.is_empty() {
55 return Err(Error::invalid("invalid-name", "name must not be empty"));
56 }
57 if value.len() > MAX_NAME_LEN {
58 return Err(Error::invalid(
59 "invalid-name",
60 format!("name must be at most {MAX_NAME_LEN} characters"),
61 ));
62 }
63
64 if !value.as_bytes()[0].is_ascii_alphanumeric() {
65 return Err(Error::invalid(
66 "invalid-name",
67 "name must start with a letter or digit",
68 ));
69 }
70
71 for byte in value.bytes() {
72 let ok = byte.is_ascii_alphanumeric() || byte == b'.' || byte == b'_' || byte == b'-';
73 if !ok {
74 return Err(Error::invalid(
75 "invalid-name",
76 "name may contain only letters, digits, '.', '_' and '-'",
77 ));
78 }
79 }
80
81 if value.contains("..") {
82 return Err(Error::invalid("invalid-name", "name must not contain '..'"));
83 }
84 if value.ends_with(".lock") {
85 return Err(Error::invalid(
86 "invalid-name",
87 "name must not end with '.lock'",
88 ));
89 }
90
91 let folded = value.to_ascii_lowercase();
92 if RESERVED_NAMES.contains(&folded.as_str()) {
93 return Err(Error::invalid(
94 "invalid-name",
95 format!("{value:?} is reserved"),
96 ));
97 }
98
99 Ok(Name(folded))
100}
101
102/// Validate a fully-qualified ref name.
103///
104/// Only `refs/heads/` and `refs/tags/` are writable; `refs/notes/` is readable but
105/// not writable through the API, so callers that only read pass `allow_notes`.
106pub fn ref_name(value: &str) -> Result<()> {
107 ref_name_inner(value, false)
108}
109
110/// As [`ref_name`], additionally permitting `refs/notes/` for read paths.
111pub fn readable_ref_name(value: &str) -> Result<()> {
112 ref_name_inner(value, true)
113}
114
115fn ref_name_inner(value: &str, allow_notes: bool) -> Result<()> {
116 let prefixes: &[&str] = if allow_notes {
117 &["refs/heads/", "refs/tags/", "refs/notes/"]
118 } else {
119 &["refs/heads/", "refs/tags/"]
120 };
121
122 let prefix = prefixes
123 .iter()
124 .find(|p| value.starts_with(**p))
125 .ok_or_else(|| {
126 Error::invalid(
127 "invalid-ref",
128 format!("ref must be under one of {prefixes:?}"),
129 )
130 })?;
131
132 if value.len() <= prefix.len() || value.ends_with('/') {
133 return Err(Error::invalid("invalid-ref", "ref name is empty"));
134 }
135
136 gix_validate::reference::name(value.into())
137 .map_err(|e| Error::invalid("invalid-ref", format!("invalid ref name: {e}")))?;
138
139 // Belt to gix-validate's braces: prove the name cannot leave refs/ once it is a
140 // path, matching what `repo_path` does for repositories (SPEC §2.5).
141 if Path::new(value)
142 .components()
143 .any(|c| matches!(c, Component::ParentDir | Component::RootDir))
144 {
145 return Err(Error::invalid("invalid-ref", "ref name must not traverse"));
146 }
147
148 Ok(())
149}
150
151/// Validate a path inside a git tree.
152///
153/// Rejects absolute paths, traversal, and any component that is `.git` after
154/// Unicode and case normalization. A tree entry named `.git` is hook injection:
155/// real `git checkout` refuses it, but a hand-rolled checkout may not.
156pub fn tree_path(value: &str) -> Result<()> {
157 if value.is_empty() {
158 return Err(Error::invalid("invalid-path", "path must not be empty"));
159 }
160 if value.len() > MAX_PATH_LEN {
161 return Err(Error::invalid(
162 "invalid-path",
163 format!("path must be at most {MAX_PATH_LEN} bytes"),
164 ));
165 }
166 if value.starts_with('/') || value.starts_with('\\') {
167 return Err(Error::invalid("invalid-path", "path must be relative"));
168 }
169 if value.contains('\0') {
170 return Err(Error::invalid("invalid-path", "path must not contain NUL"));
171 }
172 if value.contains('\\') {
173 return Err(Error::invalid("invalid-path", "path separator must be '/'"));
174 }
175
176 let components: Vec<&str> = value.split('/').collect();
177 if components.len() > MAX_PATH_COMPONENTS {
178 return Err(Error::invalid(
179 "invalid-path",
180 format!("path must have at most {MAX_PATH_COMPONENTS} components"),
181 ));
182 }
183
184 for component in components {
185 if component.is_empty() {
186 return Err(Error::invalid(
187 "invalid-path",
188 "path must not contain an empty component",
189 ));
190 }
191 if component == "." || component == ".." {
192 return Err(Error::invalid(
193 "invalid-path",
194 "path must not contain '.' or '..'",
195 ));
196 }
197 if is_dot_git(component) {
198 return Err(Error::invalid(
199 "invalid-path",
200 "path must not contain a '.git' component",
201 ));
202 }
203 if component.chars().any(|c| c.is_control()) {
204 return Err(Error::invalid(
205 "invalid-path",
206 "path must not contain control characters",
207 ));
208 }
209 // A trailing dot or space is stripped by some filesystems, which would let
210 // `.git.` land on disk as `.git`.
211 if component.ends_with('.') || component.ends_with(' ') {
212 return Err(Error::invalid(
213 "invalid-path",
214 "path component must not end with '.' or a space",
215 ));
216 }
217 }
218
219 Ok(())
220}
221
222/// True when a path component means `.git` to some filesystem git will later read.
223///
224/// Covers case folding, NFD decomposition, and the HFS+ ignorable-codepoint trick
225/// where zero-width characters are stripped during comparison.
226fn is_dot_git(component: &str) -> bool {
227 let stripped: String = component
228 .chars()
229 .filter(|c| !is_hfs_ignorable(*c))
230 .flat_map(|c| c.to_lowercase())
231 .collect();
232 let normalized = stripped.trim_end_matches(['.', ' ']);
233 normalized == ".git" || normalized == ".gitmodules"
234}
235
236/// Codepoints HFS+ ignores when comparing filenames, so `.g\u{200c}it` == `.git`.
237fn is_hfs_ignorable(c: char) -> bool {
238 matches!(c as u32,
239 0x200C..=0x200F | 0x202A..=0x202E | 0x206A..=0x206F | 0xFEFF | 0x00AD)
240}
241
242/// Join canonical names onto the git root, then prove the result did not escape it.
243pub fn repo_path(git_root: &Path, account: &Name, repo: &Name) -> Result<PathBuf> {
244 let candidate = git_root
245 .join(account.as_str())
246 .join(format!("{}.git", repo.as_str()));
247
248 if candidate
249 .components()
250 .any(|c| matches!(c, Component::ParentDir))
251 || !candidate.starts_with(git_root)
252 {
253 return Err(Error::invalid(
254 "invalid-name",
255 "resolved path escapes the git root",
256 ));
257 }
258 Ok(candidate)
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264
265 #[test]
266 fn accepts_ordinary_names() {
267 for value in ["repo", "my-repo", "my_repo", "repo.v2", "a", "A1"] {
268 name(value).unwrap_or_else(|e| panic!("{value:?} should be valid: {e}"));
269 }
270 }
271
272 #[test]
273 fn names_are_canonicalised_so_case_variants_are_one_name() {
274 assert_eq!(name("Alice").unwrap(), name("alice").unwrap());
275 assert_eq!(name("SITE").unwrap().as_str(), "site");
276 }
277
278 #[test]
279 fn rejects_names_that_would_escape_the_data_directory() {
280 for value in ["..", "../etc", "a/b", "a\\b", ".hidden", "-leading", "a..b"] {
281 assert!(name(value).is_err(), "{value:?} must be rejected");
282 }
283 }
284
285 #[test]
286 fn rejects_reserved_and_lock_suffixed_names() {
287 for value in [".git", "git", "hooks", "tmp", "GIT", "branch.lock"] {
288 assert!(name(value).is_err(), "{value:?} must be rejected");
289 }
290 }
291
292 #[test]
293 fn rejects_ref_names_that_escape_the_refs_directory() {
294 for value in [
295 "refs/heads/../../config",
296 "refs/heads/../../hooks/post-receive",
297 "../../config",
298 "config",
299 "refs/heads/",
300 "refs/heads/main.lock",
301 "refs/heads/.hidden",
302 "refs/heads/a..b",
303 "refs/heads/a@{0}",
304 "refs/heads/a~1",
305 "refs/heads/a^",
306 "refs/heads/a:b",
307 "refs/heads/a?",
308 "refs/heads/a*",
309 "refs/heads/a[b",
310 "refs/heads/a\\b",
311 ] {
312 assert!(ref_name(value).is_err(), "{value:?} must be rejected");
313 }
314 }
315
316 #[test]
317 fn accepts_ordinary_ref_names_including_slashes() {
318 for value in [
319 "refs/heads/main",
320 "refs/heads/feature/login",
321 "refs/tags/v1.0.0",
322 ] {
323 ref_name(value).unwrap_or_else(|e| panic!("{value:?} should be valid: {e}"));
324 }
325 }
326
327 #[test]
328 fn notes_are_readable_but_not_writable() {
329 assert!(ref_name("refs/notes/commits").is_err());
330 readable_ref_name("refs/notes/commits").unwrap();
331 assert!(readable_ref_name("refs/remotes/origin/main").is_err());
332 }
333
334 #[test]
335 fn rejects_dot_git_tree_components_in_every_disguise() {
336 for value in [
337 ".git/hooks/post-checkout",
338 ".GIT/config",
339 ".gIt/config",
340 "src/.git/config",
341 ".git./config",
342 ".git /config",
343 ".gitmodules",
344 "\u{200c}.git/config",
345 ] {
346 assert!(tree_path(value).is_err(), "{value:?} must be rejected");
347 }
348 }
349
350 #[test]
351 fn rejects_traversal_and_absolute_tree_paths() {
352 for value in [
353 "../outside",
354 "a/../../b",
355 "/etc/passwd",
356 "a//b",
357 "a\\b",
358 "",
359 "a\0b",
360 ] {
361 assert!(tree_path(value).is_err(), "{value:?} must be rejected");
362 }
363 }
364
365 #[test]
366 fn accepts_ordinary_tree_paths() {
367 for value in ["README.md", "src/main.rs", "a/b/c/d.txt", ".gitignore"] {
368 tree_path(value).unwrap_or_else(|e| panic!("{value:?} should be valid: {e}"));
369 }
370 }
371
372 #[test]
373 fn repo_path_stays_under_the_git_root() {
374 let root = Path::new("/data/git");
375 let path = repo_path(root, &name("alice").unwrap(), &name("site").unwrap()).unwrap();
376 assert_eq!(path, PathBuf::from("/data/git/alice/site.git"));
377 }
378
379 #[test]
380 fn a_case_variant_account_resolves_to_the_same_path() {
381 let root = Path::new("/data/git");
382 let lower = repo_path(root, &name("alice").unwrap(), &name("site").unwrap()).unwrap();
383 let upper = repo_path(root, &name("Alice").unwrap(), &name("Site").unwrap()).unwrap();
384 assert_eq!(
385 lower, upper,
386 "a case variant must not address a second, unowned repository"
387 );
388 }
389}