Skip to content
637 linesCodeBlameRaw

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.

Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R21//! The toolkit's credentials: a job's runtime token, the signed tokens of
2//! the blob URLs the toolkit uploads to and downloads from, and the claims
3//! of a job's OIDC token.
4//!
5//! - The runtime token (`ACTIONS_RUNTIME_TOKEN`) is a JSON Web Token, as
6//! the toolkit expects: `@actions/artifact` reads the run and job from
7//! its `scp` claim. It is signed (HS256) with the hash of the job's own
8//! token as the key, so it is checked against the job's row without a
9//! secret of its own, stops working when the job ends, and never lets
10//! its holder read the job's spec or report for it.
11//! - A blob token is a payload and its HMAC under a key derived from
12//! `ACTIONS_KEY`: which entry, which R2 upload (for an upload), and until
13//! when. The API serves `/actions/toolkit/blobs/{token}` with it.
14//! - OIDC claims follow GitHub's, so cloud providers' trust policies read
15//! them the same way. The API adds `iss`, `aud`, `jti` and the times,
16//! and signs.
17
18use base64::Engine;
19use base64::engine::general_purpose::URL_SAFE_NO_PAD;
20use g1t_actions::events::RunInfo;
21use g1t_contracts::actions::{BlobArgs, BlobGrant, BlobPart, BlobSignArgs, RuntimeAuthArgs, RuntimeJob};
22use g1t_contracts::time::rfc3339;
23use g1t_contracts::{FailureCode, Outcome};
24use g1t_kit::now_ms;
25use g1t_secrets::{hmac_sha256_hex, same, sha256_hex};
26use serde::Deserialize;
27use serde_json::{Value, json};
28use worker::Result;
29
30use crate::plan::{JobRow, RunRow};
31use crate::{Actions, check, fail};
32
33/// How long a blob token for an upload is good: as long as an unfinished
34/// upload is kept (cache.rs).
35pub(crate) const UPLOAD_SECONDS: u64 = 6 * 60 * 60;
36/// How long a blob token for a download is good: long enough to start one.
37pub(crate) const DOWNLOAD_SECONDS: u64 = 60 * 60;
38/// How long a download link the API redirects a person to is good.
39pub(crate) const LINK_SECONDS: u64 = 10 * 60;
40
41fn encode(bytes: &[u8]) -> String {
42 URL_SAFE_NO_PAD.encode(bytes)
43}
44
45fn decode_json(part: &str) -> Option<Value> {
46 serde_json::from_slice(&URL_SAFE_NO_PAD.decode(part).ok()?).ok()
47}
48
49/// HMAC-SHA256 of `body` under `key`, base64url.
50fn mac(key: &str, body: &str) -> String {
51 encode(&hex::decode(hmac_sha256_hex(key, body)).unwrap_or_default())
52}
53
54// ── The runtime token ───────────────────────────────────────────────────────
55
56/// The scopes the toolkit reads: `Actions.Results:{run}:{job}` names the
57/// run and job to `@actions/artifact`.
58pub(crate) fn scopes(run: &str, job: &str) -> String {
59 format!("Actions.GenericRead:00000000-0000-0000-0000-000000000000 Actions.UploadArtifacts:{run}:{job} Actions.Results:{run}:{job}")
60}
61
62/// A job's runtime token, good for `ttl` seconds from `now` (seconds).
63/// `token_hash` is the hash of the job's own token, as its row keeps it.
64pub(crate) fn runtime_token(job: &str, run: &str, token_hash: &str, now: u64, ttl: u64) -> String {
65 let header = encode(br#"{"typ":"JWT","alg":"HS256"}"#);
66 let claims = json!({
67 "iss": "g1t",
68 "sub": job,
69 "job": job,
70 "run": run,
71 "scp": scopes(run, job),
72 "iat": now,
73 "nbf": now.saturating_sub(60),
74 "exp": now + ttl,
75 });
76 let claims = encode(claims.to_string().as_bytes());
77 let signed = format!("{header}.{claims}");
78 let signature = mac(token_hash, &signed);
79 format!("{signed}.{signature}")
80}
81
82/// The job a runtime token says it is, unchecked: the API reads it to know
83/// which job to ask about.
84pub fn runtime_job(token: &str) -> Option<String> {
85 let claims = decode_json(token.split('.').nth(1)?)?;
86 claims["job"].as_str().map(str::to_owned)
87}
88
89/// Whether `token` is a good runtime token for `job`, whose own token
90/// hashes to `token_hash`, at `now` (seconds).
91pub(crate) fn runtime_valid(token: &str, job: &str, token_hash: &str, now: u64) -> bool {
92 let mut parts = token.split('.');
93 let (Some(header), Some(claims), Some(signature), None) = (parts.next(), parts.next(), parts.next(), parts.next()) else {
94 return false;
95 };
96 if !same(&mac(token_hash, &format!("{header}.{claims}")), signature) {
97 return false;
98 }
99 let Some(claims) = decode_json(claims) else { return false };
100 claims["job"].as_str() == Some(job) && claims["exp"].as_u64().is_some_and(|exp| exp > now)
101}
102
103/// Whether `token` looks like a runtime token rather than a job's own
104/// (which is hex).
105pub(crate) fn is_runtime(token: &str) -> bool {
106 token.matches('.').count() == 2
107}
108
109// ── Blob tokens ─────────────────────────────────────────────────────────────
110
111#[derive(Debug, PartialEq, serde::Serialize, Deserialize)]
112pub(crate) struct BlobClaims {
113 /// `cache` or `artifact`.
114 pub k: String,
115 /// The entry's id.
116 pub i: String,
117 /// Its object in R2.
118 pub o: String,
119 /// The R2 upload, for an upload.
120 #[serde(default)]
121 pub u: Option<String>,
122 /// Good until, seconds since the epoch.
123 pub e: u64,
124}
125
126/// The key blob tokens are signed with, from the service's own key.
127pub(crate) fn blob_key(actions_key: &str) -> String {
128 hmac_sha256_hex(actions_key, "g1t actions blob tokens v1")
129}
130
131pub(crate) fn sign_blob(key: &str, claims: &BlobClaims) -> String {
132 let payload = encode(serde_json::to_string(claims).unwrap_or_default().as_bytes());
133 let signature = mac(key, &payload);
134 format!("{payload}.{signature}")
135}
136
137/// What a blob token grants, if it is signed with `key` and good at `now`.
138pub(crate) fn open_blob(key: &str, token: &str, now: u64) -> Option<BlobClaims> {
139 let (payload, signature) = token.split_once('.')?;
140 if !same(&mac(key, payload), signature) {
141 return None;
142 }
143 let claims: BlobClaims = serde_json::from_value(decode_json(payload)?).ok()?;
144 (claims.e > now).then_some(claims)
145}
146
147// ── OIDC ────────────────────────────────────────────────────────────────────
148
149/// Whether a job may have an OIDC token: when its permissions, or its
150/// workflow's when it has none of its own, give `id-token: write`
151/// (`write-all` included). Nothing given gives no OIDC token, as GitHub's
152/// default token permissions do not include it.
153///
Merge branch 'worktree-agent-a3abfcce648e87dca'154/// Read with the same model as the job's token (`g1t_actions::permissions`).
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2155pub(crate) fn id_token_permitted(workflow: &Value, job: &Value) -> bool {
Merge branch 'worktree-agent-a3abfcce648e87dca'156 use g1t_actions::permissions::{self, Access};
157 // A job's own `permissions:` replace the workflow's; without either,
158 // no default gives `id-token`, as on GitHub.
159 let written = |spec: &Value| spec.get("permissions").and_then(|value| permissions::parse(value).ok()).map(|(read, _)| read);
160 written(job).or_else(|| written(workflow)).is_some_and(|granted| granted.get("id-token") == Access::Write)
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2161}
162
163/// A job's `environment:` name, when it names one plainly.
164pub(crate) fn environment_name(job: &Value) -> Option<String> {
165 match job.get("environment") {
166 Some(Value::String(name)) if !name.contains("${{") && !name.trim().is_empty() => Some(name.trim().to_owned()),
167 Some(Value::Object(env)) => env.get("name").and_then(Value::as_str).filter(|n| !n.contains("${{") && !n.trim().is_empty()).map(|n| n.trim().to_owned()),
168 _ => None,
169 }
170}
171
172/// GitHub's subject: by environment, else for a pull request, else by ref.
173pub fn subject(repository: &str, environment: Option<&str>, event: &str, git_ref: &str) -> String {
174 if let Some(environment) = environment {
175 return format!("repo:{repository}:environment:{environment}");
176 }
177 if event == "pull_request" || event == "pull_request_target" {
178 return format!("repo:{repository}:pull_request");
179 }
180 format!("repo:{repository}:ref:{git_ref}")
181}
182
183/// What an OIDC token says about a job, besides who issued it, for whom
184/// and when.
185pub(crate) struct ClaimFacts<'a> {
186 pub info: &'a RunInfo,
187 pub environment: Option<&'a str>,
188 /// The workflow that defines the job: a called workflow's own path.
189 pub job_workflow_path: &'a str,
190 pub private: bool,
191 pub owner_id: &'a str,
192 pub self_hosted: bool,
193}
194
195pub(crate) fn claims(facts: &ClaimFacts) -> Value {
196 let info = facts.info;
197 let owner = info.repository.split('/').next().unwrap_or_default();
198 let workflow_ref = format!("{}/{}@{}", info.repository, info.workflow_path, info.git_ref);
199 let job_workflow_ref = format!("{}/{}@{}", info.repository, facts.job_workflow_path, info.git_ref);
200 let mut claims = json!({
201 "sub": subject(&info.repository, facts.environment, &info.event_name, &info.git_ref),
202 "ref": info.git_ref,
203 "sha": info.sha,
204 "repository": info.repository,
205 "repository_owner": owner,
206 "repository_owner_id": facts.owner_id,
207 "repository_id": info.repository_id,
208 "repository_visibility": if facts.private { "private" } else { "public" },
209 "run_id": info.run_id,
210 "run_number": info.run_number.to_string(),
211 "run_attempt": info.run_attempt.to_string(),
212 "actor": info.actor,
213 "actor_id": info.actor_id,
214 "workflow": info.workflow,
215 "workflow_ref": workflow_ref,
216 "workflow_sha": info.sha,
217 "job_workflow_ref": job_workflow_ref,
218 "job_workflow_sha": info.sha,
219 "head_ref": info.head_ref.clone().unwrap_or_default(),
220 "base_ref": info.base_ref.clone().unwrap_or_default(),
221 "event_name": info.event_name,
222 "ref_type": info.ref_type(),
223 "ref_protected": (info.ref_name() == info.default_branch).to_string(),
224 "runner_environment": if facts.self_hosted { "self-hosted" } else { "github-hosted" },
225 });
226 if let Some(environment) = facts.environment {
227 claims["environment"] = json!(environment);
228 }
229 claims
230}
231
232impl Actions {
233 /// The running job a runtime token is for.
234 pub(crate) async fn job_for_runtime(&self, job: &str, token: &str) -> Result<Outcome<JobRow>> {
235 let row = self.db.prepare("SELECT * FROM jobs WHERE id = ?").bind(&[job.into()])?.first::<JobRow>(None).await?;
236 Ok(match row {
237 Some(row)
238 if row.status == "in_progress"
239 && row.token_hash.as_deref().is_some_and(|hash| runtime_valid(token, job, hash, now_ms() / 1000)) =>
240 {
241 Outcome::Ok(row)
242 }
243 _ => fail(FailureCode::Unauthenticated, "That job is not running, or the token is not its."),
244 })
245 }
246
247 /// The running job a sandbox's credential is for: its job's own token,
248 /// or its runtime token. Only for the cache and artifacts: a runtime
249 /// token never reads a job's spec or reports for it.
250 pub(crate) async fn job_for_credential(&self, job: &str, token: &str) -> Result<Outcome<JobRow>> {
251 if is_runtime(token) {
252 return self.job_for_runtime(job, token).await;
253 }
254 let row = self.db.prepare("SELECT * FROM jobs WHERE id = ?").bind(&[job.into()])?.first::<JobRow>(None).await?;
255 Ok(match row {
256 Some(row) if row.status == "in_progress" && row.token_hash.as_deref().is_some_and(|hash| same(hash, &sha256_hex(token))) => Outcome::Ok(row),
257 _ => fail(FailureCode::Unauthenticated, "That job is not running, or the token is not its."),
258 })
259 }
260
261 /// `runtime_auth`.
262 pub async fn runtime_auth(&self, a: RuntimeAuthArgs) -> Result<Outcome<RuntimeJob>> {
263 let job = check!(self.job_for_runtime(&a.job, &a.token).await?);
264 let Some(run) = self.run_row(&job.run_id).await? else {
265 return Ok(fail(FailureCode::NotFound, "No such run."));
266 };
267 Ok(Outcome::Ok(RuntimeJob { job: job.id, run: job.run_id, repo_id: job.repo_id, namespace: job.namespace, repository: run.repo }))
268 }
269
270 /// `oidc_claims`.
271 pub async fn oidc_claims(&self, a: RuntimeAuthArgs) -> Result<Outcome<Value>> {
272 let job = check!(self.job_for_runtime(&a.job, &a.token).await?);
273 let Some(run) = self.run_row(&job.run_id).await? else {
274 return Ok(fail(FailureCode::NotFound, "No such run."));
275 };
276 let Some(permitted) = self.oidc_permitted(&run, &job) else {
277 return Ok(fail(FailureCode::Forbidden, "The workflow no longer reads."));
278 };
279 if !permitted.0 {
280 return Ok(fail(
281 FailureCode::Forbidden,
282 "This job has no OIDC token: give it `permissions: id-token: write` (a run of a pull request from outside the repository never has one).",
283 ));
284 }
285 let (repo, _) = match self.repo_by_id(&run.repo_id).await? {
286 Some(found) => found,
287 None => return Ok(fail(FailureCode::NotFound, "There is no such repository.")),
288 };
289 let info = run.info();
290 let facts = ClaimFacts {
291 info: &info,
292 environment: permitted.1.as_deref(),
293 job_workflow_path: &permitted.2,
294 private: repo.is_private,
295 owner_id: &repo.owner_id,
296 self_hosted: job.runner_id.is_some(),
297 };
298 Ok(Outcome::Ok(claims(&facts)))
299 }
300
301 /// Whether a job may have an OIDC token, its environment, and the path
302 /// of the workflow that defines it. A called workflow's job needs both
303 /// its own permissions and its caller's to allow it, as on GitHub. A
304 /// run that is not trusted (a pull request from outside) never may.
305 /// `None` when the workflow no longer reads.
306 fn oidc_permitted(&self, run: &RunRow, job: &JobRow) -> Option<(bool, Option<String>, String)> {
307 let caller = g1t_actions::workflow::parse(&run.source).ok()?;
308 let trusted = run.trusted != 0;
309 match job.callee() {
310 Some((called, spec, call)) => {
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.311 let parent = crate::plan::top_key(call["parent"].as_str().unwrap_or_default());
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2312 let caller_allows = caller.jobs.iter().find(|j| j.id == parent).is_none_or(|j| id_token_permitted(&caller.raw, &j.raw));
313 let path = call["path"].as_str().unwrap_or(&run.path).to_owned();
Merge branch 'worktree-agent-a3abfcce648e87dca'314 let environment = job.environment.clone().or_else(|| environment_name(&spec.raw));
315 Some((trusted && caller_allows && id_token_permitted(&called.raw, &spec.raw), environment, path))
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2316 }
317 None => {
318 let spec = caller.jobs.iter().find(|j| j.id == job.key)?;
Merge branch 'worktree-agent-a3abfcce648e87dca'319 // As read when its needs were done, an expression's included.
320 let environment = job.environment.clone().or_else(|| environment_name(&spec.raw));
321 Some((trusted && id_token_permitted(&caller.raw, &spec.raw), environment, run.path.clone()))
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2322 }
323 }
324 }
325
326 /// Whether a job may have an OIDC token, for its spec.
327 pub(crate) fn oidc_allowed(&self, run: &RunRow, job: &JobRow) -> bool {
328 self.oidc_permitted(run, job).is_some_and(|p| p.0)
329 }
330
331 fn blob_signing_key(&self) -> Option<String> {
332 self.blob_key.clone()
333 }
334
335 /// `blob_sign`.
336 pub async fn blob_sign(&self, a: BlobSignArgs) -> Result<Outcome<String>> {
337 let job = check!(self.job_for_credential(&a.job, &a.token).await?);
338 let Some(key) = self.blob_signing_key() else {
339 return Ok(fail(FailureCode::Invalid, "The toolkit's storage is not set up here: the actions service has no ACTIONS_KEY."));
340 };
341 let object = match a.kind.as_str() {
342 "cache" => {
343 #[derive(Deserialize)]
344 struct Row {
345 object: String,
346 }
347 let row = self
348 .db
349 .prepare("UPDATE cache_entries SET upload = ? WHERE id = ? AND repo_id = ? AND status = 'pending' RETURNING object")
350 .bind(&[a.upload.as_str().into(), a.id.as_str().into(), job.repo_id.as_str().into()])?
351 .first::<Row>(None)
352 .await?;
353 row.map(|r| r.object)
354 }
355 "artifact" => {
356 #[derive(Deserialize)]
357 struct Row {
358 object: String,
359 }
360 let id: f64 = a.id.parse().unwrap_or(-1.0);
361 self.db
362 .prepare("SELECT object FROM artifacts WHERE id = ? AND run_id = ? AND status = 'pending'")
363 .bind(&[id.into(), job.run_id.as_str().into()])?
364 .first::<Row>(None)
365 .await?
366 .map(|r| r.object)
367 }
368 _ => None,
369 };
370 let Some(object) = object else {
371 return Ok(fail(FailureCode::NotFound, "No upload of that is in progress."));
372 };
373 let claims = BlobClaims { k: a.kind, i: a.id, o: object, u: Some(a.upload), e: now_ms() / 1000 + UPLOAD_SECONDS };
374 Ok(Outcome::Ok(sign_blob(&key, &claims)))
375 }
376
377 /// An upload token for an entry whose R2 upload has been started.
378 pub(crate) fn upload_token(&self, kind: &str, id: &str, object: &str, upload: &str) -> Option<String> {
379 let key = self.blob_signing_key()?;
380 let claims = BlobClaims { k: kind.to_owned(), i: id.to_owned(), o: object.to_owned(), u: Some(upload.to_owned()), e: now_ms() / 1000 + UPLOAD_SECONDS };
381 Some(sign_blob(&key, &claims))
382 }
383
384 /// A download token for an entry, good for `seconds`.
385 pub(crate) fn download_token(&self, kind: &str, id: &str, object: &str, seconds: u64) -> Option<String> {
386 let key = self.blob_signing_key()?;
387 Some(sign_blob(&key, &BlobClaims { k: kind.to_owned(), i: id.to_owned(), o: object.to_owned(), u: None, e: now_ms() / 1000 + seconds }))
388 }
389
390 fn opened(&self, token: &str) -> Option<BlobClaims> {
391 open_blob(&self.blob_signing_key()?, token, now_ms() / 1000)
392 }
393
394 /// `blob_open`.
395 pub async fn blob_open(&self, a: BlobArgs) -> Result<Outcome<BlobGrant>> {
396 let Some(claims) = self.opened(&a.blob) else {
397 return Ok(fail(FailureCode::Unauthenticated, "That link has expired or is not one of g1t's."));
398 };
399 // A download needs its entry still there; an upload, still pending.
400 let wanted = if claims.u.is_some() { "pending" } else { "ready" };
401 let (exists, filename, content_type) = match claims.k.as_str() {
402 "cache" => {
403 let row = self
404 .db
405 .prepare("SELECT id FROM cache_entries WHERE id = ? AND object = ? AND status = ?")
406 .bind(&[claims.i.as_str().into(), claims.o.as_str().into(), wanted.into()])?
407 .first::<Value>(None)
408 .await?;
409 (row.is_some(), None, Some("application/octet-stream".to_owned()))
410 }
411 _ => {
412 #[derive(Deserialize)]
413 struct Row {
414 name: String,
415 format: String,
416 }
417 let id: f64 = claims.i.parse().unwrap_or(-1.0);
418 let row = self
419 .db
420 .prepare("SELECT name, format FROM artifacts WHERE id = ? AND object = ? AND status = ?")
421 .bind(&[id.into(), claims.o.as_str().into(), wanted.into()])?
422 .first::<Row>(None)
423 .await?;
424 match row {
425 Some(row) => {
426 let (extension, kind) = if row.format == "tgz" { ("tar.gz", "application/gzip") } else { ("zip", "application/zip") };
427 (true, Some(format!("{}.{extension}", row.name)), Some(kind.to_owned()))
428 }
429 None => (false, None, None),
430 }
431 }
432 };
433 if !exists {
434 return Ok(fail(FailureCode::NotFound, "That is gone: it expired, was deleted, or its upload finished."));
435 }
436 Ok(Outcome::Ok(BlobGrant { kind: claims.k, id: claims.i, object: claims.o, upload: claims.u, filename, content_type }))
437 }
438
439 /// `blob_part`.
440 pub async fn blob_part(&self, a: BlobArgs) -> Result<Outcome<bool>> {
441 let Some(BlobClaims { u: Some(upload), .. }) = self.opened(&a.blob) else {
442 return Ok(fail(FailureCode::Unauthenticated, "That link has expired or is not an upload."));
443 };
444 self.db
445 .prepare(
446 "INSERT INTO blob_parts (upload, part, etag, size, created_at) VALUES (?1, ?2, ?3, ?4, ?5)
447 ON CONFLICT (upload, part) DO UPDATE SET etag = ?3, size = ?4, created_at = ?5",
448 )
449 .bind(&[upload.as_str().into(), f64::from(a.part).into(), a.etag.as_str().into(), (a.size as f64).into(), rfc3339(now_ms()).into()])?
450 .run()
451 .await?;
452 Ok(Outcome::Ok(true))
453 }
454
455 /// `blob_parts`.
456 pub async fn blob_parts(&self, a: BlobArgs) -> Result<Outcome<Vec<BlobPart>>> {
457 let Some(BlobClaims { u: Some(upload), .. }) = self.opened(&a.blob) else {
458 return Ok(fail(FailureCode::Unauthenticated, "That link has expired or is not an upload."));
459 };
460 #[derive(Deserialize)]
461 struct Row {
462 part: f64,
463 etag: String,
464 size: f64,
465 }
466 let rows = self
467 .db
468 .prepare("SELECT part, etag, size FROM blob_parts WHERE upload = ? ORDER BY part")
469 .bind(&[upload.as_str().into()])?
470 .all()
471 .await?
472 .results::<Row>()?;
473 Ok(Outcome::Ok(rows.into_iter().map(|r| BlobPart { part: r.part as u32, etag: r.etag, size: r.size as u64 }).collect()))
474 }
475
476 /// `blob_done`: the upload is complete at `size` bytes, as R2 measured
477 /// it, which is the size its entry is committed at.
478 pub async fn blob_done(&self, a: BlobArgs) -> Result<Outcome<bool>> {
479 let Some(BlobClaims { k, i, u: Some(upload), .. }) = self.opened(&a.blob) else {
480 return Ok(Outcome::Ok(false));
481 };
482 self.db.prepare("DELETE FROM blob_parts WHERE upload = ?").bind(&[upload.as_str().into()])?.run().await?;
483 let size = (a.size as f64).into();
484 if k == "cache" {
485 self.db
486 .prepare("UPDATE cache_entries SET size = ? WHERE id = ? AND status = 'pending'")
487 .bind(&[size, i.as_str().into()])?
488 .run()
489 .await?;
490 } else {
491 self.db
492 .prepare("UPDATE artifacts SET size = ? WHERE id = ? AND status = 'pending'")
493 .bind(&[size, i.parse::<f64>().unwrap_or(-1.0).into()])?
494 .run()
495 .await?;
496 }
497 Ok(Outcome::Ok(true))
498 }
499
500 /// Hourly: parts of uploads abandoned long ago.
501 pub(crate) async fn sweep_blob_parts(&self, now: u64) -> Result<()> {
502 self.db
503 .prepare("DELETE FROM blob_parts WHERE created_at < ?")
504 .bind(&[rfc3339(now.saturating_sub(UPLOAD_SECONDS * 1000 * 2)).into()])?
505 .run()
506 .await?;
507 Ok(())
508 }
509}
510
511#[cfg(test)]
512mod tests {
513 use super::*;
514
515 const HASH: &str = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08";
516
517 #[test]
518 fn a_runtime_token_is_a_jwt_the_toolkit_reads() {
519 let token = runtime_token("job_1", "run_1", HASH, 1_700_000_000, 3600);
520 let parts: Vec<&str> = token.split('.').collect();
521 assert_eq!(parts.len(), 3);
522 let header = decode_json(parts[0]).unwrap();
523 assert_eq!(header["alg"], "HS256");
524 let claims = decode_json(parts[1]).unwrap();
525 // @actions/artifact finds the run and job in the `Actions.Results`
526 // scope: three parts split on `:`.
527 let results = claims["scp"].as_str().unwrap().split(' ').find(|s| s.starts_with("Actions.Results:")).unwrap();
528 assert_eq!(results.split(':').collect::<Vec<_>>(), ["Actions.Results", "run_1", "job_1"]);
529 assert_eq!(runtime_job(&token).as_deref(), Some("job_1"));
530 assert!(is_runtime(&token) && !is_runtime(HASH));
531 }
532
533 #[test]
534 fn a_runtime_token_is_checked_against_its_job() {
535 let token = runtime_token("job_1", "run_1", HASH, 1_700_000_000, 3600);
536 assert!(runtime_valid(&token, "job_1", HASH, 1_700_000_100));
537 // Another job, another job's key, too late.
538 assert!(!runtime_valid(&token, "job_2", HASH, 1_700_000_100));
539 assert!(!runtime_valid(&token, "job_1", &"0".repeat(64), 1_700_000_100));
540 assert!(!runtime_valid(&token, "job_1", HASH, 1_700_003_601));
541 // Claims changed without the key.
542 let parts: Vec<&str> = token.split('.').collect();
543 let forged = encode(json!({ "job": "job_1", "run": "run_9", "exp": 9_999_999_999u64 }).to_string().as_bytes());
544 assert!(!runtime_valid(&format!("{}.{forged}.{}", parts[0], parts[2]), "job_1", HASH, 1_700_000_100));
545 assert!(!runtime_valid("a.b", "job_1", HASH, 0));
546 }
547
548 #[test]
549 fn blob_tokens_are_signed_and_expire() {
550 let key = blob_key("00".repeat(32).as_str());
551 let claims = BlobClaims { k: "artifact".into(), i: "12".into(), o: "a/repo_1/12".into(), u: Some("up".into()), e: 1_000 };
552 let token = sign_blob(&key, &claims);
553 assert_eq!(open_blob(&key, &token, 999), Some(claims));
554 assert_eq!(open_blob(&key, &token, 1_000), None);
555 assert_eq!(open_blob(&blob_key("11".repeat(32).as_str()), &token, 999), None);
556 let (payload, _) = token.split_once('.').unwrap();
557 assert_eq!(open_blob(&key, &format!("{payload}.AAAA"), 999), None);
558 }
559
560 #[test]
561 fn id_token_needs_write_in_the_job_or_else_the_workflow() {
562 let wf = |p: Value| json!({ "permissions": p });
563 let none = json!({});
564 assert!(!id_token_permitted(&none, &none));
565 assert!(id_token_permitted(&wf(json!({ "id-token": "write", "contents": "read" })), &none));
566 assert!(!id_token_permitted(&wf(json!({ "id-token": "read" })), &none));
567 assert!(id_token_permitted(&wf(json!("write-all")), &none));
568 assert!(!id_token_permitted(&wf(json!("read-all")), &none));
569 // The job's own permissions replace the workflow's entirely.
570 assert!(!id_token_permitted(&wf(json!({ "id-token": "write" })), &json!({ "permissions": { "contents": "read" } })));
571 assert!(id_token_permitted(&wf(json!({})), &json!({ "permissions": { "id-token": "write" } })));
572 assert!(!id_token_permitted(&none, &json!({ "permissions": {} })));
573 }
574
575 #[test]
576 fn subjects_are_github_s() {
577 assert_eq!(subject("acme/web", None, "push", "refs/heads/main"), "repo:acme/web:ref:refs/heads/main");
578 assert_eq!(subject("acme/web", None, "push", "refs/tags/v1"), "repo:acme/web:ref:refs/tags/v1");
579 assert_eq!(subject("acme/web", Some("prod"), "push", "refs/heads/main"), "repo:acme/web:environment:prod");
580 assert_eq!(subject("acme/web", None, "pull_request", "refs/pull/3/merge"), "repo:acme/web:pull_request");
581 assert_eq!(subject("acme/web", None, "pull_request_target", "refs/heads/main"), "repo:acme/web:pull_request");
582 // An environment wins over a pull request, as on GitHub.
583 assert_eq!(subject("acme/web", Some("preview"), "pull_request", "refs/pull/3/merge"), "repo:acme/web:environment:preview");
584 }
585
586 #[test]
587 fn environments_are_named_plainly_or_not_at_all() {
588 assert_eq!(environment_name(&json!({ "environment": "production" })).as_deref(), Some("production"));
589 assert_eq!(environment_name(&json!({ "environment": { "name": "staging", "url": "x" } })).as_deref(), Some("staging"));
590 assert_eq!(environment_name(&json!({ "environment": "${{ inputs.env }}" })), None);
591 assert_eq!(environment_name(&json!({})), None);
592 }
593
594 #[test]
595 fn claims_carry_what_trust_policies_read() {
596 let info = RunInfo {
597 repository: "acme/web".into(),
598 repository_id: "repo_1".into(),
599 default_branch: "main".into(),
600 event_name: "push".into(),
601 git_ref: "refs/heads/main".into(),
602 sha: "abc".into(),
603 actor: "ada".into(),
604 actor_id: "usr_1".into(),
605 run_id: "run_1".into(),
606 run_number: 7,
607 run_attempt: 2,
608 workflow: "Deploy".into(),
609 workflow_path: ".g1t/workflows/deploy.yml".into(),
610 ..RunInfo::default()
611 };
612 let facts = ClaimFacts {
613 info: &info,
614 environment: Some("production"),
615 job_workflow_path: ".g1t/workflows/release.yml",
616 private: true,
617 owner_id: "ws_1",
618 self_hosted: false,
619 };
620 let c = claims(&facts);
621 assert_eq!(c["sub"], "repo:acme/web:environment:production");
622 assert_eq!(c["environment"], "production");
623 assert_eq!(c["repository_owner"], "acme");
624 assert_eq!(c["repository_owner_id"], "ws_1");
625 assert_eq!(c["repository_visibility"], "private");
626 assert_eq!(c["run_number"], "7");
627 assert_eq!(c["run_attempt"], "2");
628 assert_eq!(c["workflow_ref"], "acme/web/.g1t/workflows/deploy.yml@refs/heads/main");
629 assert_eq!(c["job_workflow_ref"], "acme/web/.g1t/workflows/release.yml@refs/heads/main");
630 assert_eq!(c["ref_type"], "branch");
631 assert_eq!(c["ref_protected"], "true");
632 assert_eq!(c["runner_environment"], "github-hosted");
633 for absent in ["iss", "aud", "exp", "iat", "jti"] {
634 assert!(c.get(absent).is_none(), "{absent} is the API's to add");
635 }
636 }
637}

This file's history is long; its oldest lines are credited to the oldest commit read.