zuka
zuka/src/store/quota.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/store/quota.rs
RSquota.rs6.8 KBDownload
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
13use crate::error::{Error, Result};
14use crate::git::exec::Git;
15use crate::git::validate::Name;
16use serde::Serialize;
17use std::path::Path;
18
19/// Limits applied to one account. A zero means "no limit".
20#[derive(Debug, Clone, Copy)]
21pub struct Limits {
22 pub max_repos: u64,
23 pub max_repo_bytes: u64,
24 pub max_account_bytes: u64,
25}
26
27impl 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)]
35pub 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.
45pub 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.
64pub 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.
83pub 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.
98pub 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)]
115mod 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}