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 | // Storage accounting and quota enforcement. |
| 2 | // |
| 3 | // Size comes from `git count-objects -v`, which reports what git actually holds — |
| 4 | // loose objects, packs and their indexes — rather than a directory walk that would |
| 5 | // also count working state, logs and the lockfiles GC leaves behind. |
| 6 | // |
| 7 | // Enforcement is deliberately coarse: a push is refused when the repository is |
| 8 | // *already* over its limit, not when it *would* go over. Knowing the final size |
| 9 | // before accepting a pack means buffering the pack, which is exactly what the |
| 10 | // streaming transport exists to avoid. `receive.maxInputSize` bounds the overshoot |
| 11 | // to one pack, and that is the trade. |
| 12 | |
| 13 | use crate::error::{Error, Result}; |
| 14 | use crate::git::exec::Git; |
| 15 | use crate::git::validate::Name; |
| 16 | use serde::Serialize; |
| 17 | use std::path::Path; |
| 18 | |
| 19 | /// Limits applied to one account. A zero means "no limit". |
| 20 | #[derive(Debug, Clone, Copy)] |
| 21 | pub struct Limits { |
| 22 | pub max_repos: u64, |
| 23 | pub max_repo_bytes: u64, |
| 24 | pub max_account_bytes: u64, |
| 25 | } |
| 26 | |
| 27 | impl Limits { |
| 28 | /// Whether every limit is disabled, so callers can skip the accounting walk. |
| 29 | pub fn disabled(&self) -> bool { |
| 30 | self.max_repos == 0 && self.max_repo_bytes == 0 && self.max_account_bytes == 0 |
| 31 | } |
| 32 | } |
| 33 | |
| 34 | #[derive(Debug, Clone, Copy, Serialize)] |
| 35 | pub struct Usage { |
| 36 | pub repos: u64, |
| 37 | pub bytes: u64, |
| 38 | } |
| 39 | |
| 40 | /// Bytes a repository occupies, as git accounts for it. |
| 41 | /// |
| 42 | /// Returns zero rather than an error when the repository cannot be read: quota is |
| 43 | /// not the right place to surface a broken repository, and failing closed here |
| 44 | /// would block pushes to every other repository in the account. |
| 45 | pub fn repo_bytes(git_dir: &Path) -> u64 { |
| 46 | let Ok(out) = Git::at(git_dir).run(&["count-objects", "-v"]) else { |
| 47 | return 0; |
| 48 | }; |
| 49 | |
| 50 | // `size` and `size-pack` are reported in KiB; the rest of the fields are counts. |
| 51 | let mut kib = 0u64; |
| 52 | for line in String::from_utf8_lossy(&out).lines() { |
| 53 | if let Some(value) = line |
| 54 | .strip_prefix("size: ") |
| 55 | .or_else(|| line.strip_prefix("size-pack: ")) |
| 56 | { |
| 57 | kib += value.trim().parse::<u64>().unwrap_or(0); |
| 58 | } |
| 59 | } |
| 60 | kib.saturating_mul(1024) |
| 61 | } |
| 62 | |
| 63 | /// Total usage for an account. |
| 64 | pub fn account_usage(git_root: &Path, account: &Name) -> Usage { |
| 65 | let dir = git_root.join(account.as_str()); |
| 66 | let Ok(entries) = std::fs::read_dir(&dir) else { |
| 67 | return Usage { repos: 0, bytes: 0 }; |
| 68 | }; |
| 69 | |
| 70 | let mut usage = Usage { repos: 0, bytes: 0 }; |
| 71 | for entry in entries.flatten() { |
| 72 | let path = entry.path(); |
| 73 | if path.extension().and_then(|e| e.to_str()) != Some("git") { |
| 74 | continue; |
| 75 | } |
| 76 | usage.repos += 1; |
| 77 | usage.bytes = usage.bytes.saturating_add(repo_bytes(&path)); |
| 78 | } |
| 79 | usage |
| 80 | } |
| 81 | |
| 82 | /// Whether an account may create another repository. |
| 83 | pub fn check_repo_count(usage: &Usage, limits: Limits) -> Result<()> { |
| 84 | if limits.max_repos > 0 && usage.repos >= limits.max_repos { |
| 85 | return Err(Error::QuotaExceeded { |
| 86 | limit: limits.max_repos, |
| 87 | used: usage.repos, |
| 88 | }); |
| 89 | } |
| 90 | Ok(()) |
| 91 | } |
| 92 | |
| 93 | /// Whether a repository may accept more data. |
| 94 | /// |
| 95 | /// Checked before a push is accepted. Over-quota blocks writes only — reads and |
| 96 | /// clones keep working, because taking someone's data away from them is the wrong |
| 97 | /// response to them having too much of it. |
| 98 | pub fn check_writable(repo_bytes: u64, usage: &Usage, limits: Limits) -> Result<()> { |
| 99 | if limits.max_repo_bytes > 0 && repo_bytes >= limits.max_repo_bytes { |
| 100 | return Err(Error::QuotaExceeded { |
| 101 | limit: limits.max_repo_bytes, |
| 102 | used: repo_bytes, |
| 103 | }); |
| 104 | } |
| 105 | if limits.max_account_bytes > 0 && usage.bytes >= limits.max_account_bytes { |
| 106 | return Err(Error::QuotaExceeded { |
| 107 | limit: limits.max_account_bytes, |
| 108 | used: usage.bytes, |
| 109 | }); |
| 110 | } |
| 111 | Ok(()) |
| 112 | } |
| 113 | |
| 114 | #[cfg(test)] |
| 115 | mod tests { |
| 116 | use super::*; |
| 117 | |
| 118 | const LIMITS: Limits = Limits { |
| 119 | max_repos: 3, |
| 120 | max_repo_bytes: 1000, |
| 121 | max_account_bytes: 2000, |
| 122 | }; |
| 123 | |
| 124 | #[test] |
| 125 | fn a_zero_limit_disables_that_check() { |
| 126 | let unlimited = Limits { |
| 127 | max_repos: 0, |
| 128 | max_repo_bytes: 0, |
| 129 | max_account_bytes: 0, |
| 130 | }; |
| 131 | assert!(unlimited.disabled()); |
| 132 | |
| 133 | let usage = Usage { |
| 134 | repos: 9_999, |
| 135 | bytes: u64::MAX, |
| 136 | }; |
| 137 | check_repo_count(&usage, unlimited).unwrap(); |
| 138 | check_writable(u64::MAX, &usage, unlimited).unwrap(); |
| 139 | } |
| 140 | |
| 141 | #[test] |
| 142 | fn repo_count_is_refused_at_the_limit_not_past_it() { |
| 143 | let under = Usage { repos: 2, bytes: 0 }; |
| 144 | check_repo_count(&under, LIMITS).unwrap(); |
| 145 | |
| 146 | let at = Usage { repos: 3, bytes: 0 }; |
| 147 | let err = check_repo_count(&at, LIMITS).unwrap_err(); |
| 148 | assert_eq!(err.status(), 413); |
| 149 | assert_eq!(err.slug(), "quota-exceeded"); |
| 150 | } |
| 151 | |
| 152 | #[test] |
| 153 | fn a_repository_over_its_own_limit_stops_accepting_writes() { |
| 154 | let usage = Usage { repos: 1, bytes: 0 }; |
| 155 | check_writable(999, &usage, LIMITS).unwrap(); |
| 156 | assert!(check_writable(1000, &usage, LIMITS).is_err()); |
| 157 | } |
| 158 | |
| 159 | #[test] |
| 160 | fn an_account_over_its_total_stops_accepting_writes_to_any_repo() { |
| 161 | let usage = Usage { |
| 162 | repos: 2, |
| 163 | bytes: 2000, |
| 164 | }; |
| 165 | assert!( |
| 166 | check_writable(1, &usage, LIMITS).is_err(), |
| 167 | "a small repo must still be blocked when the account is full" |
| 168 | ); |
| 169 | } |
| 170 | |
| 171 | #[test] |
| 172 | fn the_problem_body_reports_both_the_limit_and_the_usage() { |
| 173 | let usage = Usage { repos: 3, bytes: 0 }; |
| 174 | let problem = check_repo_count(&usage, LIMITS).unwrap_err().problem(); |
| 175 | assert_eq!(problem["limit"], 3); |
| 176 | assert_eq!(problem["used"], 3); |
| 177 | } |
| 178 | |
| 179 | #[test] |
| 180 | fn an_unreadable_repository_accounts_as_zero_rather_than_failing() { |
| 181 | assert_eq!(repo_bytes(Path::new("/nonexistent/repo.git")), 0); |
| 182 | } |
| 183 | |
| 184 | #[test] |
| 185 | fn a_real_repository_reports_its_object_size() { |
| 186 | let dir = tempfile::tempdir().unwrap(); |
| 187 | let git_dir = dir.path().join("r.git"); |
| 188 | Git::plain() |
| 189 | .run(&["init", "--bare", "--quiet", git_dir.to_str().unwrap()]) |
| 190 | .unwrap(); |
| 191 | |
| 192 | // An empty repository holds no objects. |
| 193 | assert_eq!(repo_bytes(&git_dir), 0); |
| 194 | |
| 195 | // Writing one object makes it non-zero. |
| 196 | let mut child = std::process::Command::new("git") |
| 197 | .arg("--git-dir") |
| 198 | .arg(&git_dir) |
| 199 | .args(["hash-object", "-w", "--stdin"]) |
| 200 | .stdin(std::process::Stdio::piped()) |
| 201 | .stdout(std::process::Stdio::null()) |
| 202 | .spawn() |
| 203 | .unwrap(); |
| 204 | { |
| 205 | use std::io::Write; |
| 206 | child.stdin.take().unwrap().write_all(b"hello").unwrap(); |
| 207 | } |
| 208 | child.wait().unwrap(); |
| 209 | |
| 210 | assert!( |
| 211 | repo_bytes(&git_dir) > 0, |
| 212 | "a stored object must be accounted" |
| 213 | ); |
| 214 | } |
| 215 | } |