zuka
zuka/src/git/store.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/store.rs
RSstore.rs21.7 KBDownload
1// Repository lifecycle on disk.
2//
3// Hooks are symlinks into `$DATA_DIR/hooks/`, installed through git's
4// `init.templateDir` at creation. Symlinks rather than copies so upgrading a hook
5// is atomic across every existing repository.
6//
7// The hooks contain no policy: each is a one-line `exec` into this binary. The ref
8// rule therefore lives in Rust rather than in shell, where it would have been a
9// second implementation that drifts. It has one caller — the `update` hook — because
10// there is no API path that moves a ref; git is the only writer.
11
12use crate::brand;
13use crate::error::{Error, Result};
14use crate::git::exec::Git;
15use crate::git::validate::{self, Name};
16use anyhow::Context;
17use std::path::{Path, PathBuf};
18
19/// Hooks installed into every repository. Each delegates to `<binary> hook <name>`
20/// so policy lives in Rust rather than in shell.
21const HOOKS: &[&str] = &["update", "post-receive"];
22
23#[derive(Debug, Clone)]
24pub struct GitStore {
25 git_root: PathBuf,
26 hooks_dir: PathBuf,
27 template_dir: PathBuf,
28 deleted_dir: PathBuf,
29 /// Mirrors the transport's cap so `receive.maxInputSize` cannot disagree with
30 /// the byte limit the HTTP layer enforces.
31 max_pack_bytes: u64,
32}
33
34impl GitStore {
35 pub fn open(data_dir: &Path, max_pack_bytes: u64) -> Result<Self> {
36 let store = GitStore {
37 git_root: data_dir.join("git"),
38 hooks_dir: data_dir.join("hooks"),
39 template_dir: data_dir.join("template"),
40 deleted_dir: data_dir.join("tmp").join("deleted"),
41 max_pack_bytes,
42 };
43 store.install_hooks()?;
44 Ok(store)
45 }
46
47 pub fn git_root(&self) -> &Path {
48 &self.git_root
49 }
50
51 /// Write hook shims and point the template's `hooks/` at them.
52 ///
53 /// Idempotent, and rerun on every boot so an upgraded binary lands its hook
54 /// changes across repositories that already exist.
55 fn install_hooks(&self) -> Result<()> {
56 for dir in [&self.git_root, &self.hooks_dir, &self.deleted_dir] {
57 std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
58 }
59 let template_hooks = self.template_dir.join("hooks");
60 std::fs::create_dir_all(&template_hooks)
61 .with_context(|| format!("create {}", template_hooks.display()))?;
62
63 let binary = std::env::current_exe().context("locate own executable")?;
64
65 for name in HOOKS {
66 let target = self.hooks_dir.join(name);
67 let body = format!(
68 "#!/bin/sh\n# Installed by {}. Policy lives in the binary.\nexec {} hook {name} \"$@\"\n",
69 brand::NAME,
70 shell_quote(&binary.to_string_lossy())
71 );
72 std::fs::write(&target, body)
73 .with_context(|| format!("write hook {}", target.display()))?;
74 set_executable(&target)?;
75
76 let link = template_hooks.join(name);
77 match std::fs::remove_file(&link) {
78 Ok(()) => {}
79 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
80 Err(e) => {
81 return Err(Error::Internal(
82 anyhow::Error::from(e).context(format!("unlink {}", link.display())),
83 ))
84 }
85 }
86 symlink(&target, &link).with_context(|| format!("link {}", link.display()))?;
87 }
88 Ok(())
89 }
90
91 pub fn repo_path(&self, account: &Name, repo: &Name) -> Result<PathBuf> {
92 validate::repo_path(&self.git_root, account, repo)
93 }
94
95 pub fn exists(&self, account: &Name, repo: &Name) -> Result<bool> {
96 Ok(self.repo_path(account, repo)?.join("HEAD").is_file())
97 }
98
99 /// Initialise a bare repository with hooks installed and HEAD pointed at the
100 /// default branch.
101 ///
102 /// HEAD must be a symref to the branch even though it does not exist yet, or
103 /// cloning the empty repo leaves the client on its own `init.defaultBranch`.
104 pub fn create(&self, account: &Name, repo: &Name, default_branch: &str) -> Result<PathBuf> {
105 validate::ref_name(default_branch)?;
106 let path = self.repo_path(account, repo)?;
107
108 let parent = path.parent().expect("repo path always has a parent");
109 std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
110
111 let path_str = path
112 .to_str()
113 .ok_or_else(|| Error::invalid("invalid-name", "repository path is not valid UTF-8"))?;
114
115 Git::plain().run(&[
116 "init",
117 "--bare",
118 "--quiet",
119 &format!("--template={}", self.template_dir.display()),
120 &format!(
121 "--initial-branch={}",
122 default_branch
123 .strip_prefix("refs/heads/")
124 .unwrap_or(default_branch)
125 ),
126 path_str,
127 ])?;
128
129 self.apply_repo_config(&path)?;
130 Ok(path)
131 }
132
133 /// Config every repository must carry. Reapplied on open so an upgrade reaches
134 /// repositories created before a setting existed.
135 fn apply_repo_config(&self, path: &Path) -> Result<()> {
136 let git = Git::at(path);
137 let max_pack = self.max_pack_bytes.to_string();
138
139 for setting in [
140 // Reject malformed and unreachable objects at receive time rather than
141 // storing them and failing later on read.
142 ["receive.fsckObjects", "true"].as_slice(),
143 // Git's own bound, applied at index-pack before the pack reaches disk.
144 // Taken from configuration so it cannot disagree with the transport cap.
145 ["receive.maxInputSize", &max_pack].as_slice(),
146 ["core.logAllRefUpdates", "true"].as_slice(),
147 // Maintenance is scheduled, not opportunistic: git's own auto-gc
148 // otherwise fires inside a push, at a moment nobody chose and
149 // unsynchronised with everything else. See `jobs::gc`.
150 ["gc.auto", "0"].as_slice(),
151 ["receive.autogc", "false"].as_slice(),
152 ] {
153 git.run(&["config", setting[0], setting[1]])?;
154 }
155 Ok(())
156 }
157
158 /// Apply our config and hooks to a repository that arrived from elsewhere.
159 ///
160 /// An imported repository is untrusted input and must end up indistinguishable
161 /// from one we created: same hooks, same receive limits, same maintenance
162 /// posture. `clone --mirror` brings none of that.
163 pub fn adopt(&self, account: &Name, repo: &Name) -> Result<()> {
164 let path = self.repo_path(account, repo)?;
165 self.apply_repo_config(&path)?;
166
167 let hooks = path.join("hooks");
168 std::fs::create_dir_all(&hooks).with_context(|| format!("create {}", hooks.display()))?;
169
170 for name in HOOKS {
171 let link = hooks.join(name);
172 let _ = std::fs::remove_file(&link);
173 symlink(&self.hooks_dir.join(name), &link)
174 .with_context(|| format!("link {}", link.display()))?;
175 }
176 Ok(())
177 }
178
179 /// The branch HEAD points at, if it is a symbolic ref.
180 pub fn head_ref(&self, account: &Name, repo: &Name) -> Option<String> {
181 let path = self.repo_path(account, repo).ok()?;
182 let head = Git::at(&path)
183 .text(&["symbolic-ref", "--quiet", "HEAD"])
184 .ok()?;
185 (!head.is_empty()).then_some(head)
186 }
187
188 /// Move a repository aside. Recoverable until the sweep runs.
189 pub fn soft_delete(&self, account: &Name, repo: &Name) -> Result<bool> {
190 let path = self.repo_path(account, repo)?;
191 if !path.exists() {
192 return Ok(false);
193 }
194 let stamp = std::time::SystemTime::now()
195 .duration_since(std::time::UNIX_EPOCH)
196 .map(|d| d.as_millis())
197 .unwrap_or(0);
198 let grave = self
199 .deleted_dir
200 .join(format!("{account}__{repo}__{stamp}.git"));
201 std::fs::rename(&path, &grave)
202 .with_context(|| format!("move {} to {}", path.display(), grave.display()))?;
203 Ok(true)
204 }
205
206 /// List refs under `refs/heads/`, `refs/tags/` and `refs/notes/`.
207 pub fn list_refs(&self, account: &Name, repo: &Name) -> Result<Vec<(String, String)>> {
208 let path = self.repo_path(account, repo)?;
209 let out = Git::at(&path).run(&[
210 "for-each-ref",
211 "--format=%(refname) %(objectname)",
212 "refs/heads/",
213 "refs/tags/",
214 "refs/notes/",
215 ])?;
216
217 Ok(String::from_utf8_lossy(&out)
218 .lines()
219 .filter_map(|line| {
220 let (name, sha) = line.split_once(' ')?;
221 Some((name.to_string(), sha.to_string()))
222 })
223 .collect())
224 }
225}
226
227/// The all-zero object id git uses for "this ref does not exist".
228///
229/// Length varies with the repository's hash algorithm, so this matches on content
230/// rather than against a hardcoded 40-character SHA-1 sentinel.
231pub fn is_null_oid(oid: &str) -> bool {
232 !oid.is_empty() && oid.bytes().all(|b| b == b'0')
233}
234
235/// Git config key holding the refs that may not be rewritten.
236fn protected_key() -> String {
237 brand::git_config_key("protected")
238}
239
240/// Refs protected from rewriting, read from the repository's own git config.
241///
242/// Kept in git config rather than in our metadata so the hook can read it with no
243/// data directory, no environment and no lookup — and so the setting travels with
244/// the repository if it is ever copied or restored from a backup.
245pub fn protected_refs(git_dir: &Path) -> Vec<String> {
246 let Ok(Some(out)) = Git::at(git_dir).query(&["config", "--get-all", &protected_key()]) else {
247 return Vec::new();
248 };
249 String::from_utf8_lossy(&out)
250 .lines()
251 .map(str::trim)
252 .filter(|line| !line.is_empty())
253 .map(String::from)
254 .collect()
255}
256
257/// Replace the protected-ref list.
258pub fn set_protected_refs(git_dir: &Path, refs: &[String]) -> Result<()> {
259 for name in refs {
260 validate::ref_name(name)?;
261 }
262
263 let key = protected_key();
264 let git = Git::at(git_dir);
265 // `--unset-all` exits 5 when the key is absent, which is not a failure here.
266 git.ignore(&["config", "--unset-all", &key]);
267
268 for name in refs {
269 git.run(&["config", "--add", &key, name])?;
270 }
271 Ok(())
272}
273
274/// Point HEAD at a branch.
275///
276/// A symref rather than a detached id, so a clone of the repository checks the
277/// branch out by name.
278pub fn set_head(git_dir: &Path, branch: &str) -> Result<()> {
279 validate::ref_name(branch)?;
280 Git::at(git_dir)
281 .run(&["symbolic-ref", "HEAD", branch])
282 .map(|_| ())
283}
284
285/// Whether a ref may move from `old` to `new`.
286///
287/// **Rewriting is allowed by default.** There is one agent and no reviewer here, so
288/// a blanket non-fast-forward block protects nobody while breaking `rebase`,
289/// `commit --amend` and `filter-branch` — ordinary git that this service exists to
290/// serve. Recovery is git's own reflog, enabled on every repository at creation.
291///
292/// Protection is opt-in per ref, for the branch something deploys from. Its one
293/// caller is the `update` hook, reached through `<binary> hook update`.
294pub fn check_ref_move(git_dir: &Path, ref_name: &str, old: &str, new: &str) -> Result<()> {
295 // Creating or deleting a ref is not a rewrite.
296 if is_null_oid(old) || is_null_oid(new) {
297 return Ok(());
298 }
299 if !protected_refs(git_dir).iter().any(|p| p == ref_name) {
300 return Ok(());
301 }
302
303 if Git::at(git_dir).succeeds(&["merge-base", "--is-ancestor", old, new]) {
304 return Ok(());
305 }
306
307 Err(Error::conflict(
308 "non-fast-forward",
309 format!("{ref_name} is protected; non-fast-forward update rejected"),
310 ))
311}
312
313/// Single-quote a path for embedding in a `sh` script.
314fn shell_quote(value: &str) -> String {
315 format!("'{}'", value.replace('\'', r"'\''"))
316}
317
318#[cfg(unix)]
319fn set_executable(path: &Path) -> Result<()> {
320 use std::os::unix::fs::PermissionsExt;
321 let mut perms = std::fs::metadata(path)
322 .with_context(|| format!("stat {}", path.display()))?
323 .permissions();
324 perms.set_mode(0o755);
325 std::fs::set_permissions(path, perms).with_context(|| format!("chmod {}", path.display()))?;
326 Ok(())
327}
328
329#[cfg(not(unix))]
330fn set_executable(_path: &Path) -> Result<()> {
331 Ok(())
332}
333
334#[cfg(unix)]
335fn symlink(target: &Path, link: &Path) -> anyhow::Result<()> {
336 std::os::unix::fs::symlink(target, link)?;
337 Ok(())
338}
339
340#[cfg(not(unix))]
341fn symlink(target: &Path, link: &Path) -> anyhow::Result<()> {
342 std::fs::copy(target, link)?;
343 Ok(())
344}
345
346#[cfg(all(test, unix))]
347mod tests {
348 use super::*;
349 use crate::git::validate::name;
350 // Tests build commits directly, which needs stdin; the Git helper is
351 // deliberately stdin-less because no production call site needs it.
352 use std::process::Command;
353
354 fn store() -> (tempfile::TempDir, GitStore) {
355 let dir = tempfile::tempdir().unwrap();
356 let store = GitStore::open(dir.path(), 512 * 1024 * 1024).unwrap();
357 (dir, store)
358 }
359
360 fn make(store: &GitStore, account: &str, repo: &str) -> PathBuf {
361 store
362 .create(
363 &name(account).unwrap(),
364 &name(repo).unwrap(),
365 "refs/heads/main",
366 )
367 .unwrap()
368 }
369
370 #[test]
371 fn creates_a_bare_repo_with_head_pointed_at_the_default_branch() {
372 let (_dir, store) = store();
373 let path = make(&store, "alice", "site");
374
375 assert!(path.join("HEAD").is_file());
376 assert!(!path.join(".git").exists(), "repo must be bare");
377 assert_eq!(
378 std::fs::read_to_string(path.join("HEAD")).unwrap().trim(),
379 "ref: refs/heads/main"
380 );
381 }
382
383 #[test]
384 fn every_repo_carries_the_pack_safety_config() {
385 let (_dir, store) = store();
386 let path = make(&store, "alice", "site");
387
388 for (key, expected) in [
389 ("receive.fsckObjects", "true"),
390 ("receive.maxInputSize", "536870912"),
391 ] {
392 assert_eq!(
393 Git::at(&path).text(&["config", "--get", key]).unwrap(),
394 expected,
395 "{key} must be set at create"
396 );
397 }
398 }
399
400 #[test]
401 fn hooks_are_executable_symlinks_that_delegate_to_the_binary() {
402 let (dir, store) = store();
403 let path = make(&store, "alice", "site");
404
405 for name in HOOKS {
406 let hook = path.join("hooks").join(name);
407 assert!(
408 std::fs::symlink_metadata(&hook)
409 .unwrap()
410 .file_type()
411 .is_symlink(),
412 "{name} must be a symlink"
413 );
414 assert_eq!(
415 std::fs::read_link(&hook).unwrap(),
416 dir.path().join("hooks").join(name)
417 );
418
419 let body = std::fs::read_to_string(&hook).unwrap();
420 assert!(
421 body.contains(&format!("hook {name}")),
422 "{name} must delegate to the binary, not carry policy in shell"
423 );
424 }
425 }
426
427 #[test]
428 fn a_hook_upgrade_reaches_repos_that_already_exist() {
429 let dir = tempfile::tempdir().unwrap();
430 let store = GitStore::open(dir.path(), 512 * 1024 * 1024).unwrap();
431 let path = make(&store, "alice", "site");
432
433 std::fs::write(
434 dir.path().join("hooks").join("update"),
435 "#!/bin/sh\nexit 7\n",
436 )
437 .unwrap();
438 let body = std::fs::read_to_string(path.join("hooks").join("update")).unwrap();
439 assert!(
440 body.contains("exit 7"),
441 "the symlink must carry the upgrade"
442 );
443 }
444
445 #[test]
446 fn a_case_variant_resolves_to_the_same_repository() {
447 let (_dir, store) = store();
448 let created = make(&store, "alice", "site");
449 let variant = store
450 .repo_path(&name("Alice").unwrap(), &name("SITE").unwrap())
451 .unwrap();
452 assert_eq!(created, variant);
453 }
454
455 #[test]
456 fn rejects_a_default_branch_that_is_not_a_valid_ref() {
457 let (_dir, store) = store();
458 assert!(store
459 .create(
460 &name("alice").unwrap(),
461 &name("site").unwrap(),
462 "refs/heads/../config"
463 )
464 .is_err());
465 }
466
467 #[test]
468 fn soft_delete_moves_the_repo_aside_rather_than_removing_it() {
469 let (dir, store) = store();
470 let path = make(&store, "alice", "site");
471 let (alice, site) = (name("alice").unwrap(), name("site").unwrap());
472
473 assert!(store.soft_delete(&alice, &site).unwrap());
474 assert!(!path.exists());
475 assert_eq!(
476 std::fs::read_dir(dir.path().join("tmp").join("deleted"))
477 .unwrap()
478 .count(),
479 1,
480 "repo must be recoverable after delete"
481 );
482 assert!(!store.soft_delete(&alice, &site).unwrap());
483 }
484
485 #[test]
486 fn an_empty_repo_lists_no_refs() {
487 let (_dir, store) = store();
488 make(&store, "alice", "site");
489 assert!(store
490 .list_refs(&name("alice").unwrap(), &name("site").unwrap())
491 .unwrap()
492 .is_empty());
493 }
494
495 #[test]
496 fn the_null_oid_is_recognised_at_any_hash_length() {
497 assert!(is_null_oid(&"0".repeat(40)), "sha-1 sentinel");
498 assert!(is_null_oid(&"0".repeat(64)), "sha-256 sentinel");
499 assert!(!is_null_oid(""));
500 assert!(!is_null_oid("0123"));
501 }
502
503 #[test]
504 fn ref_creation_and_deletion_are_always_allowed() {
505 let (_dir, store) = store();
506 let path = make(&store, "alice", "site");
507 let zero = "0".repeat(40);
508
509 check_ref_move(&path, "refs/heads/main", &zero, "abc").unwrap();
510 check_ref_move(&path, "refs/heads/main", "abc", &zero).unwrap();
511 }
512
513 #[test]
514 fn rewriting_an_unprotected_ref_is_allowed() {
515 let (_dir, store) = store();
516 let path = make(&store, "alice", "site");
517
518 // Two unrelated commits: neither is an ancestor of the other.
519 let first = commit(&path, "one");
520 let second = commit(&path, "two");
521
522 check_ref_move(&path, "refs/heads/main", &second, &first).unwrap_or_else(|e| {
523 panic!("rebase and amend are ordinary git and must not be blocked: {e}")
524 });
525 }
526
527 #[test]
528 fn rewriting_a_protected_ref_is_refused() {
529 let (_dir, store) = store();
530 let path = make(&store, "alice", "site");
531 set_protected_refs(&path, &["refs/heads/main".to_string()]).unwrap();
532
533 let first = commit(&path, "one");
534 let second = commit(&path, "two");
535
536 let err = check_ref_move(&path, "refs/heads/main", &second, &first).unwrap_err();
537 assert_eq!(err.status(), 409);
538
539 // A different branch is untouched by main's protection.
540 check_ref_move(&path, "refs/heads/topic", &second, &first).unwrap();
541 }
542
543 #[test]
544 fn a_fast_forward_is_allowed_even_when_protected() {
545 let (_dir, store) = store();
546 let path = make(&store, "alice", "site");
547 set_protected_refs(&path, &["refs/heads/main".to_string()]).unwrap();
548
549 let first = commit(&path, "one");
550 let second = commit_onto(&path, &first, "two");
551 check_ref_move(&path, "refs/heads/main", &first, &second).unwrap();
552 }
553
554 #[test]
555 fn protected_refs_round_trip_through_the_repository_config() {
556 let (_dir, store) = store();
557 let path = make(&store, "alice", "site");
558
559 assert!(
560 protected_refs(&path).is_empty(),
561 "nothing is protected by default"
562 );
563
564 let wanted = vec!["refs/heads/main".to_string(), "refs/tags/v1".to_string()];
565 set_protected_refs(&path, &wanted).unwrap();
566 assert_eq!(protected_refs(&path), wanted);
567
568 set_protected_refs(&path, &[]).unwrap();
569 assert!(protected_refs(&path).is_empty());
570 }
571
572 #[test]
573 fn a_protected_ref_name_is_validated_before_it_reaches_git_config() {
574 let (_dir, store) = store();
575 let path = make(&store, "alice", "site");
576 assert!(set_protected_refs(&path, &["../../config".to_string()]).is_err());
577 }
578
579 /// Create a root commit directly in the bare repo, returning its oid.
580 fn commit(git_dir: &Path, message: &str) -> String {
581 let tree = git(git_dir, &["hash-object", "-t", "tree", "-w", "--stdin"]);
582 let out = Command::new("git")
583 .arg("--git-dir")
584 .arg(git_dir)
585 .args(["commit-tree", &tree, "-m", message])
586 .env("GIT_AUTHOR_NAME", "t")
587 .env("GIT_AUTHOR_EMAIL", "t@e")
588 .env("GIT_COMMITTER_NAME", "t")
589 .env("GIT_COMMITTER_EMAIL", "t@e")
590 .env("GIT_AUTHOR_DATE", format!("@{} +0000", message.len()))
591 .output()
592 .unwrap();
593 String::from_utf8_lossy(&out.stdout).trim().to_string()
594 }
595
596 fn commit_onto(git_dir: &Path, parent: &str, message: &str) -> String {
597 let tree = git(git_dir, &["hash-object", "-t", "tree", "-w", "--stdin"]);
598 let out = Command::new("git")
599 .arg("--git-dir")
600 .arg(git_dir)
601 .args(["commit-tree", &tree, "-p", parent, "-m", message])
602 .env("GIT_AUTHOR_NAME", "t")
603 .env("GIT_AUTHOR_EMAIL", "t@e")
604 .env("GIT_COMMITTER_NAME", "t")
605 .env("GIT_COMMITTER_EMAIL", "t@e")
606 .output()
607 .unwrap();
608 String::from_utf8_lossy(&out.stdout).trim().to_string()
609 }
610
611 fn git(git_dir: &Path, args: &[&str]) -> String {
612 use std::io::Write;
613 use std::process::Stdio;
614 let mut child = Command::new("git")
615 .arg("--git-dir")
616 .arg(git_dir)
617 .args(args)
618 .stdin(Stdio::piped())
619 .stdout(Stdio::piped())
620 .spawn()
621 .unwrap();
622 child.stdin.take().unwrap().write_all(b"").unwrap();
623 let out = child.wait_with_output().unwrap();
624 String::from_utf8_lossy(&out.stdout).trim().to_string()
625 }
626}