1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
//! Registry byte-quota classifier for `apr pull` (CRUX-A-22).
//!
//! Contract: `contracts/crux-A-22-v1.yaml`.
//!
//! Two pure algorithm-level necessary conditions:
//!
//! 1. `classify_pull_against_quota(quota, used, incoming)` is a pure
//! function of three scalars. It returns Allow iff
//! `used + incoming ≤ quota`, else Reject with the used/free/needed
//! triple. Because the decision is pre-download and depends only on
//! these three inputs, the "no partial blobs on disk after reject"
//! invariant (FALSIFY-CRUX-A-22-002) cannot be violated at this
//! layer: rejection happens before any network I/O starts.
//!
//! 2. `render_quota_error_json(used, free, needed)` emits the machine-
//! parseable error body with the three contract-defined fields.
//! FALSIFY-CRUX-A-22-001 asserts a downstream JSON body with
//! used / free / needed keys; this classifier proves the renderer
//! always includes them and the values are self-consistent.
use serde::Serialize;
/// Decision for a single pull request against the current registry state.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum QuotaOutcome {
/// Pull fits in the remaining budget.
Allow { free_after: u64 },
/// Pull would exceed the quota — rejected pre-download.
Reject { used: u64, free: u64, needed: u64 },
}
/// Decide whether a pull of `incoming` bytes is allowed given the
/// current registry state.
///
/// Saturating arithmetic on `used + incoming` prevents integer overflow
/// from masking a legitimate reject (e.g. u64::MAX used + 1 incoming).
pub fn classify_pull_against_quota(quota: u64, used: u64, incoming: u64) -> QuotaOutcome {
let free = quota.saturating_sub(used);
let required = used.saturating_add(incoming);
if required <= quota {
QuotaOutcome::Allow {
free_after: quota.saturating_sub(required),
}
} else {
QuotaOutcome::Reject {
used,
free,
needed: incoming,
}
}
}
/// Machine-parseable quota-exceeded error body.
///
/// FALSIFY-CRUX-A-22-001 requires the error to carry `used`, `free`,
/// and `needed` fields. Keeping the struct derived and serialized via
/// serde guarantees the keys cannot drift out of sync with callers.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct QuotaErrorBody {
pub error: &'static str,
pub used: u64,
pub free: u64,
pub needed: u64,
}
impl QuotaErrorBody {
pub fn new(used: u64, free: u64, needed: u64) -> Self {
Self {
error: "registry_quota_exceeded",
used,
free,
needed,
}
}
}
/// Render the quota-exceeded error body to a single JSON line.
///
/// Single line is required by the FALSIFY test which does
/// `open('/tmp/err').read().split('\n')[-2]` — the body must occupy
/// exactly one line so that shell pipelines can locate it.
pub fn render_quota_error_json(used: u64, free: u64, needed: u64) -> String {
let body = QuotaErrorBody::new(used, free, needed);
serde_json::to_string(&body).expect("QuotaErrorBody serializes as JSON")
}
#[cfg(test)]
mod tests {
use super::*;
// ===== classify_pull_against_quota =====
#[test]
fn allow_when_incoming_fits() {
let r = classify_pull_against_quota(1000, 200, 300);
assert_eq!(r, QuotaOutcome::Allow { free_after: 500 });
}
#[test]
fn allow_exactly_at_quota_boundary() {
// Used 600 + incoming 400 = 1000 == quota → exact fit is Allow.
let r = classify_pull_against_quota(1000, 600, 400);
assert_eq!(r, QuotaOutcome::Allow { free_after: 0 });
}
#[test]
fn reject_one_byte_over_quota() {
let r = classify_pull_against_quota(1000, 600, 401);
assert_eq!(
r,
QuotaOutcome::Reject {
used: 600,
free: 400,
needed: 401,
}
);
}
#[test]
fn reject_when_already_over_quota() {
// Used > quota can happen if quota was lowered after earlier pulls.
// Any incoming > 0 must still reject.
let r = classify_pull_against_quota(500, 800, 1);
match r {
QuotaOutcome::Reject { used, free, needed } => {
assert_eq!(used, 800);
assert_eq!(free, 0); // saturating_sub
assert_eq!(needed, 1);
}
other => panic!("expected Reject, got {other:?}"),
}
}
#[test]
fn reject_when_incoming_is_zero_but_used_over_quota() {
// Edge: used already exceeds quota AND incoming=0 → we choose to
// Allow because no new bytes are requested. This matches the
// "allow(pull) = free ≥ size(incoming)" formula where free may
// be 0 and size(incoming) is 0.
let r = classify_pull_against_quota(500, 800, 0);
// 800 + 0 = 800, 800 > 500 → Reject (we DO reject — honest to formula)
assert!(matches!(r, QuotaOutcome::Reject { .. }));
}
#[test]
fn saturating_arithmetic_prevents_overflow() {
// u64::MAX used + any positive incoming must reject, not wrap.
let r = classify_pull_against_quota(1000, u64::MAX, 1);
assert!(matches!(r, QuotaOutcome::Reject { .. }));
}
#[test]
fn classifier_is_deterministic() {
let a = classify_pull_against_quota(1000, 200, 300);
let b = classify_pull_against_quota(1000, 200, 300);
assert_eq!(a, b);
}
#[test]
fn reject_preserves_all_three_fields() {
// FALSIFY-CRUX-A-22-001 requires used/free/needed on every reject.
let r = classify_pull_against_quota(1000, 300, 900);
match r {
QuotaOutcome::Reject { used, free, needed } => {
assert_eq!(used, 300);
assert_eq!(free, 700);
assert_eq!(needed, 900);
assert!(used + needed > used + free, "needed must exceed free");
}
other => panic!("expected Reject, got {other:?}"),
}
}
// ===== render_quota_error_json =====
#[test]
fn error_json_contains_required_keys() {
let s = render_quota_error_json(100, 50, 200);
let v: serde_json::Value = serde_json::from_str(&s).unwrap();
for k in ["error", "used", "free", "needed"] {
assert!(v.get(k).is_some(), "missing key {k}: {s}");
}
}
#[test]
fn error_json_values_roundtrip() {
let s = render_quota_error_json(123, 456, 789);
let v: serde_json::Value = serde_json::from_str(&s).unwrap();
assert_eq!(v["used"].as_u64().unwrap(), 123);
assert_eq!(v["free"].as_u64().unwrap(), 456);
assert_eq!(v["needed"].as_u64().unwrap(), 789);
}
#[test]
fn error_json_discriminator_is_stable() {
// Callers may programmatically match on the `error` field.
let s = render_quota_error_json(0, 0, 0);
let v: serde_json::Value = serde_json::from_str(&s).unwrap();
assert_eq!(v["error"].as_str().unwrap(), "registry_quota_exceeded");
}
#[test]
fn error_json_is_single_line() {
// FALSIFY test picks up the last line of stderr — body must not
// contain embedded newlines.
let s = render_quota_error_json(100, 50, 200);
assert!(
!s.contains('\n'),
"quota error body must be single-line: {s:?}"
);
}
#[test]
fn error_json_is_deterministic() {
let a = render_quota_error_json(100, 50, 200);
let b = render_quota_error_json(100, 50, 200);
assert_eq!(a, b);
}
#[test]
fn error_json_parses_with_same_arithmetic_invariant() {
// Regression guard: the FALSIFY test asserts
// e['used'] + e['needed'] > e['used'] + e['free']
// which simplifies to needed > free. Our classifier only emits
// Reject when used + incoming > quota, i.e. incoming > free, so
// the invariant must hold on every Reject we serialize.
let r = classify_pull_against_quota(1000, 600, 500);
match r {
QuotaOutcome::Reject { used, free, needed } => {
let s = render_quota_error_json(used, free, needed);
let v: serde_json::Value = serde_json::from_str(&s).unwrap();
let needed = v["needed"].as_u64().unwrap();
let free = v["free"].as_u64().unwrap();
assert!(needed > free, "needed must exceed free on reject");
}
other => panic!("expected Reject, got {other:?}"),
}
}
}