g1t/services/repos/src/store.rs

578 lines21,519 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Rust repos service with shipping; pull requests kept in the model1//! The storage that actually holds git repositories.
2//!
3//! The service depends on the [`GitStore`] and [`GitRepo`] ports;
4//! [`ArtifactsStore`] is the adapter for Cloudflare Artifacts.
5
Pull requests from branches6use g1t_contracts::repos::{Branch, Commit, EntryKind, GitAccess, Signature, TreeEntry};
RFC 3339 timestamps in identity and repos7use g1t_contracts::time::rfc3339;
Rust repos service with shipping; pull requests kept in the model8use g1t_kit::js;
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms9use serde::{Deserialize, Serialize};
10use std::cell::RefCell;
11use std::collections::HashMap;
12use std::rc::Rc;
Rust repos service with shipping; pull requests kept in the model13use worker::js_sys::{Reflect, Uint8Array};
14use worker::wasm_bindgen::{JsCast, JsValue};
15use worker::{Env, Result};
16
17/// How long a credential handed to git stays valid.
18const TOKEN_TTL_SECONDS: u32 = 300;
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms19/// The same, in milliseconds.
20pub const CREDENTIAL_LIFE_MS: u64 = TOKEN_TTL_SECONDS as u64 * 1000;
21/// How long a credential is reused for, so that every one used has at
22/// least two minutes left. Credentials never leave this service: g1t has
23/// already decided who may do what before one is used.
24const TOKEN_REUSE_MS: u64 = 180_000;
Rust repos service with shipping; pull requests kept in the model25
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms26#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
Rust repos service with shipping; pull requests kept in the model27pub enum Scope {
28 Read,
29 Write,
30}
31
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms32impl Scope {
33 fn as_str(self) -> &'static str {
34 match self {
35 Scope::Read => "read",
36 Scope::Write => "write",
37 }
38 }
39}
40
41/// Where a credential handed out came from, for `Server-Timing`.
42#[derive(Clone, Copy, PartialEq, Eq, Debug)]
43pub enum Kept {
44 /// This isolate made it, or had it from another, a moment ago.
45 Isolate,
46 /// Another isolate made it and shared it.
47 Shared,
48}
49
50impl Kept {
51 pub fn as_str(self) -> &'static str {
52 match self {
53 Kept::Isolate => "isolate",
54 Kept::Shared => "shared",
55 }
56 }
57}
58
Rust repos service with shipping; pull requests kept in the model59/// A place repositories live. `key` is the store's own name for a repo.
60#[allow(async_fn_in_trait)]
61pub trait GitStore {
62 type Repo: GitRepo;
63
64 /// Creates an empty repository. Succeeds if it already exists.
65 async fn create(
66 &self,
67 key: &str,
68 description: Option<&str>,
69 default_branch: &str,
70 ) -> Result<()>;
71 async fn open(&self, key: &str) -> Result<Self::Repo>;
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms72 /// A credential for `key` made a moment ago, if the store keeps one.
73 async fn kept_access(&self, _key: &str, _scope: Scope) -> Option<(GitAccess, Kept)> {
74 None
75 }
76 /// A new credential for `key`, which the store may keep for next time.
77 async fn mint_access(&self, key: &str, scope: Scope) -> Result<GitAccess> {
78 self.open(key).await?.access(scope).await
79 }
80 /// A remote URL and credential for git itself, for the repository at
81 /// `key`. A store may hand out one it made a moment ago.
82 async fn access(&self, key: &str, scope: Scope) -> Result<GitAccess> {
83 match self.kept_access(key, scope).await {
84 Some((access, _)) => Ok(access),
85 None => self.mint_access(key, scope).await,
86 }
87 }
88 /// Stops handing out the credentials it keeps for `key`: the store
89 /// turned one down, or the repository is gone.
90 async fn forget_access(&self, _key: &str) {}
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look91 /// Removes a repository and everything in it, for good. Succeeds if it
92 /// is already gone.
93 async fn delete(&self, key: &str) -> Result<()>;
Rust repos service with shipping; pull requests kept in the model94}
95
96/// One open repository.
97#[allow(async_fn_in_trait)]
98pub trait GitRepo {
99 /// A remote URL and short-lived credential for git itself.
100 async fn access(&self, scope: Scope) -> Result<GitAccess>;
Pull requests from branches101 /// Every branch and the commit it points to.
102 async fn branches(&self) -> Result<Vec<Branch>>;
Rust repos service with shipping; pull requests kept in the model103 /// Newest first along the first-parent chain; empty for an unknown ref.
104 async fn log(&self, git_ref: &str, limit: u32) -> Result<Vec<Commit>>;
105 /// The parents of a commit, or `None` if the commit does not exist.
106 async fn parents(&self, commit_hash: &str) -> Result<Option<Vec<String>>>;
107 async fn read_tree(&self, tree_hash: &str) -> Result<Option<Vec<TreeEntry>>>;
108 async fn read_blob(&self, blob_hash: &str) -> Result<Option<Vec<u8>>>;
109 /// `None` when the ref or path does not resolve to a file.
110 async fn read_file(&self, git_ref: &str, path: &str) -> Result<Option<Vec<u8>>>;
111 /// Makes a copy-on-write copy of this repository under `target_key`.
112 async fn fork(&self, target_key: &str) -> Result<()>;
113}
114
115pub struct ArtifactsStore {
116 binding: JsValue,
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms117 /// Where isolates share the credentials they make; see shared.rs.
118 shared: Option<Rc<crate::shared::Shared>>,
Rust repos service with shipping; pull requests kept in the model119}
120
121impl ArtifactsStore {
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms122 pub fn new(env: &Env, shared: Option<Rc<crate::shared::Shared>>) -> Result<Self> {
Rust repos service with shipping; pull requests kept in the model123 Ok(Self {
124 binding: js::binding(env, "ARTIFACTS")?,
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms125 shared,
Rust repos service with shipping; pull requests kept in the model126 })
127 }
128}
129
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms130/// A credential as isolates share it, sealed (see shared.rs): with when it
131/// was made, so that one shared is reused no longer than one kept here.
132#[derive(Serialize, Deserialize)]
133struct SharedCredential {
134 remote: String,
135 token: String,
136 made: u64,
137}
138
139/// The shared cache's key for a credential: the store's key for the
140/// repository, and the scope.
141fn shared_key(key: &str, scope: Scope) -> String {
142 format!("cred:{key}:{}", scope.as_str())
143}
144
145/// A shared credential, if it was made less than [`TOKEN_REUSE_MS`] before
146/// `now`; with when it was made.
147fn shared_credential(bytes: &[u8], now: u64) -> Option<(GitAccess, u64)> {
148 let kept: SharedCredential = serde_json::from_slice(bytes).ok()?;
149 (now.saturating_sub(kept.made) < TOKEN_REUSE_MS).then_some((
150 GitAccess {
151 remote: kept.remote,
152 token: kept.token,
153 },
154 kept.made,
155 ))
156}
157
158/// Credentials made in the last few minutes, by repository and scope.
159/// Making one is a round trip to the store on every git request; reusing
160/// it saves that, and the store's lookup of the repository with it.
161#[derive(Default)]
162pub struct Credentials {
163 kept: HashMap<(String, Scope), (GitAccess, u64)>,
164}
165
166impl Credentials {
167 /// One made for `key` and `scope` less than [`TOKEN_REUSE_MS`] before `now`.
168 pub fn get(&self, key: &str, scope: Scope, now: u64) -> Option<GitAccess> {
169 self.kept
170 .get(&(key.to_owned(), scope))
171 .filter(|(_, made)| now.saturating_sub(*made) < TOKEN_REUSE_MS)
172 .map(|(access, _)| access.clone())
173 }
174
175 pub fn keep(&mut self, key: &str, scope: Scope, access: GitAccess, now: u64) {
176 // Expired ones go first, so the map stays as small as the isolate's
177 // recent repositories.
178 self.kept
179 .retain(|_, (_, made)| now.saturating_sub(*made) < TOKEN_REUSE_MS);
180 self.kept.insert((key.to_owned(), scope), (access, now));
181 }
182
183 pub fn forget(&mut self, key: &str) {
184 self.kept.retain(|(kept, _), _| kept != key);
185 }
186}
187
188thread_local! {
189 static CREDENTIALS: RefCell<Credentials> = RefCell::new(Credentials::default());
190}
191
Rust repos service with shipping; pull requests kept in the model192impl GitStore for ArtifactsStore {
193 type Repo = ArtifactsRepo;
194
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms195 /// One kept in this isolate, else one another isolate shared. A shared
196 /// one is kept here only for the rest of its own reuse window.
197 async fn kept_access(&self, key: &str, scope: Scope) -> Option<(GitAccess, Kept)> {
198 let now = g1t_kit::now_ms();
199 if let Some(access) = CREDENTIALS.with(|kept| kept.borrow().get(key, scope, now)) {
200 return Some((access, Kept::Isolate));
201 }
202 let bytes = self.shared.as_ref()?.get(&shared_key(key, scope)).await?;
203 let (access, made) = shared_credential(&bytes, now)?;
204 CREDENTIALS.with(|kept| kept.borrow_mut().keep(key, scope, access.clone(), made));
205 Some((access, Kept::Shared))
206 }
207
208 /// Made by the store, then kept here and shared with other isolates.
209 async fn mint_access(&self, key: &str, scope: Scope) -> Result<GitAccess> {
210 let now = g1t_kit::now_ms();
211 let access = self.open(key).await?.access(scope).await?;
212 CREDENTIALS.with(|kept| kept.borrow_mut().keep(key, scope, access.clone(), now));
213 if let Some(shared) = &self.shared {
214 let value = SharedCredential {
215 remote: access.remote.clone(),
216 token: access.token.clone(),
217 made: now,
218 };
219 if let Ok(bytes) = serde_json::to_vec(&value) {
220 shared
221 .put(&shared_key(key, scope), &bytes, TOKEN_REUSE_MS / 1000)
222 .await;
223 }
224 }
225 Ok(access)
226 }
227
228 async fn forget_access(&self, key: &str) {
229 CREDENTIALS.with(|kept| kept.borrow_mut().forget(key));
230 if let Some(shared) = &self.shared {
231 futures_util::future::join(
232 shared.delete(&shared_key(key, Scope::Read)),
233 shared.delete(&shared_key(key, Scope::Write)),
234 )
235 .await;
236 }
237 }
238
Rust repos service with shipping; pull requests kept in the model239 async fn create(
240 &self,
241 key: &str,
242 description: Option<&str>,
243 default_branch: &str,
244 ) -> Result<()> {
245 let options = js::to_js(&serde_json::json!({
246 "description": description,
247 "setDefaultBranch": default_branch,
248 }))?;
249 match js::call(&self.binding, "create", &[key.into(), options]).await {
250 // Left behind by an earlier failed attempt; adopt it.
251 Err(thrown) if !thrown.is("ALREADY_EXISTS") => Err(thrown.into()),
252 _ => Ok(()),
253 }
254 }
255
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look256 async fn delete(&self, key: &str) -> Result<()> {
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms257 self.forget_access(key).await;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look258 match js::call(&self.binding, "delete", &[key.into()]).await {
259 // Gone already: an earlier purge got this far.
260 Err(thrown) if !thrown.is("NOT_FOUND") => Err(thrown.into()),
261 _ => Ok(()),
262 }
263 }
264
Rust repos service with shipping; pull requests kept in the model265 async fn open(&self, key: &str) -> Result<ArtifactsRepo> {
266 Ok(ArtifactsRepo {
267 handle: js::call(&self.binding, "get", &[key.into()]).await?,
Agents as a team: lifecycle, merge queue, billing and a new shell268 key: key.to_owned(),
Rust repos service with shipping; pull requests kept in the model269 })
270 }
271}
272
273/// A handle to one Artifacts repository. It is an RPC stub, so it is
274/// released when dropped.
275pub struct ArtifactsRepo {
276 handle: JsValue,
Agents as a team: lifecycle, merge queue, billing and a new shell277 /// The repository's store key, which scopes its cached objects.
278 key: String,
279}
280
281/// Where cached git objects live. Trees and blobs are named by their
282/// content, so a cached one is never stale; each is kept under its own
283/// repository's key, so a repository only ever finds its own objects.
284const OBJECT_CACHE: &str = "https://objects.g1t.internal/";
285/// Blobs larger than this are not cached.
286const MAX_CACHED_BLOB: usize = 1024 * 1024;
287const OBJECT_MAX_AGE: &str = "public, max-age=31536000, immutable";
288
Fast pages, required checks on the branch, self-hosted runners, honest incidents289/// Whether a ref is a full commit hash (SHA-1 or SHA-256), whose history
290/// can be kept for good.
291pub fn is_commit_hash(git_ref: &str) -> bool {
292 (git_ref.len() == 40 || git_ref.len() == 64) && git_ref.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
293}
294
Agents as a team: lifecycle, merge queue, billing and a new shell295impl ArtifactsRepo {
296 fn cache_url(&self, kind: &str, hash: &str) -> String {
297 format!("{OBJECT_CACHE}{}/{kind}/{hash}", self.key)
298 }
299
300 async fn cached(&self, kind: &str, hash: &str) -> Option<Vec<u8>> {
301 let mut response = worker::Cache::default()
302 .get(self.cache_url(kind, hash), false)
303 .await
304 .ok()??;
305 response.bytes().await.ok()
306 }
307
308 /// Keeps an object for next time. A failure only costs a later read.
309 async fn keep(&self, kind: &str, hash: &str, bytes: Vec<u8>) {
310 let Ok(mut response) = worker::Response::from_bytes(bytes) else {
311 return;
312 };
313 let _ = response.headers_mut().set("cache-control", OBJECT_MAX_AGE);
314 let _ = worker::Cache::default()
315 .put(self.cache_url(kind, hash), response)
316 .await;
317 }
Rust repos service with shipping; pull requests kept in the model318}
319
320impl Drop for ArtifactsRepo {
321 fn drop(&mut self) {
322 let symbol = js::get(&worker::js_sys::global(), "Symbol");
323 let dispose = js::get(&symbol, "dispose");
324 if let Ok(function) = Reflect::get(&self.handle, &dispose)
325 .and_then(|value| value.dyn_into::<worker::js_sys::Function>())
326 {
327 let _ = function.call0(&self.handle);
328 }
329 }
330}
331
332#[derive(Deserialize)]
333#[serde(rename_all = "camelCase")]
334struct RawCommit {
335 hash: String,
336 tree_hash: String,
337 message: String,
338 author: Signature,
339 parents: Vec<String>,
340 /// Seconds since the epoch.
341 authored_at: u64,
342}
343
344#[derive(Deserialize)]
345struct RawEntry {
346 name: String,
347 hash: String,
348 #[serde(rename = "type")]
349 kind: EntryKind,
350}
351
352#[derive(Deserialize)]
353struct RawInfo {
354 remote: String,
355}
356
357#[derive(Deserialize)]
358struct RawToken {
359 plaintext: String,
360}
361
362/// The bytes of a `Blob`, or `None` for null.
363async fn blob_bytes(blob: JsValue) -> Result<Option<Vec<u8>>> {
364 if blob.is_null() || blob.is_undefined() {
365 return Ok(None);
366 }
367 let buffer = js::call(&blob, "arrayBuffer", &[]).await?;
368 Ok(Some(Uint8Array::new(&buffer).to_vec()))
369}
370
371impl GitRepo for ArtifactsRepo {
372 async fn access(&self, scope: Scope) -> Result<GitAccess> {
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms373 let scope = scope.as_str();
374 // Two round trips to the store, at once.
375 let (info, token) = futures_util::future::join(
376 js::call(&self.handle, "info", &[]),
377 js::call(
Rust repos service with shipping; pull requests kept in the model378 &self.handle,
379 "createToken",
380 &[scope.into(), TOKEN_TTL_SECONDS.into()],
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms381 ),
382 )
383 .await;
384 let info: RawInfo = js::from_js(&info?)?;
385 let token: RawToken = js::from_js(&token?)?;
Rust repos service with shipping; pull requests kept in the model386 Ok(GitAccess {
387 remote: info.remote,
388 token: token.plaintext,
389 })
390 }
391
Pull requests from branches392 async fn branches(&self) -> Result<Vec<Branch>> {
393 crate::refs::branches(&self.access(Scope::Read).await?).await
394 }
395
Rust repos service with shipping; pull requests kept in the model396 async fn log(&self, git_ref: &str, limit: u32) -> Result<Vec<Commit>> {
Fast pages, required checks on the branch, self-hosted runners, honest incidents397 // History from a commit never changes, so a log asked for by hash is
398 // kept like an object: walking it is a read per commit.
399 let by_hash = is_commit_hash(git_ref);
400 let key = format!("{git_ref}-{limit}");
401 if by_hash
402 && let Some(bytes) = self.cached("log", &key).await
403 && let Ok(commits) = serde_json::from_slice::<Vec<Commit>>(&bytes)
404 {
405 return Ok(commits);
406 }
Rust repos service with shipping; pull requests kept in the model407 let options = js::to_js(&serde_json::json!({ "ref": git_ref, "limit": limit }))?;
Fast pages, required checks on the branch, self-hosted runners, honest incidents408 let raw: Vec<RawCommit> =
Rust repos service with shipping; pull requests kept in the model409 js::from_js(&js::call(&self.handle, "log", &[options]).await?)?;
Fast pages, required checks on the branch, self-hosted runners, honest incidents410 let commits: Vec<Commit> = raw
Rust repos service with shipping; pull requests kept in the model411 .into_iter()
412 .map(|commit| Commit {
413 hash: commit.hash,
414 tree_hash: commit.tree_hash,
415 message: commit.message,
416 author: commit.author,
417 parents: commit.parents,
RFC 3339 timestamps in identity and repos418 authored_at: rfc3339(commit.authored_at * 1000),
Rust repos service with shipping; pull requests kept in the model419 })
Fast pages, required checks on the branch, self-hosted runners, honest incidents420 .collect();
421 // An unknown hash logs nothing; that is not kept, in case it arrives.
422 if by_hash
423 && !commits.is_empty()
424 && let Ok(bytes) = serde_json::to_vec(&commits)
425 {
426 self.keep("log", &key, bytes).await;
427 }
428 Ok(commits)
Rust repos service with shipping; pull requests kept in the model429 }
430
431 async fn parents(&self, commit_hash: &str) -> Result<Option<Vec<String>>> {
432 let commit: Option<RawCommit> =
433 js::from_js(&js::call(&self.handle, "readCommit", &[commit_hash.into()]).await?)?;
434 Ok(commit.map(|commit| commit.parents))
435 }
436
437 async fn read_tree(&self, tree_hash: &str) -> Result<Option<Vec<TreeEntry>>> {
Polish: phones, copy boxes, the plan page, the landing page, a real glide438 if let Some(bytes) = self.cached("tree", tree_hash).await
439 && let Ok(entries) = serde_json::from_slice::<Vec<TreeEntry>>(&bytes) {
Agents as a team: lifecycle, merge queue, billing and a new shell440 return Ok(Some(entries));
441 }
Rust repos service with shipping; pull requests kept in the model442 let entries: Option<Vec<RawEntry>> =
443 js::from_js(&js::call(&self.handle, "readTree", &[tree_hash.into()]).await?)?;
Agents as a team: lifecycle, merge queue, billing and a new shell444 let entries: Option<Vec<TreeEntry>> = entries.map(|entries| {
Rust repos service with shipping; pull requests kept in the model445 entries
446 .into_iter()
447 .map(|entry| TreeEntry {
448 name: entry.name,
449 hash: entry.hash,
450 kind: entry.kind,
451 })
452 .collect()
Agents as a team: lifecycle, merge queue, billing and a new shell453 });
Polish: phones, copy boxes, the plan page, the landing page, a real glide454 if let Some(entries) = &entries
455 && let Ok(bytes) = serde_json::to_vec(entries) {
Agents as a team: lifecycle, merge queue, billing and a new shell456 self.keep("tree", tree_hash, bytes).await;
457 }
458 Ok(entries)
Rust repos service with shipping; pull requests kept in the model459 }
460
461 async fn read_blob(&self, blob_hash: &str) -> Result<Option<Vec<u8>>> {
Agents as a team: lifecycle, merge queue, billing and a new shell462 if let Some(bytes) = self.cached("blob", blob_hash).await {
463 return Ok(Some(bytes));
464 }
465 let bytes = blob_bytes(js::call(&self.handle, "readBlob", &[blob_hash.into()]).await?).await?;
466 if let Some(bytes) = bytes.as_ref().filter(|bytes| bytes.len() <= MAX_CACHED_BLOB) {
467 self.keep("blob", blob_hash, bytes.clone()).await;
468 }
469 Ok(bytes)
Rust repos service with shipping; pull requests kept in the model470 }
471
472 async fn read_file(&self, git_ref: &str, path: &str) -> Result<Option<Vec<u8>>> {
473 let args = js::to_js(&serde_json::json!({ "ref": git_ref, "path": path }))?;
474 blob_bytes(js::call(&self.handle, "readFile", &[args]).await?).await
475 }
476
477 async fn fork(&self, target_key: &str) -> Result<()> {
478 let options = js::to_js(&serde_json::json!({ "defaultBranchOnly": true }))?;
479 match js::call(&self.handle, "fork", &[target_key.into(), options]).await {
480 Err(thrown) if !thrown.is("ALREADY_EXISTS") => Err(thrown.into()),
481 _ => Ok(()),
482 }
483 }
484}
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms485
486#[cfg(test)]
487mod tests {
488 use super::{Credentials, GitAccess, Scope, SharedCredential, TOKEN_REUSE_MS, shared_credential, shared_key};
489
490 fn access(token: &str) -> GitAccess {
491 GitAccess {
492 remote: "https://store.example/acme--rocket.git".to_owned(),
493 token: token.to_owned(),
494 }
495 }
496
497 #[test]
498 fn a_credential_is_reused_only_while_it_has_time_left() {
499 let mut kept = Credentials::default();
500 kept.keep("acme--rocket", Scope::Read, access("r1"), 1_000);
501 assert_eq!(kept.get("acme--rocket", Scope::Read, 1_000).unwrap().token, "r1");
502 assert_eq!(
503 kept.get("acme--rocket", Scope::Read, 1_000 + TOKEN_REUSE_MS - 1).unwrap().token,
504 "r1"
505 );
506 assert!(kept.get("acme--rocket", Scope::Read, 1_000 + TOKEN_REUSE_MS).is_none());
507 }
508
509 #[test]
510 fn a_credential_is_kept_for_its_own_repository_and_scope() {
511 let mut kept = Credentials::default();
512 kept.keep("acme--rocket", Scope::Read, access("r1"), 1_000);
513 // A read credential never stands in for a write one.
514 assert!(kept.get("acme--rocket", Scope::Write, 1_000).is_none());
515 assert!(kept.get("acme--booster", Scope::Read, 1_000).is_none());
516 kept.keep("acme--rocket", Scope::Write, access("w1"), 1_000);
517 assert_eq!(kept.get("acme--rocket", Scope::Write, 1_000).unwrap().token, "w1");
518 assert_eq!(kept.get("acme--rocket", Scope::Read, 1_000).unwrap().token, "r1");
519 }
520
521 #[test]
522 fn a_shared_credential_is_reused_only_in_its_own_window() {
523 let value = serde_json::to_vec(&SharedCredential {
524 remote: "https://store.example/acme--rocket.git".to_owned(),
525 token: "r1".to_owned(),
526 made: 10_000,
527 })
528 .unwrap();
529 let (access, made) = shared_credential(&value, 10_000 + TOKEN_REUSE_MS - 1).unwrap();
530 assert_eq!(access.token, "r1");
531 // Kept here only for what is left of its window, not a new one.
532 assert_eq!(made, 10_000);
533 assert!(shared_credential(&value, 10_000 + TOKEN_REUSE_MS).is_none());
534 // Anything else is a miss.
535 assert!(shared_credential(b"not json", 10_000).is_none());
536 // Each repository and scope has its own key.
537 assert_eq!(shared_key("acme--rocket", Scope::Read), "cred:acme--rocket:read");
538 assert_ne!(shared_key("acme--rocket", Scope::Read), shared_key("acme--rocket", Scope::Write));
539 }
540
541 #[test]
542 fn a_shared_credential_kept_here_expires_with_the_original() {
543 let mut kept = Credentials::default();
544 // Made at 1_000 elsewhere, found here at 100_000.
545 kept.keep("acme--rocket", Scope::Read, access("r1"), 1_000);
546 assert!(kept.get("acme--rocket", Scope::Read, 100_000).is_some());
547 assert!(kept.get("acme--rocket", Scope::Read, 1_000 + TOKEN_REUSE_MS).is_none());
548 }
549
550 #[test]
551 fn a_turned_down_credential_is_forgotten_and_old_ones_are_dropped() {
552 let mut kept = Credentials::default();
553 kept.keep("acme--rocket", Scope::Read, access("r1"), 1_000);
554 kept.keep("acme--rocket", Scope::Write, access("w1"), 1_000);
555 kept.keep("acme--booster", Scope::Read, access("b1"), 1_000);
556 kept.forget("acme--rocket");
557 assert!(kept.get("acme--rocket", Scope::Read, 1_000).is_none());
558 assert!(kept.get("acme--rocket", Scope::Write, 1_000).is_none());
559 assert!(kept.get("acme--booster", Scope::Read, 1_000).is_some());
560 // Keeping another later drops the expired one from the map.
561 kept.keep("acme--other", Scope::Read, access("o1"), 1_000 + TOKEN_REUSE_MS);
562 assert_eq!(kept.kept.len(), 1);
563 }
564}
Fast pages, required checks on the branch, self-hosted runners, honest incidents565
566#[cfg(test)]
567mod log_cache_tests {
568 use super::is_commit_hash;
569
570 #[test]
571 fn only_full_lowercase_hashes_are_kept() {
572 assert!(is_commit_hash(&"a".repeat(40)));
573 assert!(is_commit_hash(&"0123456789abcdef".repeat(4)));
574 assert!(!is_commit_hash("main"));
575 assert!(!is_commit_hash(&"A".repeat(40)));
576 assert!(!is_commit_hash(&"a".repeat(39)));
577 }
578}