1use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
2use eyre::{Result, bail, eyre};
3use log::warn;
4use reqwest::StatusCode;
5use reqwest::header::{
6 ACCEPT, AUTHORIZATION, CONTENT_LENGTH, CONTENT_TYPE, ETAG, HeaderValue, IF_MATCH, IF_NONE_MATCH,
7};
8use serde::{Deserialize, Serialize};
9use sha2::Digest as _;
10use std::fs::{self, File};
11use std::io::Read;
12use std::path::{Path, PathBuf};
13use std::sync::Arc;
14use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
15use tokio::io::AsyncWriteExt;
16use url::{Host, Url};
17
18mod agent;
19mod local;
20
21pub use agent::{
22 AGENT_PROTOCOL_VERSION, ActionPrediction, AgentRemoteCache, AgentRequest, AgentResponse,
23 AgentStats, CacheAgent,
24};
25pub use local::{LocalActionCache, LocalCas};
26
27pub const PROTOCOL_VERSION: u8 = 1;
28const PROTOCOL_HEADER: &str = "mise-cache-protocol";
29const NAMESPACE_HEADER: &str = "mise-cache-namespace";
30pub const ACTION_RESULT_MEDIA_TYPE: &str = "application/vnd.mise.cache-action-result.v1+json";
31pub const DIRECTORY_MEDIA_TYPE: &str = "application/vnd.mise.cache-directory.v1+json";
32pub const CLIENT_METADATA_MEDIA_TYPE: &str = "application/vnd.mise.cache-client-metadata.v1+json";
33pub const TASK_ACTION_MANIFEST_MEDIA_TYPE: &str =
34 "application/vnd.mise.cache-task-action-manifest.v1+json";
35pub const BLOB_MEDIA_TYPE: &str = "application/octet-stream";
36
37pub fn canonical_json(value: &impl Serialize) -> Result<Vec<u8>> {
42 Ok(serde_json_canonicalizer::to_vec(value)?)
43}
44
45#[derive(
46 Debug,
47 Clone,
48 Copy,
49 Serialize,
50 Deserialize,
51 Default,
52 strum::EnumString,
53 strum::Display,
54 PartialEq,
55 Eq,
56)]
57#[serde(rename_all = "kebab-case")]
58#[strum(serialize_all = "kebab-case")]
59pub enum RemoteCacheMode {
60 #[default]
61 ReadWrite,
62 ReadOnly,
63 WriteOnly,
64}
65
66impl RemoteCacheMode {
67 pub fn reads(self) -> bool {
68 matches!(self, Self::ReadWrite | Self::ReadOnly)
69 }
70
71 pub fn writes(self) -> bool {
72 matches!(self, Self::ReadWrite | Self::WriteOnly)
73 }
74}
75
76pub struct RemoteCacheConfig {
77 pub base_url: Url,
78 pub namespace: String,
79 pub token: Option<String>,
80 pub token_file: Option<PathBuf>,
81 pub oidc_audience: Option<String>,
82 pub connect_timeout: Duration,
83 pub read_timeout: Duration,
84 pub download_timeout: Duration,
85 pub retries: i64,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
89pub struct CacheDigest {
90 pub algorithm: String,
91 pub hash: String,
92 pub size: u64,
93}
94
95impl CacheDigest {
96 pub fn blake3(bytes: &[u8]) -> Self {
97 Self {
98 algorithm: "blake3".into(),
99 hash: blake3::hash(bytes).to_hex().to_string(),
100 size: bytes.len() as u64,
101 }
102 }
103
104 pub fn blake3_file(path: &Path) -> Result<Self> {
106 let (hash, size) = hash_file_blake3(path)?;
107 Ok(Self {
108 algorithm: "blake3".into(),
109 hash,
110 size,
111 })
112 }
113
114 pub fn validate(&self) -> Result<()> {
115 if self.algorithm != "blake3" && self.algorithm != "sha256" {
116 bail!("unsupported remote cache digest algorithm");
117 }
118 if self.hash.len() != 64
119 || !self
120 .hash
121 .bytes()
122 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
123 {
124 bail!("invalid remote cache digest");
125 }
126 Ok(())
127 }
128
129 pub fn matches_bytes(&self, bytes: &[u8]) -> Result<bool> {
130 self.validate()?;
131 if self.size != bytes.len() as u64 {
132 return Ok(false);
133 }
134 let hash = match self.algorithm.as_str() {
135 "blake3" => blake3::hash(bytes).to_hex().to_string(),
136 "sha256" => hex::encode(sha2::Sha256::digest(bytes)),
137 _ => unreachable!("digest algorithm was validated"),
138 };
139 Ok(self.hash == hash)
140 }
141
142 pub fn matches_file(&self, path: &Path) -> Result<bool> {
143 self.validate()?;
144 let (hash, size) = match self.algorithm.as_str() {
145 "blake3" => hash_file_blake3(path)?,
146 "sha256" => hash_file_sha256(path)?,
147 _ => unreachable!("digest algorithm was validated"),
148 };
149 Ok(self.size == size && self.hash == hash)
150 }
151}
152
153fn hash_file_blake3(path: &Path) -> Result<(String, u64)> {
154 let mut file = File::open(path)?;
155 let mut hasher = blake3::Hasher::new();
156 let mut buffer = [0; 64 * 1024];
157 let mut size = 0;
158 loop {
159 let count = file.read(&mut buffer)?;
160 if count == 0 {
161 break;
162 }
163 hasher.update(&buffer[..count]);
164 size += count as u64;
165 }
166 Ok((hasher.finalize().to_hex().to_string(), size))
167}
168
169fn hash_file_sha256(path: &Path) -> Result<(String, u64)> {
170 let mut file = File::open(path)?;
171 let mut hasher = sha2::Sha256::new();
172 let mut buffer = [0; 64 * 1024];
173 let mut size = 0;
174 loop {
175 let count = file.read(&mut buffer)?;
176 if count == 0 {
177 break;
178 }
179 hasher.update(&buffer[..count]);
180 size += count as u64;
181 }
182 Ok((hex::encode(hasher.finalize()), size))
183}
184
185#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
187#[serde(deny_unknown_fields)]
188pub struct RemoteActionResult {
189 pub action: CacheDigest,
190 #[serde(default)]
191 pub metadata: Option<CacheDigest>,
192 #[serde(default)]
193 pub output_root: Option<CacheDigest>,
194 pub version: u8,
195}
196
197#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
199#[serde(deny_unknown_fields)]
200pub struct CacheDirectory {
201 pub directories: Vec<CacheDirectoryNode>,
202 pub files: Vec<CacheFileNode>,
203 pub symlinks: Vec<CacheSymlinkNode>,
204 pub version: u8,
205}
206
207#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
209#[serde(deny_unknown_fields)]
210pub struct CacheDirectoryNode {
211 pub digest: CacheDigest,
212 pub mode: u32,
213 pub name: String,
214}
215
216#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
218#[serde(deny_unknown_fields)]
219pub struct CacheFileNode {
220 pub digest: CacheDigest,
221 pub executable: bool,
222 pub mode: u32,
223 pub name: String,
224}
225
226#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
228#[serde(deny_unknown_fields)]
229pub struct CacheSymlinkNode {
230 pub mode: u32,
231 pub name: String,
232 pub target: String,
233}
234
235#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
237#[serde(deny_unknown_fields)]
238pub struct RustcMetadata {
239 pub version: u8,
240 pub kind: String,
241 pub stdout: CacheDigest,
242 pub stderr: CacheDigest,
243}
244
245pub enum BlobSource {
246 Bytes(Vec<u8>),
247 File(tempfile::NamedTempFile),
248 Path(PathBuf),
249}
250
251pub struct BlobUpload {
252 pub digest: CacheDigest,
253 pub source: BlobSource,
254}
255
256pub struct RemoteActionManifest {
257 pub bytes: Vec<u8>,
258 pub etag: String,
259}
260
261#[derive(Debug, Clone, Copy, PartialEq, Eq)]
262pub enum ManifestPutOutcome {
263 Stored,
264 PreconditionFailed,
265}
266
267pub struct RemoteCacheClient {
268 base_url: Url,
269 namespace: String,
270 client: reqwest::Client,
271 credential: RemoteCacheCredential,
272 download_timeout: Duration,
273 retries: i64,
274}
275
276impl RemoteCacheClient {
277 pub fn new(config: RemoteCacheConfig) -> Result<Self> {
278 let authenticated = config
279 .token
280 .as_deref()
281 .is_some_and(|token| !token.trim().is_empty())
282 || config.token_file.is_some()
283 || config
284 .oidc_audience
285 .as_deref()
286 .is_some_and(|audience| !audience.trim().is_empty());
287 validate_remote_url(&config.base_url, authenticated)?;
288 let client = reqwest::Client::builder()
289 .connect_timeout(config.connect_timeout)
290 .read_timeout(config.read_timeout)
291 .redirect(reqwest::redirect::Policy::none())
292 .build()?;
293 let credential = remote_credential(&config, client.clone())?;
294 Ok(Self {
295 base_url: normalized_base_url(config.base_url),
296 namespace: config.namespace,
297 client,
298 credential,
299 download_timeout: config.download_timeout,
300 retries: config.retries,
301 })
302 }
303
304 fn action_result_endpoint(&self, action: &CacheDigest) -> Result<Url> {
305 action.validate()?;
306 if action.algorithm != "blake3" {
307 bail!("remote cache action keys must use blake3");
308 }
309 Ok(self.base_url.join(&format!(
310 "v{PROTOCOL_VERSION}/action-results/{}/{}/{}",
311 action.algorithm, action.hash, action.size
312 ))?)
313 }
314
315 fn blob_endpoint(&self, digest: &CacheDigest) -> Result<Url> {
316 digest.validate()?;
317 Ok(self.base_url.join(&format!(
318 "v{PROTOCOL_VERSION}/blobs/{}/{}/{}",
319 digest.algorithm, digest.hash, digest.size
320 ))?)
321 }
322
323 fn action_manifest_endpoint(&self, key: &CacheDigest) -> Result<Url> {
324 key.validate()?;
325 if key.algorithm != "blake3" {
326 bail!("remote action manifest keys must use blake3");
327 }
328 Ok(self.base_url.join(&format!(
329 "v{PROTOCOL_VERSION}/action-manifests/{}/{}/{}",
330 key.algorithm, key.hash, key.size
331 ))?)
332 }
333
334 async fn request(
335 &self,
336 method: reqwest::Method,
337 url: Url,
338 media_type: &'static str,
339 ) -> Result<reqwest::RequestBuilder> {
340 let request = self
341 .client
342 .request(method, url)
343 .header(PROTOCOL_HEADER, u16::from(PROTOCOL_VERSION))
344 .header(NAMESPACE_HEADER, &self.namespace)
345 .header(ACCEPT, media_type);
346 if let Some(authorization) = self.credential.authorization().await? {
347 Ok(request.header(AUTHORIZATION, authorization))
348 } else {
349 Ok(request)
350 }
351 }
352
353 pub async fn get_action_result(
354 &self,
355 action: &CacheDigest,
356 ) -> Result<Option<RemoteActionResult>> {
357 let url = self.action_result_endpoint(action)?;
358 let result = retry_async("GET", &url, self.retries, || async {
359 let response = self
360 .request(reqwest::Method::GET, url.clone(), ACTION_RESULT_MEDIA_TYPE)
361 .await?
362 .send()
363 .await?;
364 if response.status() == StatusCode::NOT_FOUND {
365 return Ok(None);
366 }
367 Ok(Some(
368 response
369 .error_for_status()?
370 .json::<RemoteActionResult>()
371 .await?,
372 ))
373 })
374 .await?;
375 if let Some(result) = &result
376 && (result.version != 1 || result.action != *action)
377 {
378 bail!("remote action result does not match requested action");
379 }
380 Ok(result)
381 }
382
383 pub async fn put_action_result(&self, result: &RemoteActionResult) -> Result<()> {
384 let url = self.action_result_endpoint(&result.action)?;
385 let body = serde_json::to_vec(result)?;
386 retry_async("PUT", &url, self.retries, || async {
387 let response = self
388 .request(reqwest::Method::PUT, url.clone(), ACTION_RESULT_MEDIA_TYPE)
389 .await?
390 .header(CONTENT_TYPE, ACTION_RESULT_MEDIA_TYPE)
391 .header(IF_NONE_MATCH, "*")
392 .body(body.clone())
393 .send()
394 .await?;
395 if response.status() != StatusCode::PRECONDITION_FAILED {
396 response.error_for_status()?;
397 }
398 Ok(())
399 })
400 .await
401 }
402
403 pub async fn get_action_manifest(
404 &self,
405 key: &CacheDigest,
406 ) -> Result<Option<RemoteActionManifest>> {
407 let url = self.action_manifest_endpoint(key)?;
408 retry_async("GET", &url, self.retries, || async {
409 let response = self
410 .request(
411 reqwest::Method::GET,
412 url.clone(),
413 TASK_ACTION_MANIFEST_MEDIA_TYPE,
414 )
415 .await?
416 .send()
417 .await?;
418 if response.status() == StatusCode::NOT_FOUND {
419 return Ok(None);
420 }
421 let response = response.error_for_status()?;
422 let etag = parse_strong_etag(response.headers().get(ETAG))?;
423 let bytes = response.bytes().await?.to_vec();
424 if blake3::hash(&bytes).to_hex().as_str() != etag {
425 bail!("remote action manifest ETag does not match its body");
426 }
427 Ok(Some(RemoteActionManifest { bytes, etag }))
428 })
429 .await
430 }
431
432 pub async fn put_action_manifest(
433 &self,
434 key: &CacheDigest,
435 bytes: &[u8],
436 expected_etag: Option<&str>,
437 ) -> Result<ManifestPutOutcome> {
438 let url = self.action_manifest_endpoint(key)?;
439 let body = bytes.to_vec();
440 let expected_etag = expected_etag.map(quoted_etag).transpose()?;
441 retry_async("PUT", &url, self.retries, || async {
442 let mut request = self
443 .request(
444 reqwest::Method::PUT,
445 url.clone(),
446 TASK_ACTION_MANIFEST_MEDIA_TYPE,
447 )
448 .await?
449 .header(CONTENT_TYPE, TASK_ACTION_MANIFEST_MEDIA_TYPE)
450 .body(body.clone());
451 request = if let Some(etag) = &expected_etag {
452 request.header(IF_MATCH, etag)
453 } else {
454 request.header(IF_NONE_MATCH, "*")
455 };
456 let response = request.send().await?;
457 if response.status() == StatusCode::PRECONDITION_FAILED {
458 return Ok(ManifestPutOutcome::PreconditionFailed);
459 }
460 response.error_for_status()?;
461 Ok(ManifestPutOutcome::Stored)
462 })
463 .await
464 }
465
466 pub async fn get_blob(
467 &self,
468 digest: &CacheDigest,
469 media_type: &'static str,
470 ) -> Result<Vec<u8>> {
471 digest.validate()?;
472 let url = self.blob_endpoint(digest)?;
473 retry_async("GET", &url, self.retries, || async {
474 let response = self
475 .request(reqwest::Method::GET, url.clone(), media_type)
476 .await?
477 .send()
478 .await?
479 .error_for_status()?;
480 let bytes = response.bytes().await?.to_vec();
481 if !digest.matches_bytes(&bytes)? {
482 bail!("remote cache blob failed digest verification");
483 }
484 Ok(bytes)
485 })
486 .await
487 }
488
489 pub async fn get_blob_file(
490 &self,
491 digest: &CacheDigest,
492 staging_dir: &Path,
493 ) -> Result<tempfile::NamedTempFile> {
494 let url = self.blob_endpoint(digest)?;
495 let download = retry_async("GET", &url, self.retries, || async {
496 let mut response = self
497 .request(reqwest::Method::GET, url.clone(), BLOB_MEDIA_TYPE)
498 .await?
499 .send()
500 .await?;
501 response.error_for_status_ref()?;
502 fs::create_dir_all(staging_dir)?;
503 let temporary = tempfile::NamedTempFile::new_in(staging_dir)?;
504 let mut output = tokio::fs::File::from_std(temporary.reopen()?);
505 while let Some(chunk) = response.chunk().await? {
506 output.write_all(&chunk).await?;
507 }
508 output.flush().await?;
509 drop(output);
510 if !digest.matches_file(temporary.path())? {
511 bail!("remote cache blob failed digest verification");
512 }
513 Ok(temporary)
514 });
515 tokio::time::timeout(self.download_timeout, download)
516 .await
517 .map_err(|_| eyre!("remote cache blob download timed out for {url}"))?
518 }
519
520 pub async fn put_blob(&self, upload: &BlobUpload) -> Result<()> {
521 let url = self.blob_endpoint(&upload.digest)?;
522 retry_async("PUT", &url, self.retries, || async {
523 let (length, body) = match &upload.source {
524 BlobSource::Bytes(bytes) => {
525 (bytes.len() as u64, reqwest::Body::from(bytes.clone()))
526 }
527 BlobSource::File(file) => {
528 let file = tokio::fs::File::open(file.path()).await?;
529 let length = file.metadata().await?.len();
530 let stream = tokio_util::io::ReaderStream::new(file);
531 (length, reqwest::Body::wrap_stream(stream))
532 }
533 BlobSource::Path(path) => {
534 let file = tokio::fs::File::open(path).await?;
535 let length = file.metadata().await?.len();
536 let stream = tokio_util::io::ReaderStream::new(file);
537 (length, reqwest::Body::wrap_stream(stream))
538 }
539 };
540 let response = self
541 .request(reqwest::Method::PUT, url.clone(), BLOB_MEDIA_TYPE)
542 .await?
543 .header(CONTENT_TYPE, BLOB_MEDIA_TYPE)
544 .header(CONTENT_LENGTH, length)
545 .header(IF_NONE_MATCH, "*")
546 .body(body)
547 .send()
548 .await?;
549 if response.status() != StatusCode::PRECONDITION_FAILED {
550 response.error_for_status()?;
551 }
552 Ok(())
553 })
554 .await
555 }
556}
557
558fn parse_strong_etag(value: Option<&HeaderValue>) -> Result<String> {
559 let value = value
560 .and_then(|value| value.to_str().ok())
561 .ok_or_else(|| eyre!("remote action manifest response is missing an ETag"))?;
562 let etag = value
563 .strip_prefix('"')
564 .and_then(|value| value.strip_suffix('"'))
565 .filter(|value| is_lower_hex_digest(value))
566 .ok_or_else(|| eyre!("remote action manifest response has an invalid ETag"))?;
567 Ok(etag.to_owned())
568}
569
570fn quoted_etag(etag: &str) -> Result<HeaderValue> {
571 if !is_lower_hex_digest(etag) {
572 bail!("invalid remote action manifest ETag");
573 }
574 Ok(HeaderValue::from_str(&format!("\"{etag}\""))?)
575}
576
577fn is_lower_hex_digest(value: &str) -> bool {
578 value.len() == 64
579 && value
580 .bytes()
581 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
582}
583
584#[derive(Clone)]
585enum RemoteCacheCredential {
586 None,
587 Static(HeaderValue),
588 File(PathBuf),
589 GithubActions(Arc<GithubActionsOidcCredential>),
590}
591
592struct GithubActionsOidcCredential {
593 audience: String,
594 request_url: Url,
595 request_token: HeaderValue,
596 client: reqwest::Client,
597 retries: i64,
598 cached: tokio::sync::Mutex<Option<CachedOidcToken>>,
599}
600
601struct CachedOidcToken {
602 authorization: HeaderValue,
603 expires_at: u64,
604}
605
606#[derive(Deserialize)]
607struct GithubActionsOidcResponse {
608 value: String,
609}
610
611#[derive(Deserialize)]
612struct JwtExpiry {
613 exp: u64,
614}
615
616fn remote_credential(
617 config: &RemoteCacheConfig,
618 client: reqwest::Client,
619) -> Result<RemoteCacheCredential> {
620 if let Some(authorization) = authorization_header(config.token.as_deref())? {
621 return Ok(RemoteCacheCredential::Static(authorization));
622 }
623 if let Some(path) = &config.token_file {
624 return Ok(RemoteCacheCredential::File(path.clone()));
625 }
626 let Some(audience) = config
627 .oidc_audience
628 .as_deref()
629 .map(str::trim)
630 .filter(|audience| !audience.is_empty())
631 else {
632 return Ok(RemoteCacheCredential::None);
633 };
634 Ok(RemoteCacheCredential::GithubActions(Arc::new(
635 GithubActionsOidcCredential::from_env(audience, client, config.retries)?,
636 )))
637}
638
639fn authorization_header(token: Option<&str>) -> Result<Option<HeaderValue>> {
640 let Some(token) = token.map(str::trim).filter(|token| !token.is_empty()) else {
641 return Ok(None);
642 };
643 let mut value = HeaderValue::from_str(&format!("Bearer {token}"))?;
644 value.set_sensitive(true);
645 Ok(Some(value))
646}
647
648impl RemoteCacheCredential {
649 async fn authorization(&self) -> Result<Option<HeaderValue>> {
650 match self {
651 Self::None => Ok(None),
652 Self::Static(value) => Ok(Some(value.clone())),
653 Self::File(path) => {
654 let token = tokio::fs::read_to_string(path).await.map_err(|err| {
655 eyre!(
656 "failed to read remote cache token file {}: {err}",
657 path.display()
658 )
659 })?;
660 authorization_header(Some(&token))?
661 .ok_or_else(|| eyre!("remote cache token file {} is empty", path.display()))
662 .map(Some)
663 }
664 Self::GithubActions(credential) => credential.authorization().await.map(Some),
665 }
666 }
667}
668
669impl GithubActionsOidcCredential {
670 fn from_env(audience: &str, client: reqwest::Client, retries: i64) -> Result<Self> {
671 let request_url = std::env::var("ACTIONS_ID_TOKEN_REQUEST_URL").map_err(|_| {
672 eyre!(
673 "remote cache OIDC audience requires GitHub Actions OIDC; \
674 grant `id-token: write` or set MISE_TASK_CACHE_REMOTE_TOKEN"
675 )
676 })?;
677 let request_token = std::env::var("ACTIONS_ID_TOKEN_REQUEST_TOKEN").map_err(|_| {
678 eyre!(
679 "remote cache OIDC audience requires GitHub Actions OIDC; \
680 ACTIONS_ID_TOKEN_REQUEST_TOKEN is missing"
681 )
682 })?;
683 let request_url: Url = request_url
684 .parse()
685 .map_err(|err| eyre!("invalid GitHub Actions OIDC request URL: {err}"))?;
686 Self::new(audience, request_url, &request_token, client, retries)
687 }
688
689 fn new(
690 audience: &str,
691 mut request_url: Url,
692 request_token: &str,
693 client: reqwest::Client,
694 retries: i64,
695 ) -> Result<Self> {
696 validate_oidc_request_url(&request_url)?;
697 let query = request_url
698 .query_pairs()
699 .filter(|(key, _)| key != "audience")
700 .map(|(key, value)| (key.into_owned(), value.into_owned()))
701 .collect::<Vec<_>>();
702 request_url.set_query(None);
703 request_url
704 .query_pairs_mut()
705 .extend_pairs(query)
706 .append_pair("audience", audience);
707 let request_token = authorization_header(Some(request_token))?
708 .ok_or_else(|| eyre!("GitHub Actions OIDC request token is empty"))?;
709 Ok(Self {
710 audience: audience.to_string(),
711 request_url,
712 request_token,
713 client,
714 retries,
715 cached: tokio::sync::Mutex::new(None),
716 })
717 }
718
719 async fn authorization(&self) -> Result<HeaderValue> {
720 const REFRESH_LEEWAY_SECONDS: u64 = 60;
721 let mut cached = self.cached.lock().await;
722 let now = unix_timestamp()?;
723 if let Some(token) = cached.as_ref()
724 && token.expires_at > now.saturating_add(REFRESH_LEEWAY_SECONDS)
725 {
726 return Ok(token.authorization.clone());
727 }
728 let response: GithubActionsOidcResponse =
729 retry_async("GET", &self.request_url, self.retries, || async {
730 Ok(self
731 .client
732 .get(self.request_url.clone())
733 .header(AUTHORIZATION, self.request_token.clone())
734 .send()
735 .await?
736 .error_for_status()?
737 .json()
738 .await?)
739 })
740 .await
741 .map_err(|err| {
742 eyre!(
743 "failed to acquire GitHub Actions OIDC token for audience {:?}: {err}",
744 self.audience
745 )
746 })?;
747 let expires_at = jwt_expiry(&response.value)?;
748 if expires_at <= now.saturating_add(REFRESH_LEEWAY_SECONDS) {
749 bail!("GitHub Actions OIDC token expires too soon");
750 }
751 let authorization = authorization_header(Some(&response.value))?
752 .ok_or_else(|| eyre!("GitHub Actions returned an empty OIDC token"))?;
753 *cached = Some(CachedOidcToken {
754 authorization: authorization.clone(),
755 expires_at,
756 });
757 Ok(authorization)
758 }
759}
760
761fn jwt_expiry(token: &str) -> Result<u64> {
762 let payload = token
763 .split('.')
764 .nth(1)
765 .ok_or_else(|| eyre!("GitHub Actions returned a malformed OIDC token"))?;
766 let payload = URL_SAFE_NO_PAD
767 .decode(payload)
768 .map_err(|_| eyre!("GitHub Actions returned a malformed OIDC token"))?;
769 let claims: JwtExpiry = serde_json::from_slice(&payload)
770 .map_err(|_| eyre!("GitHub Actions OIDC token is missing a valid expiry"))?;
771 Ok(claims.exp)
772}
773
774fn unix_timestamp() -> Result<u64> {
775 Ok(SystemTime::now()
776 .duration_since(UNIX_EPOCH)
777 .map_err(|err| eyre!("system clock is before the Unix epoch: {err}"))?
778 .as_secs())
779}
780
781fn validate_oidc_request_url(url: &Url) -> Result<()> {
782 if url.scheme() == "https"
783 || url.scheme() == "http"
784 && url.host().is_some_and(|host| match host {
785 Host::Domain(host) => host.eq_ignore_ascii_case("localhost"),
786 Host::Ipv4(address) => address.is_loopback(),
787 Host::Ipv6(address) => address.is_loopback(),
788 })
789 {
790 Ok(())
791 } else {
792 bail!("GitHub Actions OIDC request URL must use HTTPS")
793 }
794}
795
796fn validate_remote_url(base_url: &Url, authenticated: bool) -> Result<()> {
797 if base_url.scheme() == "https" {
798 return Ok(());
799 }
800 if base_url.scheme() != "http" {
801 bail!("remote cache URL must use HTTPS");
802 }
803 let is_loopback = base_url.host().is_some_and(|host| match host {
804 Host::Domain(host) => host.eq_ignore_ascii_case("localhost"),
805 Host::Ipv4(address) => address.is_loopback(),
806 Host::Ipv6(address) => address.is_loopback(),
807 });
808 if !is_loopback && authenticated {
809 bail!("remote cache URL must use HTTPS except for loopback development servers");
810 }
811 if !is_loopback {
812 warn!(
813 "using an unauthenticated remote build cache over plain HTTP; cache traffic can be read \
814 or modified in transit"
815 );
816 }
817 Ok(())
818}
819
820fn normalized_base_url(mut url: Url) -> Url {
821 if !url.path().ends_with('/') {
822 url.set_path(&format!("{}/", url.path()));
823 }
824 url
825}
826
827fn retry_delays(retries: i64) -> impl Iterator<Item = Duration> {
828 [200u64, 1_000, 4_000, 15_000]
829 .into_iter()
830 .chain(std::iter::repeat(15_000))
831 .map(Duration::from_millis)
832 .map(|duration| {
833 let factor = 0.5 + rand::random::<f64>() * 0.5;
834 Duration::from_secs_f64(duration.as_secs_f64() * factor)
835 })
836 .take(retries.max(0) as usize)
837}
838
839fn is_dns_error(error: &(dyn std::error::Error + 'static)) -> bool {
843 let mut current = Some(error);
844 while let Some(source) = current {
845 if source.to_string() == "dns error" {
846 return true;
847 }
848 current = source.source();
849 }
850 false
851}
852
853fn is_transient(error: &eyre::Report) -> bool {
854 if is_dns_error(error.as_ref()) {
857 return false;
858 }
859 error.chain().any(|source| {
860 let Some(error) = source.downcast_ref::<reqwest::Error>() else {
861 return false;
862 };
863 if error.is_timeout() || error.is_connect() || error.is_body() {
864 return true;
865 }
866 error.status().is_some_and(|status| {
867 let status = status.as_u16();
868 status == 408 || status == 429 || (500..600).contains(&status)
869 })
870 })
871}
872
873async fn retry_async<F, Fut, T>(verb: &str, url: &Url, retries: i64, mut operation: F) -> Result<T>
874where
875 F: FnMut() -> Fut,
876 Fut: std::future::Future<Output = Result<T>>,
877{
878 let mut delays = retry_delays(retries);
879 let mut attempt = 1;
880 loop {
881 let started_at = Instant::now();
882 match operation().await {
883 Ok(value) => return Ok(value),
884 Err(error) if is_transient(&error) => {
885 let Some(delay) = delays.next() else {
886 return Err(error);
887 };
888 warn!(
889 "HTTP {verb} {url} attempt {attempt} failed after {:?} (transient): {error}; retrying in {delay:?}",
890 started_at.elapsed()
891 );
892 tokio::time::sleep(delay).await;
893 attempt += 1;
894 }
895 Err(error) => return Err(error),
896 }
897 }
898}
899
900#[cfg(test)]
901mod tests {
902 use super::*;
903
904 #[test]
905 fn protocol_json_uses_jcs_key_and_number_encoding() {
906 let value = serde_json::json!({"z": 1.0e30, "a": {"d": true, "c": null}});
907 assert_eq!(
908 canonical_json(&value).unwrap(),
909 br#"{"a":{"c":null,"d":true},"z":1e+30}"#
910 );
911 }
912
913 #[test]
914 fn dns_errors_are_not_transient() {
915 #[derive(Debug)]
916 struct DnsError;
917
918 impl std::fmt::Display for DnsError {
919 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
920 formatter.write_str("dns error")
921 }
922 }
923
924 impl std::error::Error for DnsError {}
925
926 let error = eyre::Report::new(DnsError);
927 assert!(is_dns_error(error.as_ref()));
928 assert!(!is_transient(&error));
929 }
930
931 #[test]
932 fn cache_digest_verifies_its_declared_algorithm() {
933 let bytes = b"remote cache blob";
934 let sha256 = CacheDigest {
935 algorithm: "sha256".into(),
936 hash: hex::encode(sha2::Sha256::digest(bytes)),
937 size: bytes.len() as u64,
938 };
939 assert!(sha256.matches_bytes(bytes).unwrap());
940 assert!(!sha256.matches_bytes(b"different").unwrap());
941
942 let file = tempfile::NamedTempFile::new().unwrap();
943 fs::write(file.path(), bytes).unwrap();
944 assert!(sha256.matches_file(file.path()).unwrap());
945 assert_eq!(
946 CacheDigest::blake3_file(file.path()).unwrap().size,
947 bytes.len() as u64
948 );
949 assert!(
950 CacheDigest::blake3_file(file.path())
951 .unwrap()
952 .matches_bytes(bytes)
953 .unwrap()
954 );
955 }
956
957 #[test]
958 fn action_result_keys_require_blake3() {
959 let client = RemoteCacheClient::new(RemoteCacheConfig {
960 base_url: "http://127.0.0.1:1".parse().unwrap(),
961 namespace: "test".into(),
962 token: None,
963 token_file: None,
964 oidc_audience: None,
965 connect_timeout: Duration::from_secs(1),
966 read_timeout: Duration::from_secs(1),
967 download_timeout: Duration::from_secs(1),
968 retries: 0,
969 })
970 .unwrap();
971 let action = CacheDigest {
972 algorithm: "sha256".into(),
973 hash: "0".repeat(64),
974 size: 0,
975 };
976
977 assert!(
978 client
979 .action_result_endpoint(&action)
980 .unwrap_err()
981 .to_string()
982 .contains("must use blake3")
983 );
984 }
985
986 #[test]
987 fn bearer_authorization_headers_are_sensitive() {
988 let header = authorization_header(Some(" test-token ")).unwrap().unwrap();
989 assert_eq!(header, "Bearer test-token");
990 assert!(header.is_sensitive());
991 assert!(authorization_header(Some(" ")).unwrap().is_none());
992 }
993
994 #[tokio::test]
995 async fn token_file_credentials_are_reloaded() {
996 let directory = tempfile::tempdir().unwrap();
997 let path = directory.path().join("cache-token");
998 fs::write(&path, "first-token\n").unwrap();
999 let credential = RemoteCacheCredential::File(path.clone());
1000
1001 let first = credential.authorization().await.unwrap().unwrap();
1002 assert_eq!(first, "Bearer first-token");
1003 assert!(first.is_sensitive());
1004
1005 fs::write(path, "rotated-token\n").unwrap();
1006 let rotated = credential.authorization().await.unwrap().unwrap();
1007 assert_eq!(rotated, "Bearer rotated-token");
1008 }
1009
1010 #[tokio::test]
1011 async fn github_actions_oidc_tokens_are_acquired_and_cached() {
1012 let mut server = mockito::Server::new_async().await;
1013 let expires_at = unix_timestamp().unwrap() + 3600;
1014 let token = test_jwt(expires_at);
1015 let token_response = serde_json::json!({"value":token}).to_string();
1016 let request = server
1017 .mock("GET", "/oidc")
1018 .match_query(mockito::Matcher::UrlEncoded(
1019 "audience".into(),
1020 "https://cache.example.com".into(),
1021 ))
1022 .match_header("authorization", "Bearer request-secret")
1023 .with_status(200)
1024 .with_header("content-type", "application/json")
1025 .with_body(token_response)
1026 .expect(1)
1027 .create_async()
1028 .await;
1029 let credential = GithubActionsOidcCredential::new(
1030 "https://cache.example.com",
1031 format!("{}/oidc?api-version=1&audience=old", server.url())
1032 .parse()
1033 .unwrap(),
1034 "request-secret",
1035 reqwest::Client::new(),
1036 0,
1037 )
1038 .unwrap();
1039 assert_eq!(
1040 credential.request_url.query_pairs().collect::<Vec<_>>(),
1041 vec![
1042 ("api-version".into(), "1".into()),
1043 ("audience".into(), "https://cache.example.com".into()),
1044 ]
1045 );
1046
1047 let first = credential.authorization().await.unwrap();
1048 let second = credential.authorization().await.unwrap();
1049
1050 assert_eq!(first, format!("Bearer {token}"));
1051 assert_eq!(first, second);
1052 assert!(first.is_sensitive());
1053 request.assert_async().await;
1054 }
1055
1056 #[test]
1057 fn oidc_request_urls_require_https_except_for_loopback() {
1058 validate_oidc_request_url(&"https://example.com/oidc".parse().unwrap()).unwrap();
1059 validate_oidc_request_url(&"http://127.0.0.1:3000/oidc".parse().unwrap()).unwrap();
1060 assert!(validate_oidc_request_url(&"http://example.com/oidc".parse().unwrap()).is_err());
1061 }
1062
1063 fn test_jwt(expires_at: u64) -> String {
1064 let header = URL_SAFE_NO_PAD.encode(b"{}");
1065 let claims = URL_SAFE_NO_PAD
1066 .encode(serde_json::to_vec(&serde_json::json!({"exp":expires_at})).unwrap());
1067 format!("{header}.{claims}.signature")
1068 }
1069
1070 #[test]
1071 fn remote_urls_require_https_for_authenticated_requests() {
1072 for url in [
1073 "http://localhost:3000",
1074 "http://127.0.0.1:3000",
1075 "http://[::1]:3000",
1076 "https://cache.example.com",
1077 ] {
1078 validate_remote_url(&url.parse().unwrap(), true).unwrap();
1079 }
1080 let insecure: Url = "http://cache.example.com".parse().unwrap();
1081 assert!(validate_remote_url(&insecure, true).is_err());
1082 validate_remote_url(&insecure, false).unwrap();
1083 assert!(validate_remote_url(&"ftp://localhost/cache".parse().unwrap(), false).is_err());
1084 }
1085}