Skip to content
569 linesCodeBlameRaw
1//! The cache of `actions/cache`: which entries each repository has, kept
2//! here, while the API keeps their bytes in R2 (the ACTIONS_CACHE bucket).
3//!
4//! - Entries are scoped by ref, as on GitHub (`scopes`): an entry belongs
5//! to the ref whose run saved it, and a run restores from its own ref,
6//! then its pull request's base branch, then the default branch. A pull
7//! request from outside the repository saves under `untrusted:<ref>`,
8//! which no other ref reads, so it can never plant an entry the default
9//! branch restores. g1t's own `actions/cache` and the toolkit's protocols
10//! follow the same rule (`Actions::cache_scope`).
11//! - An entry's version is the hash of its paths and compression, which
12//! the toolkit's client and g1t's runner both send: the same key saved
13//! for other paths is another entry.
14//! - A key is written once in its scope and version. In each scope in
15//! turn, a restore finds its key exactly, else the newest entry whose key
16//! starts with one of its restore keys.
17//! - An entry is at most `CACHE_MAX_ENTRY_BYTES`. A repository's entries
18//! hold at most `CACHE_REPO_QUOTA_BYTES` together: saving past it evicts
19//! the entries restored longest ago.
20//! - An entry not restored for `CACHE_UNUSED_DAYS`, or saved more than
21//! `CACHE_MAX_AGE_DAYS` ago, is deleted by the hourly sweep, which also
22//! writes down what each workspace holds, its artifacts included (they
23//! are in the same bucket), and reports this month's storage to billing
24//! (`note_pending`, source `cache`) at R2's price.
25//!
26//! The API calls these with the job's token, or its runtime token for the
27//! toolkit's protocols (runtime.rs), which is checked here. An entry the
28//! toolkit saves has the toolkit's version, and is found only by the same
29//! version; g1t's own `actions/cache` saves and finds entries without one.
30
31use g1t_contracts::FailureCode;
32use g1t_contracts::Outcome;
33use g1t_contracts::actions::{
34 CACHE_MAX_AGE_DAYS, CACHE_MAX_ENTRY_BYTES, CACHE_MICROS_PER_GB_MONTH, CACHE_REPO_QUOTA_BYTES, CACHE_UNUSED_DAYS,
35 CacheAbortArgs, CacheCommitArgs, CacheCommitted, CacheHit, CacheLookupArgs, CacheReservation, CacheReserveArgs, CacheUploadArgs,
36};
37use g1t_contracts::billing::NotePendingArgs;
38use g1t_contracts::new_id;
39use g1t_contracts::time::rfc3339;
40use g1t_kit::now_ms;
41use serde::Deserialize;
42use serde_json::Value;
43use worker::Result;
44
45use crate::{Actions, check, fail};
46
47const DAY_MS: u64 = 24 * 60 * 60 * 1000;
48/// An upload not finished after this long is given up.
49const PENDING_MS: u64 = 6 * 60 * 60 * 1000;
50/// A GB, as storage is billed.
51const GB: f64 = 1_000_000_000.0;
52
53#[derive(Debug, Deserialize)]
54struct EntryRow {
55 id: String,
56 key: String,
57 object: String,
58 size: f64,
59 created_at: String,
60}
61
62/// The longest key: GitHub's limit.
63const MAX_KEY_CHARS: usize = 512;
64
65/// Whether a key can be kept: 1 to 512 characters, no commas (GitHub's rule).
66pub(crate) fn valid_key(key: &str) -> bool {
67 !key.is_empty() && key.chars().count() <= MAX_KEY_CHARS && !key.contains(',')
68}
69
70/// Which of `entries` (id, size, newest use first) to evict so that the
71/// repository holds at most `quota` with `keep` (just saved) among them.
72/// The entry just saved is never evicted.
73pub(crate) fn to_evict(entries: &[(String, u64)], keep: &str, quota: u64) -> Vec<String> {
74 let mut total: u64 = entries.iter().map(|(_, size)| size).sum();
75 let mut out = Vec::new();
76 for (id, size) in entries.iter().rev() {
77 if total <= quota {
78 break;
79 }
80 if id == keep {
81 continue;
82 }
83 total = total.saturating_sub(*size);
84 out.push(id.clone());
85 }
86 out
87}
88
89/// Whether a repository holding `total` bytes must evict to stay within
90/// `quota`: what `to_evict` would take something from.
91pub(crate) fn over_quota(total: u64, quota: u64) -> bool {
92 total > quota
93}
94
95/// A month's GB-months from the bytes held each day so far: each day's
96/// bytes over 30 days.
97pub(crate) fn gb_months(days: &[u64]) -> f64 {
98 days.iter().map(|bytes| *bytes as f64).sum::<f64>() / GB / 30.0
99}
100
101/// What `gb_months` of cache cost g1t, in millionths of a dollar.
102pub(crate) fn storage_cost(gb_months: f64) -> i64 {
103 (gb_months * CACHE_MICROS_PER_GB_MONTH as f64).ceil() as i64
104}
105
106/// Where a job's cache entries are found and saved: its repository, and in
107/// it the refs it restores from, in order, and the one it saves to.
108pub(crate) struct CacheScope {
109 pub repo_id: String,
110 pub restore: Vec<String>,
111 pub save: String,
112}
113
114/// The scopes a run's jobs restore from, in order, and the one they save
115/// to: its own ref, then its pull request's base branch, then the default
116/// branch. A run that is not trusted (a pull request from outside) saves to
117/// a scope of its own that no other ref reads.
118pub(crate) fn scopes(git_ref: &str, base_ref: Option<&str>, default_branch: &str, trusted: bool) -> (Vec<String>, String) {
119 let own = if trusted { git_ref.to_owned() } else { format!("untrusted:{git_ref}") };
120 let mut restore = vec![own.clone()];
121 let base = base_ref.filter(|base| !base.is_empty()).map(|base| format!("refs/heads/{}", base.trim_start_matches("refs/heads/")));
122 for scope in base.into_iter().chain(std::iter::once(format!("refs/heads/{default_branch}"))) {
123 if !restore.contains(&scope) {
124 restore.push(scope);
125 }
126 }
127 (restore, own)
128}
129
130impl Actions {
131 /// Where a job's entries are found and saved (`scopes`), for g1t's own
132 /// `actions/cache` and the toolkit's protocols alike.
133 pub(crate) async fn cache_scope(&self, job: &crate::plan::JobRow) -> Result<CacheScope> {
134 let (restore, save) = match self.run_row(&job.run_id).await? {
135 Some(run) => {
136 let info = run.info();
137 scopes(&run.git_ref, info.base_ref.as_deref(), &info.default_branch, run.trusted != 0)
138 }
139 None => (Vec::new(), format!("untrusted:{}", job.run_id)),
140 };
141 Ok(CacheScope { repo_id: job.repo_id.clone(), restore, save })
142 }
143
144 /// A job by its own token or its runtime token (runtime.rs).
145 async fn cache_job(&self, job: &str, token: &str) -> Result<Outcome<crate::plan::JobRow>> {
146 self.job_for_credential(job, token).await
147 }
148
149 /// `cache_lookup`.
150 pub async fn cache_lookup(&self, a: CacheLookupArgs) -> Result<Outcome<Option<CacheHit>>> {
151 let job = check!(self.cache_job(&a.job, &a.token).await?);
152 let scope = self.cache_scope(&job).await?;
153 let now = now_ms();
154 let fresh = rfc3339(now.saturating_sub(CACHE_MAX_AGE_DAYS * DAY_MS));
155 // Found only by the same version: runners from before it was sent
156 // send none, and find only entries saved without one.
157 let version = a.version.clone().unwrap_or_default();
158 let mut found = None;
159 'scopes: for ref_scope in &scope.restore {
160 found = self
161 .db
162 .prepare(
163 "SELECT id, key, object, size, created_at FROM cache_entries
164 WHERE repo_id = ? AND scope = ? AND key = ? AND version = ? AND status = 'ready' AND created_at > ?",
165 )
166 .bind(&[
167 scope.repo_id.as_str().into(),
168 ref_scope.as_str().into(),
169 a.key.as_str().into(),
170 version.as_str().into(),
171 fresh.as_str().into(),
172 ])?
173 .first::<EntryRow>(None)
174 .await?;
175 if found.is_some() {
176 break;
177 }
178 for prefix in a.restore.iter().map(|p| p.trim()).filter(|p| !p.is_empty()) {
179 found = self
180 .db
181 .prepare(
182 "SELECT id, key, object, size, created_at FROM cache_entries
183 WHERE repo_id = ?1 AND scope = ?5 AND version = ?4 AND status = 'ready' AND created_at > ?3
184 AND substr(key, 1, length(?2)) = ?2
185 ORDER BY created_at DESC LIMIT 1",
186 )
187 .bind(&[
188 scope.repo_id.as_str().into(),
189 prefix.into(),
190 fresh.as_str().into(),
191 version.as_str().into(),
192 ref_scope.as_str().into(),
193 ])?
194 .first::<EntryRow>(None)
195 .await?;
196 if found.is_some() {
197 break 'scopes;
198 }
199 }
200 }
201 let Some(entry) = found else { return Ok(Outcome::Ok(None)) };
202 self.db
203 .prepare("UPDATE cache_entries SET last_used_at = ? WHERE id = ?")
204 .bind(&[rfc3339(now).into(), entry.id.as_str().into()])?
205 .run()
206 .await?;
207 let blob = a.version.as_ref().and_then(|_| self.download_token("cache", &entry.id, &entry.object, crate::runtime::DOWNLOAD_SECONDS));
208 Ok(Outcome::Ok(Some(CacheHit { key: entry.key, object: entry.object, size: entry.size as u64, created_at: entry.created_at, blob })))
209 }
210
211 /// `cache_upload`.
212 pub async fn cache_upload(&self, a: CacheUploadArgs) -> Result<Outcome<CacheReservation>> {
213 let job = check!(self.cache_job(&a.job, &a.token).await?);
214 let scope = self.cache_scope(&job).await?;
215 #[derive(Deserialize)]
216 struct Pending {
217 id: String,
218 object: String,
219 number: f64,
220 upload: Option<String>,
221 }
222 let columns = "SELECT id, object, rowid AS number, upload FROM cache_entries";
223 let pending = match (a.number, a.key.as_deref()) {
224 (Some(number), _) => {
225 self.db
226 .prepare(format!("{columns} WHERE rowid = ? AND repo_id = ? AND status = 'pending'"))
227 .bind(&[(number as f64).into(), scope.repo_id.as_str().into()])?
228 .first::<Pending>(None)
229 .await?
230 }
231 (None, Some(key)) => {
232 self.db
233 .prepare(format!("{columns} WHERE repo_id = ? AND scope = ? AND key = ? AND version = ? AND status = 'pending'"))
234 .bind(&[scope.repo_id.as_str().into(), scope.save.as_str().into(), key.into(), a.version.clone().unwrap_or_default().into()])?
235 .first::<Pending>(None)
236 .await?
237 }
238 (None, None) => None,
239 };
240 let Some(pending) = pending else {
241 return Ok(fail(FailureCode::NotFound, "No upload of that entry is in progress."));
242 };
243 let blob = match &pending.upload {
244 Some(upload) => match self.upload_token("cache", &pending.id, &pending.object, upload) {
245 Some(blob) => Some(blob),
246 None => return Ok(fail(FailureCode::Invalid, "The toolkit's storage is not set up here: the actions service has no ACTIONS_KEY.")),
247 },
248 None => None,
249 };
250 Ok(Outcome::Ok(CacheReservation { id: pending.id, object: pending.object, number: pending.number as u64, upload: pending.upload, blob }))
251 }
252
253 /// `cache_reserve`.
254 pub async fn cache_reserve(&self, a: CacheReserveArgs) -> Result<Outcome<CacheReservation>> {
255 let job = check!(self.cache_job(&a.job, &a.token).await?);
256 let scope = self.cache_scope(&job).await?;
257 if !valid_key(&a.key) {
258 return Ok(fail(FailureCode::Invalid, format!("A cache key is 1 to {MAX_KEY_CHARS} characters, without commas.")));
259 }
260 if a.size > CACHE_MAX_ENTRY_BYTES {
261 return Ok(fail(
262 FailureCode::Invalid,
263 format!("It is {} MB; a cache entry is at most {} MB.", a.size / 1_048_576, CACHE_MAX_ENTRY_BYTES / 1_048_576),
264 ));
265 }
266 if let Some(why) = self.storage_refused(&job.namespace, a.size).await? {
267 return Ok(fail(FailureCode::Limit, why));
268 }
269 let now = now_ms();
270 let at = rfc3339(now);
271 let version = a.version.clone().unwrap_or_default();
272 // An upload left unfinished long ago no longer holds its key.
273 self.db
274 .prepare(
275 "UPDATE cache_entries SET status = 'expired'
276 WHERE repo_id = ? AND scope = ? AND key = ? AND version = ? AND status = 'pending' AND created_at < ?",
277 )
278 .bind(&[
279 scope.repo_id.as_str().into(),
280 scope.save.as_str().into(),
281 a.key.as_str().into(),
282 version.as_str().into(),
283 rfc3339(now.saturating_sub(PENDING_MS)).into(),
284 ])?
285 .run()
286 .await?;
287 let id = new_id("cache", now);
288 let object = format!("c/{}/{id}", scope.repo_id);
289 #[derive(Deserialize)]
290 struct Inserted {
291 number: f64,
292 }
293 // A key is written once in its scope and version, never into
294 // another ref's scope.
295 let inserted = self
296 .db
297 .prepare(
298 "INSERT INTO cache_entries (id, repo_id, namespace, key, object, size, status, created_at, last_used_at, version, scope)
299 VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'pending', ?7, ?7, ?8, ?9)
300 ON CONFLICT (repo_id, scope, key, version) DO UPDATE SET
301 id = ?1, object = ?5, size = ?6, status = 'pending', created_at = ?7, last_used_at = ?7, version = ?8, upload = NULL
302 WHERE cache_entries.status = 'expired'
303 RETURNING rowid AS number",
304 )
305 .bind(&[
306 id.as_str().into(),
307 scope.repo_id.as_str().into(),
308 job.namespace.as_str().into(),
309 a.key.as_str().into(),
310 object.as_str().into(),
311 (a.size as f64).into(),
312 at.as_str().into(),
313 version.as_str().into(),
314 scope.save.as_str().into(),
315 ])?
316 .first::<Inserted>(None)
317 .await?;
318 let Some(inserted) = inserted else {
319 return Ok(fail(FailureCode::Conflict, "That key is already cached."));
320 };
321 Ok(Outcome::Ok(CacheReservation { id, object, number: inserted.number as u64, upload: None, blob: None }))
322 }
323
324 /// `cache_commit`: the entry is ready; entries past the quota are
325 /// evicted, restored longest ago first.
326 pub async fn cache_commit(&self, a: CacheCommitArgs) -> Result<Outcome<CacheCommitted>> {
327 let job = check!(self.cache_job(&a.job, &a.token).await?);
328 if a.size > CACHE_MAX_ENTRY_BYTES {
329 return Ok(fail(FailureCode::Invalid, "That entry is larger than a cache entry may be."));
330 }
331 let ready = self
332 .db
333 .prepare("UPDATE cache_entries SET status = 'ready', size = ?, last_used_at = ? WHERE id = ? AND repo_id = ? AND status = 'pending' RETURNING id")
334 .bind(&[(a.size as f64).into(), rfc3339(now_ms()).into(), a.id.as_str().into(), job.repo_id.as_str().into()])?
335 .first::<Value>(None)
336 .await?;
337 if ready.is_none() {
338 return Ok(fail(FailureCode::NotFound, "No upload of that entry is in progress."));
339 }
340 // What the repository holds, summed: only past the quota are its
341 // entries listed to choose what to evict. A tool that saves an
342 // entry per compiled file (sccache) commits thousands of small
343 // entries a build, and listing them all on each was quadratic.
344 #[derive(Deserialize)]
345 struct Total {
346 bytes: Option<f64>,
347 }
348 let total = self
349 .db
350 .prepare("SELECT SUM(size) AS bytes FROM cache_entries WHERE repo_id = ? AND status = 'ready'")
351 .bind(&[job.repo_id.as_str().into()])?
352 .first::<Total>(None)
353 .await?
354 .and_then(|t| t.bytes)
355 .unwrap_or(0.0);
356 if !over_quota(total as u64, CACHE_REPO_QUOTA_BYTES) {
357 return Ok(Outcome::Ok(CacheCommitted { evicted: Vec::new() }));
358 }
359 #[derive(Deserialize)]
360 struct Held {
361 id: String,
362 object: String,
363 size: f64,
364 }
365 let held = self
366 .db
367 .prepare("SELECT id, object, size FROM cache_entries WHERE repo_id = ? AND status = 'ready' ORDER BY last_used_at DESC")
368 .bind(&[job.repo_id.as_str().into()])?
369 .all()
370 .await?
371 .results::<Held>()?;
372 let sizes: Vec<(String, u64)> = held.iter().map(|h| (h.id.clone(), h.size as u64)).collect();
373 let evict = to_evict(&sizes, &a.id, CACHE_REPO_QUOTA_BYTES);
374 let mut evicted = Vec::new();
375 for id in &evict {
376 if let Some(entry) = held.iter().find(|h| &h.id == id) {
377 self.db.prepare("DELETE FROM cache_entries WHERE id = ?").bind(&[id.as_str().into()])?.run().await?;
378 evicted.push(entry.object.clone());
379 }
380 }
381 Ok(Outcome::Ok(CacheCommitted { evicted }))
382 }
383
384 /// `cache_abort`.
385 pub async fn cache_abort(&self, a: CacheAbortArgs) -> Result<Outcome<bool>> {
386 let job = check!(self.cache_job(&a.job, &a.token).await?);
387 self.db
388 .prepare("DELETE FROM cache_entries WHERE id = ? AND repo_id = ? AND status = 'pending'")
389 .bind(&[a.id.as_str().into(), job.repo_id.as_str().into()])?
390 .run()
391 .await?;
392 Ok(Outcome::Ok(true))
393 }
394
395 /// Hourly: deletes entries unused for a week, saved too long ago, or
396 /// left unfinished, and their objects; then what each workspace's
397 /// cache holds today, and this month's storage, for billing.
398 pub async fn sweep_cache(&self, now: u64) -> Result<()> {
399 let at = |ms: u64| rfc3339(now.saturating_sub(ms));
400 self.db
401 .prepare(
402 "UPDATE cache_entries SET status = 'expired'
403 WHERE (status = 'ready' AND (last_used_at < ?1 OR created_at < ?2)) OR (status = 'pending' AND created_at < ?3)",
404 )
405 .bind(&[at(CACHE_UNUSED_DAYS * DAY_MS).into(), at(CACHE_MAX_AGE_DAYS * DAY_MS).into(), at(PENDING_MS).into()])?
406 .run()
407 .await?;
408 #[derive(Deserialize)]
409 struct Gone {
410 id: String,
411 object: String,
412 }
413 let gone = self
414 .db
415 .prepare("SELECT id, object FROM cache_entries WHERE status = 'expired' LIMIT 500")
416 .all()
417 .await?
418 .results::<Gone>()?;
419 if let Some(bucket) = &self.cache {
420 for chunk in gone.chunks(100) {
421 if let Err(error) = bucket.delete_multiple(chunk.iter().map(|g| g.object.as_str()).collect()).await {
422 worker::console_error!("actions: cache objects not deleted: {error}");
423 return Ok(());
424 }
425 for entry in chunk {
426 self.db.prepare("DELETE FROM cache_entries WHERE id = ?").bind(&[entry.id.as_str().into()])?.run().await?;
427 }
428 }
429 }
430 self.measure_cache(now).await
431 }
432
433 /// What each workspace's cache holds today, and this month's storage so
434 /// far reported to billing, which charges it to workspaces on the plan
435 /// once the month is over.
436 async fn measure_cache(&self, now: u64) -> Result<()> {
437 #[derive(Deserialize)]
438 struct Held {
439 namespace: String,
440 bytes: f64,
441 }
442 let today = rfc3339(now);
443 let (day, month) = (&today[..10], &today[..7]);
444 let held = self
445 .db
446 // Artifacts are kept in the same bucket, at the same price.
447 .prepare(
448 "SELECT namespace, SUM(size) AS bytes FROM (
449 SELECT namespace, size FROM cache_entries WHERE status = 'ready'
450 UNION ALL SELECT namespace, size FROM artifacts WHERE status = 'ready'
451 ) GROUP BY namespace",
452 )
453 .all()
454 .await?
455 .results::<Held>()?;
456 for workspace in &held {
457 self.db
458 .prepare(
459 "INSERT INTO cache_days (namespace, day, bytes) VALUES (?1, ?2, ?3)
460 ON CONFLICT (namespace, day) DO UPDATE SET bytes = max(bytes, ?3)",
461 )
462 .bind(&[workspace.namespace.as_str().into(), day.into(), workspace.bytes.into()])?
463 .run()
464 .await?;
465 }
466 #[derive(Deserialize)]
467 struct Day {
468 namespace: String,
469 bytes: f64,
470 }
471 let days = self
472 .db
473 .prepare("SELECT namespace, bytes FROM cache_days WHERE substr(day, 1, 7) = ?")
474 .bind(&[month.into()])?
475 .all()
476 .await?
477 .results::<Day>()?;
478 let mut by_workspace: std::collections::BTreeMap<String, Vec<u64>> = std::collections::BTreeMap::new();
479 for d in days {
480 by_workspace.entry(d.namespace).or_default().push(d.bytes as u64);
481 }
482 for (workspace, days) in by_workspace {
483 let months = gb_months(&days);
484 let cost = storage_cost(months);
485 if cost <= 0 {
486 continue;
487 }
488 let noted: Result<bool> = g1t_kit::call(
489 &self.billing,
490 "note_pending",
491 &NotePendingArgs { workspace: workspace.clone(), source: "cache".to_owned(), cost_micros: cost, detail: Some(format!("{months:.2} GB-months")) },
492 )
493 .await;
494 if let Err(error) = noted {
495 worker::console_error!("actions: cache storage not reported for {workspace}: {error}");
496 }
497 }
498 Ok(())
499 }
500}
501
502#[cfg(test)]
503mod tests {
504 use super::*;
505
506 fn entries(sizes: &[(&str, u64)]) -> Vec<(String, u64)> {
507 sizes.iter().map(|(id, size)| ((*id).to_owned(), *size)).collect()
508 }
509
510 #[test]
511 fn a_run_restores_from_its_ref_then_its_base_then_the_default_branch() {
512 let (restore, save) = scopes("refs/heads/feature", None, "main", true);
513 assert_eq!(restore, ["refs/heads/feature", "refs/heads/main"]);
514 assert_eq!(save, "refs/heads/feature");
515 // A pull request: its own merge ref, the branch it merges into, the default.
516 let (restore, save) = scopes("refs/pull/7/merge", Some("release/1.x"), "main", true);
517 assert_eq!(restore, ["refs/pull/7/merge", "refs/heads/release/1.x", "refs/heads/main"]);
518 assert_eq!(save, "refs/pull/7/merge");
519 // The default branch reads only its own.
520 let (restore, _) = scopes("refs/heads/main", Some("main"), "main", true);
521 assert_eq!(restore, ["refs/heads/main"]);
522 // From outside: it saves where nothing else reads.
523 let (restore, save) = scopes("refs/pull/9/merge", Some("main"), "main", false);
524 assert_eq!(save, "untrusted:refs/pull/9/merge");
525 assert_eq!(restore, ["untrusted:refs/pull/9/merge", "refs/heads/main"]);
526 for trusted_ref in ["refs/heads/main", "refs/pull/9/merge", "refs/heads/feature"] {
527 let (others, _) = scopes(trusted_ref, Some("main"), "main", true);
528 assert!(!others.contains(&save), "{trusted_ref} must never read an untrusted entry");
529 }
530 }
531
532 #[test]
533 fn eviction_takes_the_entries_restored_longest_ago() {
534 // Newest use first.
535 let held = entries(&[("new", 4), ("b", 3), ("c", 3), ("old", 2)]);
536 assert_eq!(to_evict(&held, "new", 12), Vec::<String>::new());
537 assert_eq!(to_evict(&held, "new", 10), ["old"]);
538 assert_eq!(to_evict(&held, "new", 7), ["old", "c"]);
539 // The entry just saved stays, even when it alone is past the quota.
540 assert_eq!(to_evict(&entries(&[("big", 20), ("a", 1)]), "big", 10), ["a"]);
541 // Entries are listed only when the sum is over: at or under the
542 // quota, nothing would be evicted.
543 for (held, quota) in [(entries(&[("a", 4), ("b", 6)]), 10), (entries(&[("a", 1)]), 12)] {
544 let total = held.iter().map(|(_, size)| size).sum();
545 assert!(!over_quota(total, quota));
546 assert!(to_evict(&held, "a", quota).is_empty());
547 }
548 assert!(over_quota(11, 10));
549 }
550
551 #[test]
552 fn keys_are_checked() {
553 assert!(valid_key("cargo-Linux-abc123"));
554 assert!(!valid_key(""));
555 assert!(!valid_key("a,b"));
556 assert!(!valid_key(&"k".repeat(513)));
557 }
558
559 #[test]
560 fn storage_is_charged_by_the_gb_month_at_r2s_price() {
561 // 10 GB held for 30 days is 10 GB-months: $0.15.
562 let month = vec![10_000_000_000u64; 30];
563 assert!((gb_months(&month) - 10.0).abs() < 1e-9);
564 assert_eq!(storage_cost(gb_months(&month)), 150_000);
565 // A day of 1 GB: a thirtieth of a GB-month, rounded up.
566 assert_eq!(storage_cost(gb_months(&[1_000_000_000])), 500);
567 assert_eq!(storage_cost(0.0), 0);
568 }
569}