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
| 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 | |
| 11 | use crate::error::{Error, Result}; |
| 12 | use std::path::{Component, Path, PathBuf}; |
| 13 | |
| 14 | /// Longest accepted account or repository name. |
| 15 | const MAX_NAME_LEN: usize = 64; |
| 16 | /// Longest accepted tree path, in bytes. |
| 17 | const MAX_PATH_LEN: usize = 4096; |
| 18 | /// Deepest accepted tree path. |
| 19 | const MAX_PATH_COMPONENTS: usize = 64; |
| 20 | |
| 21 | /// Names that would collide with git's own on-disk layout or with path syntax. |
| 22 | const 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)] |
| 32 | pub struct Name(String); |
| 33 | |
| 34 | impl Name { |
| 35 | pub fn as_str(&self) -> &str { |
| 36 | &self.0 |
| 37 | } |
| 38 | } |
| 39 | |
| 40 | impl 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 | |
| 46 | impl 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. |
| 53 | pub 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`. |
| 106 | pub 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. |
| 111 | pub fn readable_ref_name(value: &str) -> Result<()> { |
| 112 | ref_name_inner(value, true) |
| 113 | } |
| 114 | |
| 115 | fn 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. |
| 156 | pub 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. |
| 226 | fn 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`. |
| 237 | fn 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. |
| 243 | pub 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)] |
| 262 | mod 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 | } |