1use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
2use eyre::{Result, bail, eyre};
3use futures_util::TryStreamExt as _;
4use log::warn;
5use reqwest::StatusCode;
6use reqwest::header::{
7 ACCEPT, AUTHORIZATION, CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_TYPE, ETAG, HeaderMap,
8 HeaderValue, IF_MATCH, IF_NONE_MATCH,
9};
10use serde::{Deserialize, Serialize};
11use sha2::Digest as _;
12use std::collections::BTreeSet;
13use std::fs::{self, File};
14use std::io::Read;
15use std::path::{Path, PathBuf};
16use std::sync::Arc;
17use std::sync::atomic::{AtomicBool, Ordering};
18use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
19use tokio::io::{AsyncReadExt, AsyncWriteExt};
20use url::{Host, Url};
21
22mod agent;
23mod local;
24
25pub use agent::{
26 AGENT_PROTOCOL_VERSION, ActionPrediction, AgentRemoteCache, AgentRequest, AgentResponse,
27 AgentStats, CacheAgent, RestoreStats,
28};
29pub use local::{LocalActionCache, LocalCas};
30
31pub const PROTOCOL_VERSION: u8 = 1;
32const PROTOCOL_HEADER: &str = "mbx-cache-protocol";
33const NAMESPACE_HEADER: &str = "mbx-cache-namespace";
34pub const ACTION_RESULT_MEDIA_TYPE: &str = "application/vnd.mbx.cache-action-result.v1+json";
35pub const DIRECTORY_MEDIA_TYPE: &str = "application/vnd.mbx.cache-directory.v1+json";
36pub const CLIENT_METADATA_MEDIA_TYPE: &str = "application/vnd.mbx.cache-client-metadata.v1+json";
37pub const TASK_ACTION_MANIFEST_MEDIA_TYPE: &str =
38 "application/vnd.mbx.cache-task-action-manifest.v1+json";
39pub const BLOB_MEDIA_TYPE: &str = "application/octet-stream";
40pub const BLOB_PACK_MEDIA_TYPE: &str = "application/vnd.mbx.cache-blob-pack.v1";
41const DIGEST_LIST_MEDIA_TYPE: &str = "application/vnd.mbx.cache-digests.v1+json";
42const BLOB_PACK_BLOBS_HEADER: &str = "mbx-cache-pack-blobs";
43const BLOB_PACK_BYTES_HEADER: &str = "mbx-cache-pack-bytes";
44const BLOB_PACK_MAGIC: &[u8; 8] = b"MBXPACK1";
45const BLOB_PACK_HEADER_BYTES: u64 = 1 + 32 + 8;
46const MAX_REMOTE_JSON_BYTES: u64 = 16 * 1024 * 1024;
53const MAX_STAGED_BLOB_PACK_BYTES: u64 = 256 * 1024 * 1024;
54const MAX_STAGED_BLOB_PACK_ITEMS: usize = 2 * 1024;
55const BLOB_PACK_TIMEOUT_BYTES_PER_UNIT: u64 = MAX_STAGED_BLOB_PACK_BYTES / 4;
56const BLOB_PACK_TIMEOUT_ITEMS_PER_UNIT: usize = MAX_STAGED_BLOB_PACK_ITEMS / 4;
57
58pub fn canonical_json(value: &impl Serialize) -> Result<Vec<u8>> {
63 Ok(serde_json_canonicalizer::to_vec(value)?)
64}
65
66#[derive(
67 Debug,
68 Clone,
69 Copy,
70 Serialize,
71 Deserialize,
72 Default,
73 strum::EnumString,
74 strum::Display,
75 PartialEq,
76 Eq,
77)]
78#[serde(rename_all = "kebab-case")]
79#[strum(serialize_all = "kebab-case")]
80pub enum RemoteCacheMode {
81 #[default]
82 ReadWrite,
83 ReadOnly,
84 WriteOnly,
85}
86
87impl RemoteCacheMode {
88 pub fn reads(self) -> bool {
89 matches!(self, Self::ReadWrite | Self::ReadOnly)
90 }
91
92 pub fn writes(self) -> bool {
93 matches!(self, Self::ReadWrite | Self::WriteOnly)
94 }
95}
96
97pub struct RemoteCacheConfig {
98 pub base_url: Url,
99 pub namespace: String,
100 pub token: Option<String>,
101 pub token_file: Option<PathBuf>,
102 pub oidc_audience: Option<String>,
103 pub connect_timeout: Duration,
104 pub read_timeout: Duration,
105 pub download_timeout: Duration,
106 pub retries: i64,
107}
108
109#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
110pub struct CacheDigest {
111 pub algorithm: String,
112 pub hash: String,
113 pub size: u64,
114}
115
116impl CacheDigest {
117 pub fn blake3(bytes: &[u8]) -> Self {
118 Self {
119 algorithm: "blake3".into(),
120 hash: blake3::hash(bytes).to_hex().to_string(),
121 size: bytes.len() as u64,
122 }
123 }
124
125 pub fn blake3_file(path: &Path) -> Result<Self> {
127 let (hash, size) = hash_file_blake3(path)?;
128 Ok(Self {
129 algorithm: "blake3".into(),
130 hash,
131 size,
132 })
133 }
134
135 pub fn validate(&self) -> Result<()> {
136 if self.algorithm != "blake3" && self.algorithm != "sha256" {
137 bail!("unsupported remote cache digest algorithm");
138 }
139 if self.hash.len() != 64
140 || !self
141 .hash
142 .bytes()
143 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
144 {
145 bail!("invalid remote cache digest");
146 }
147 Ok(())
148 }
149
150 pub fn matches_bytes(&self, bytes: &[u8]) -> Result<bool> {
151 self.validate()?;
152 if self.size != bytes.len() as u64 {
153 return Ok(false);
154 }
155 let hash = match self.algorithm.as_str() {
156 "blake3" => blake3::hash(bytes).to_hex().to_string(),
157 "sha256" => hex::encode(sha2::Sha256::digest(bytes)),
158 _ => unreachable!("digest algorithm was validated"),
159 };
160 Ok(self.hash == hash)
161 }
162
163 pub fn matches_file(&self, path: &Path) -> Result<bool> {
164 self.validate()?;
165 let (hash, size) = match self.algorithm.as_str() {
166 "blake3" => hash_file_blake3(path)?,
167 "sha256" => hash_file_sha256(path)?,
168 _ => unreachable!("digest algorithm was validated"),
169 };
170 Ok(self.size == size && self.hash == hash)
171 }
172}
173
174fn hash_file_blake3(path: &Path) -> Result<(String, u64)> {
175 let mut file = File::open(path)?;
176 let mut hasher = blake3::Hasher::new();
177 let mut buffer = [0; 64 * 1024];
178 let mut size = 0;
179 loop {
180 let count = file.read(&mut buffer)?;
181 if count == 0 {
182 break;
183 }
184 hasher.update(&buffer[..count]);
185 size += count as u64;
186 }
187 Ok((hasher.finalize().to_hex().to_string(), size))
188}
189
190fn hash_file_sha256(path: &Path) -> Result<(String, u64)> {
191 let mut file = File::open(path)?;
192 let mut hasher = sha2::Sha256::new();
193 let mut buffer = [0; 64 * 1024];
194 let mut size = 0;
195 loop {
196 let count = file.read(&mut buffer)?;
197 if count == 0 {
198 break;
199 }
200 hasher.update(&buffer[..count]);
201 size += count as u64;
202 }
203 Ok((hex::encode(hasher.finalize()), size))
204}
205
206#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
208#[serde(deny_unknown_fields)]
209pub struct RemoteActionResult {
210 pub action: CacheDigest,
211 #[serde(default)]
212 pub metadata: Option<CacheDigest>,
213 #[serde(default)]
214 pub output_root: Option<CacheDigest>,
215 pub version: u8,
216}
217
218#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
220#[serde(deny_unknown_fields)]
221pub struct CacheDirectory {
222 pub directories: Vec<CacheDirectoryNode>,
223 pub files: Vec<CacheFileNode>,
224 pub symlinks: Vec<CacheSymlinkNode>,
225 pub version: u8,
226}
227
228#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
230#[serde(deny_unknown_fields)]
231pub struct CacheDirectoryNode {
232 pub digest: CacheDigest,
233 pub mode: u32,
234 pub name: String,
235}
236
237#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
239#[serde(deny_unknown_fields)]
240pub struct CacheFileNode {
241 pub digest: CacheDigest,
242 pub executable: bool,
243 pub mode: u32,
244 pub name: String,
245}
246
247#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
249#[serde(deny_unknown_fields)]
250pub struct CacheSymlinkNode {
251 pub mode: u32,
252 pub name: String,
253 pub target: String,
254}
255
256#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
258#[serde(deny_unknown_fields)]
259pub struct RustcMetadata {
260 pub version: u8,
261 pub kind: String,
262 pub stdout: CacheDigest,
263 pub stderr: CacheDigest,
264}
265
266pub enum BlobSource {
267 Bytes(Vec<u8>),
268 File(tempfile::NamedTempFile),
269 Path(PathBuf),
270}
271
272pub struct BlobUpload {
273 pub digest: CacheDigest,
274 pub source: BlobSource,
275}
276
277pub struct RemoteActionManifest {
278 pub bytes: Vec<u8>,
279 pub etag: String,
280}
281
282pub struct RemoteBlobPack {
284 _directory: tempfile::TempDir,
285 pub blobs: Vec<(CacheDigest, PathBuf)>,
286 pub requests: u64,
287 pub requested: Vec<CacheDigest>,
288 pub blob_count: u64,
289 pub payload_bytes: u64,
290 pub framed_bytes: u64,
291}
292
293struct DownloadedBlobPack {
294 directory: tempfile::TempDir,
295 blobs: Vec<(CacheDigest, PathBuf)>,
296 metadata: BlobPackResponseStats,
297}
298
299#[derive(Debug, Clone, Copy, Default)]
300struct BlobPackResponseMetadata {
301 content_length: Option<u64>,
302 blob_count: Option<u64>,
303 payload_bytes: Option<u64>,
304}
305
306#[derive(Debug, Clone, Copy)]
307struct BlobPackResponseStats {
308 blob_count: u64,
309 payload_bytes: u64,
310 framed_bytes: u64,
311}
312
313impl BlobPackResponseMetadata {
314 fn from_headers(headers: &HeaderMap) -> Result<Self> {
315 Ok(Self {
316 content_length: optional_u64_header(headers, CONTENT_LENGTH.as_str())?,
317 blob_count: optional_u64_header(headers, BLOB_PACK_BLOBS_HEADER)?,
318 payload_bytes: optional_u64_header(headers, BLOB_PACK_BYTES_HEADER)?,
319 })
320 }
321
322 fn validate(self, decoded: BlobPackResponseStats) -> Result<BlobPackResponseStats> {
323 if let Some(content_length) = self.content_length
324 && content_length != decoded.framed_bytes
325 {
326 bail!(
327 "remote cache blob pack content length metadata mismatch: expected {}, decoded {}",
328 content_length,
329 decoded.framed_bytes
330 );
331 }
332 if let Some(blob_count) = self.blob_count
333 && blob_count != decoded.blob_count
334 {
335 bail!(
336 "remote cache blob pack blob count metadata mismatch: expected {}, decoded {}",
337 blob_count,
338 decoded.blob_count
339 );
340 }
341 if let Some(payload_bytes) = self.payload_bytes
342 && payload_bytes != decoded.payload_bytes
343 {
344 bail!(
345 "remote cache blob pack payload byte metadata mismatch: expected {}, decoded {}",
346 payload_bytes,
347 decoded.payload_bytes
348 );
349 }
350 Ok(BlobPackResponseStats {
351 blob_count: self.blob_count.unwrap_or(decoded.blob_count),
352 payload_bytes: self.payload_bytes.unwrap_or(decoded.payload_bytes),
353 framed_bytes: self.content_length.unwrap_or(decoded.framed_bytes),
354 })
355 }
356}
357
358fn optional_u64_header(headers: &HeaderMap, name: &str) -> Result<Option<u64>> {
359 let Some(value) = headers.get(name) else {
360 return Ok(None);
361 };
362 let value = value
363 .to_str()
364 .map_err(|_| eyre!("remote cache blob pack {name} header is not valid UTF-8"))?;
365 let value = value
366 .parse::<u64>()
367 .map_err(|_| eyre!("remote cache blob pack {name} header is not an unsigned integer"))?;
368 Ok(Some(value))
369}
370
371#[derive(Debug, Deserialize)]
372struct RemoteCacheCapabilities {
373 protocol: CapabilityProtocol,
374 #[serde(default)]
375 features: CapabilityFeatures,
376 #[serde(default)]
377 limits: CapabilityLimits,
378 #[serde(default)]
381 compressors: Vec<String>,
382}
383
384#[derive(Debug, Deserialize)]
385struct CapabilityProtocol {
386 major: u8,
387}
388
389#[derive(Debug, Default, Deserialize)]
390struct CapabilityFeatures {
391 #[serde(default)]
392 blob_packs: bool,
393}
394
395#[derive(Debug, Default, Deserialize)]
396struct CapabilityLimits {
397 #[serde(default)]
398 max_batch_items: u64,
399 #[serde(default)]
400 max_pack_bytes: u64,
401}
402
403#[derive(Debug, Clone, Copy)]
404struct BlobPackLimits {
405 max_items: usize,
406 max_bytes: u64,
407}
408
409#[derive(Debug, Clone, Copy, Default)]
415struct NegotiatedCapabilities {
416 blob_packs: Option<BlobPackLimits>,
417 zstd_uploads: bool,
418}
419
420#[derive(Serialize)]
421struct DigestList<'a> {
422 digests: &'a [CacheDigest],
423}
424
425#[derive(Debug, Clone, Copy, PartialEq, Eq)]
426pub enum ManifestPutOutcome {
427 Stored,
428 PreconditionFailed,
429}
430
431pub struct RemoteCacheClient {
432 base_url: Url,
433 namespace: String,
434 client: reqwest::Client,
435 credential: RemoteCacheCredential,
436 download_timeout: Duration,
437 retries: i64,
438 capabilities: tokio::sync::OnceCell<NegotiatedCapabilities>,
439 blob_packs_disabled: AtomicBool,
440}
441
442impl RemoteCacheClient {
443 pub fn new(config: RemoteCacheConfig) -> Result<Self> {
444 let authenticated = config
445 .token
446 .as_deref()
447 .is_some_and(|token| !token.trim().is_empty())
448 || config.token_file.is_some()
449 || config
450 .oidc_audience
451 .as_deref()
452 .is_some_and(|audience| !audience.trim().is_empty());
453 validate_remote_url(&config.base_url, authenticated)?;
454 let client = reqwest::Client::builder()
455 .connect_timeout(config.connect_timeout)
456 .read_timeout(config.read_timeout)
457 .redirect(reqwest::redirect::Policy::none())
458 .build()?;
459 let credential = remote_credential(&config, client.clone())?;
460 Ok(Self {
461 base_url: normalized_base_url(config.base_url),
462 namespace: config.namespace,
463 client,
464 credential,
465 download_timeout: config.download_timeout,
466 retries: config.retries,
467 capabilities: tokio::sync::OnceCell::new(),
468 blob_packs_disabled: AtomicBool::new(false),
469 })
470 }
471
472 fn action_result_endpoint(&self, action: &CacheDigest) -> Result<Url> {
473 action.validate()?;
474 if action.algorithm != "blake3" {
475 bail!("remote cache action keys must use blake3");
476 }
477 Ok(self.base_url.join(&format!(
478 "v{PROTOCOL_VERSION}/action-results/{}/{}/{}",
479 action.algorithm, action.hash, action.size
480 ))?)
481 }
482
483 fn blob_endpoint(&self, digest: &CacheDigest) -> Result<Url> {
484 digest.validate()?;
485 Ok(self.base_url.join(&format!(
486 "v{PROTOCOL_VERSION}/blobs/{}/{}/{}",
487 digest.algorithm, digest.hash, digest.size
488 ))?)
489 }
490
491 fn action_manifest_endpoint(&self, key: &CacheDigest) -> Result<Url> {
492 key.validate()?;
493 if key.algorithm != "blake3" {
494 bail!("remote action manifest keys must use blake3");
495 }
496 Ok(self.base_url.join(&format!(
497 "v{PROTOCOL_VERSION}/action-manifests/{}/{}/{}",
498 key.algorithm, key.hash, key.size
499 ))?)
500 }
501
502 fn capabilities_endpoint(&self) -> Result<Url> {
503 Ok(self
504 .base_url
505 .join(&format!("v{PROTOCOL_VERSION}/capabilities"))?)
506 }
507
508 fn blob_pack_endpoint(&self) -> Result<Url> {
509 Ok(self
510 .base_url
511 .join(&format!("v{PROTOCOL_VERSION}/blobs:pack"))?)
512 }
513
514 async fn request(
515 &self,
516 method: reqwest::Method,
517 url: Url,
518 media_type: &'static str,
519 ) -> Result<reqwest::RequestBuilder> {
520 let request = self
521 .client
522 .request(method, url)
523 .header(PROTOCOL_HEADER, u16::from(PROTOCOL_VERSION))
524 .header(NAMESPACE_HEADER, &self.namespace)
525 .header(ACCEPT, media_type);
526 if let Some(authorization) = self.credential.authorization().await? {
527 Ok(request.header(AUTHORIZATION, authorization))
528 } else {
529 Ok(request)
530 }
531 }
532
533 async fn blob_pack_limits(&self) -> Result<Option<BlobPackLimits>> {
534 Ok(self.negotiated_capabilities().await?.blob_packs)
535 }
536
537 async fn negotiated_capabilities(&self) -> Result<NegotiatedCapabilities> {
538 self.capabilities
539 .get_or_try_init(|| async {
540 let url = self.capabilities_endpoint()?;
541 let response = self
542 .request(reqwest::Method::GET, url, "application/json")
543 .await?
544 .send()
545 .await?;
546 if matches!(
547 response.status(),
548 StatusCode::NOT_FOUND
549 | StatusCode::METHOD_NOT_ALLOWED
550 | StatusCode::NOT_IMPLEMENTED
551 ) {
552 return Ok(NegotiatedCapabilities::default());
553 }
554 let bytes =
555 read_bounded_json(response.error_for_status()?, "capabilities").await?;
556 let capabilities: RemoteCacheCapabilities = serde_json::from_slice(&bytes)?;
557 if capabilities.protocol.major != PROTOCOL_VERSION {
558 bail!(
559 "remote cache capability protocol {} is incompatible with client protocol {PROTOCOL_VERSION}",
560 capabilities.protocol.major
561 );
562 }
563 let zstd_uploads = capabilities
567 .compressors
568 .iter()
569 .any(|compressor| compressor == "zstd");
570 let blob_packs = if capabilities.features.blob_packs {
571 let max_items = usize::try_from(capabilities.limits.max_batch_items)
572 .ok()
573 .filter(|limit| *limit > 0)
574 .ok_or_else(|| {
575 eyre!(
576 "remote cache blob packs require a positive max_batch_items limit"
577 )
578 })?;
579 if capabilities.limits.max_pack_bytes == 0 {
580 bail!("remote cache blob packs require a positive max_pack_bytes limit");
581 }
582 Some(BlobPackLimits {
583 max_items: max_items.min(MAX_STAGED_BLOB_PACK_ITEMS),
584 max_bytes: capabilities
585 .limits
586 .max_pack_bytes
587 .min(MAX_STAGED_BLOB_PACK_BYTES),
588 })
589 } else {
590 None
591 };
592 Ok(NegotiatedCapabilities {
593 blob_packs,
594 zstd_uploads,
595 })
596 })
597 .await
598 .copied()
599 }
600
601 pub async fn get_blob_pack(
607 &self,
608 digests: &[CacheDigest],
609 staging_dir: &Path,
610 ) -> Result<Option<RemoteBlobPack>> {
611 if digests.is_empty() || self.blob_packs_disabled.load(Ordering::Relaxed) {
612 return Ok(None);
613 }
614 let Some(limits) = self.blob_pack_limits().await? else {
615 return Ok(None);
616 };
617 fs::create_dir_all(staging_dir)?;
618 let chunk = blob_pack_chunk(digests, limits)?;
619 if chunk.is_empty() {
620 return Ok(Some(RemoteBlobPack {
621 _directory: tempfile::tempdir_in(staging_dir)?,
622 blobs: Vec::new(),
623 requests: 0,
624 requested: Vec::new(),
625 blob_count: 0,
626 payload_bytes: 0,
627 framed_bytes: BLOB_PACK_MAGIC.len() as u64,
628 }));
629 }
630 match self.download_blob_pack_chunk(&chunk, staging_dir).await? {
631 Some(pack) => Ok(Some(RemoteBlobPack {
632 _directory: pack.directory,
633 blobs: pack.blobs,
634 requests: 1,
635 requested: chunk,
636 blob_count: pack.metadata.blob_count,
637 payload_bytes: pack.metadata.payload_bytes,
638 framed_bytes: pack.metadata.framed_bytes,
639 })),
640 None => {
641 self.blob_packs_disabled.store(true, Ordering::Relaxed);
642 Ok(None)
643 }
644 }
645 }
646
647 async fn download_blob_pack_chunk(
648 &self,
649 digests: &[CacheDigest],
650 staging_dir: &Path,
651 ) -> Result<Option<DownloadedBlobPack>> {
652 let url = self.blob_pack_endpoint()?;
653 let body = serde_json::to_vec(&DigestList { digests })?;
654 let download_timeout = blob_pack_download_timeout(self.download_timeout, digests);
655 let download = retry_async("POST", &url, self.retries, || async {
656 let response = self
657 .request(reqwest::Method::POST, url.clone(), BLOB_PACK_MEDIA_TYPE)
658 .await?
659 .header(CONTENT_TYPE, DIGEST_LIST_MEDIA_TYPE)
660 .body(body.clone())
661 .send()
662 .await?;
663 if matches!(
664 response.status(),
665 StatusCode::NOT_FOUND
666 | StatusCode::METHOD_NOT_ALLOWED
667 | StatusCode::NOT_IMPLEMENTED
668 ) {
669 return Ok(None);
670 }
671 let response = response.error_for_status()?;
672 let media_type = response
673 .headers()
674 .get(CONTENT_TYPE)
675 .and_then(|value| value.to_str().ok())
676 .and_then(|value| value.split(';').next())
677 .map(str::trim);
678 if media_type != Some(BLOB_PACK_MEDIA_TYPE) {
679 bail!("remote cache blob pack has an invalid content type");
680 }
681 Ok(Some(
682 decode_blob_pack(response, digests, staging_dir).await?,
683 ))
684 });
685 tokio::time::timeout(download_timeout, download)
686 .await
687 .map_err(|_| eyre!("remote cache blob pack download timed out for {url}"))?
688 }
689
690 pub async fn get_action_result(
691 &self,
692 action: &CacheDigest,
693 ) -> Result<Option<RemoteActionResult>> {
694 let url = self.action_result_endpoint(action)?;
695 let result = retry_async("GET", &url, self.retries, || async {
696 let response = self
697 .request(reqwest::Method::GET, url.clone(), ACTION_RESULT_MEDIA_TYPE)
698 .await?
699 .send()
700 .await?;
701 if response.status() == StatusCode::NOT_FOUND {
702 return Ok(None);
703 }
704 let bytes = read_bounded_json(response.error_for_status()?, "action result").await?;
705 Ok(Some(serde_json::from_slice::<RemoteActionResult>(&bytes)?))
706 })
707 .await?;
708 if let Some(result) = &result
709 && (result.version != 1 || result.action != *action)
710 {
711 bail!("remote action result does not match requested action");
712 }
713 Ok(result)
714 }
715
716 pub async fn put_action_result(&self, result: &RemoteActionResult) -> Result<()> {
717 let url = self.action_result_endpoint(&result.action)?;
718 let body = serde_json::to_vec(result)?;
719 retry_async("PUT", &url, self.retries, || async {
720 let response = self
721 .request(reqwest::Method::PUT, url.clone(), ACTION_RESULT_MEDIA_TYPE)
722 .await?
723 .header(CONTENT_TYPE, ACTION_RESULT_MEDIA_TYPE)
724 .header(IF_NONE_MATCH, "*")
725 .body(body.clone())
726 .send()
727 .await?;
728 if response.status() != StatusCode::PRECONDITION_FAILED {
729 response.error_for_status()?;
730 }
731 Ok(())
732 })
733 .await
734 }
735
736 pub async fn get_action_manifest(
737 &self,
738 key: &CacheDigest,
739 ) -> Result<Option<RemoteActionManifest>> {
740 let url = self.action_manifest_endpoint(key)?;
741 retry_async("GET", &url, self.retries, || async {
742 let response = self
743 .request(
744 reqwest::Method::GET,
745 url.clone(),
746 TASK_ACTION_MANIFEST_MEDIA_TYPE,
747 )
748 .await?
749 .send()
750 .await?;
751 if response.status() == StatusCode::NOT_FOUND {
752 return Ok(None);
753 }
754 let response = response.error_for_status()?;
755 let etag = parse_strong_etag(response.headers().get(ETAG))?;
756 let bytes = read_bounded_json(response, "action manifest").await?;
757 if blake3::hash(&bytes).to_hex().as_str() != etag {
758 bail!("remote action manifest ETag does not match its body");
759 }
760 Ok(Some(RemoteActionManifest { bytes, etag }))
761 })
762 .await
763 }
764
765 pub async fn put_action_manifest(
766 &self,
767 key: &CacheDigest,
768 bytes: &[u8],
769 expected_etag: Option<&str>,
770 ) -> Result<ManifestPutOutcome> {
771 let url = self.action_manifest_endpoint(key)?;
772 let body = bytes.to_vec();
773 let expected_etag = expected_etag.map(quoted_etag).transpose()?;
774 retry_async("PUT", &url, self.retries, || async {
775 let mut request = self
776 .request(
777 reqwest::Method::PUT,
778 url.clone(),
779 TASK_ACTION_MANIFEST_MEDIA_TYPE,
780 )
781 .await?
782 .header(CONTENT_TYPE, TASK_ACTION_MANIFEST_MEDIA_TYPE)
783 .body(body.clone());
784 request = if let Some(etag) = &expected_etag {
785 request.header(IF_MATCH, etag)
786 } else {
787 request.header(IF_NONE_MATCH, "*")
788 };
789 let response = request.send().await?;
790 if response.status() == StatusCode::PRECONDITION_FAILED {
791 return Ok(ManifestPutOutcome::PreconditionFailed);
792 }
793 response.error_for_status()?;
794 Ok(ManifestPutOutcome::Stored)
795 })
796 .await
797 }
798
799 pub async fn get_blob(
800 &self,
801 digest: &CacheDigest,
802 media_type: &'static str,
803 ) -> Result<Vec<u8>> {
804 digest.validate()?;
805 let url = self.blob_endpoint(digest)?;
806 retry_async("GET", &url, self.retries, || async {
807 let mut response = self
808 .request(reqwest::Method::GET, url.clone(), media_type)
809 .await?
810 .send()
811 .await?
812 .error_for_status()?;
813 let mut bytes = Vec::new();
817 while let Some(chunk) = response.chunk().await? {
818 if bytes.len() as u64 + chunk.len() as u64 > digest.size {
819 bail!("remote cache blob exceeded the size of its digest");
820 }
821 bytes.extend_from_slice(&chunk);
822 }
823 if !digest.matches_bytes(&bytes)? {
824 bail!("remote cache blob failed digest verification");
825 }
826 Ok(bytes)
827 })
828 .await
829 }
830
831 pub async fn get_blob_file(
832 &self,
833 digest: &CacheDigest,
834 staging_dir: &Path,
835 ) -> Result<tempfile::NamedTempFile> {
836 let url = self.blob_endpoint(digest)?;
837 let download = retry_async("GET", &url, self.retries, || async {
838 let mut response = self
839 .request(reqwest::Method::GET, url.clone(), BLOB_MEDIA_TYPE)
840 .await?
841 .send()
842 .await?;
843 response.error_for_status_ref()?;
844 fs::create_dir_all(staging_dir)?;
845 let temporary = tempfile::NamedTempFile::new_in(staging_dir)?;
846 let mut output = tokio::fs::File::from_std(temporary.reopen()?);
847 let mut written = 0u64;
850 while let Some(chunk) = response.chunk().await? {
851 written += chunk.len() as u64;
852 if written > digest.size {
853 bail!("remote cache blob exceeded the size of its digest");
854 }
855 output.write_all(&chunk).await?;
856 }
857 output.flush().await?;
858 drop(output);
859 if !digest.matches_file(temporary.path())? {
860 bail!("remote cache blob failed digest verification");
861 }
862 Ok(temporary)
863 });
864 tokio::time::timeout(self.download_timeout, download)
865 .await
866 .map_err(|_| eyre!("remote cache blob download timed out for {url}"))?
867 }
868
869 pub async fn put_blob(&self, upload: &BlobUpload) -> Result<()> {
870 let url = self.blob_endpoint(&upload.digest)?;
871 let compress = self
874 .negotiated_capabilities()
875 .await
876 .map(|capabilities| capabilities.zstd_uploads)
877 .unwrap_or(false);
878 retry_async("PUT", &url, self.retries, || async {
879 let request = self
880 .request(reqwest::Method::PUT, url.clone(), BLOB_MEDIA_TYPE)
881 .await?
882 .header(CONTENT_TYPE, BLOB_MEDIA_TYPE)
883 .header(IF_NONE_MATCH, "*");
884 let request = if compress {
885 let reader: Box<dyn tokio::io::AsyncRead + Send + Sync + Unpin> = match &upload
889 .source
890 {
891 BlobSource::Bytes(bytes) => Box::new(std::io::Cursor::new(bytes.clone())),
892 BlobSource::File(file) => Box::new(tokio::fs::File::open(file.path()).await?),
893 BlobSource::Path(path) => Box::new(tokio::fs::File::open(path).await?),
894 };
895 let encoder = async_compression::tokio::bufread::ZstdEncoder::new(
896 tokio::io::BufReader::new(reader),
897 );
898 request
899 .header(CONTENT_ENCODING, "zstd")
900 .body(reqwest::Body::wrap_stream(
901 tokio_util::io::ReaderStream::new(encoder),
902 ))
903 } else {
904 let (length, body) = match &upload.source {
905 BlobSource::Bytes(bytes) => {
906 (bytes.len() as u64, reqwest::Body::from(bytes.clone()))
907 }
908 BlobSource::File(file) => {
909 let file = tokio::fs::File::open(file.path()).await?;
910 let length = file.metadata().await?.len();
911 let stream = tokio_util::io::ReaderStream::new(file);
912 (length, reqwest::Body::wrap_stream(stream))
913 }
914 BlobSource::Path(path) => {
915 let file = tokio::fs::File::open(path).await?;
916 let length = file.metadata().await?.len();
917 let stream = tokio_util::io::ReaderStream::new(file);
918 (length, reqwest::Body::wrap_stream(stream))
919 }
920 };
921 request.header(CONTENT_LENGTH, length).body(body)
922 };
923 let response = request.send().await?;
924 if response.status() != StatusCode::PRECONDITION_FAILED {
925 response.error_for_status()?;
926 }
927 Ok(())
928 })
929 .await
930 }
931}
932
933fn blob_pack_chunk(digests: &[CacheDigest], limits: BlobPackLimits) -> Result<Vec<CacheDigest>> {
934 let mut seen = BTreeSet::new();
935 let mut chunk = Vec::new();
936 let mut chunk_bytes = 0_u64;
937 for digest in digests {
938 digest.validate()?;
939 if !seen.insert(digest.clone()) || digest.size > limits.max_bytes {
940 continue;
941 }
942 if chunk.len() == limits.max_items
943 || chunk_bytes.saturating_add(digest.size) > limits.max_bytes
944 {
945 break;
946 }
947 chunk_bytes = chunk_bytes.saturating_add(digest.size);
948 chunk.push(digest.clone());
949 }
950 Ok(chunk)
951}
952
953fn blob_pack_download_timeout(base: Duration, digests: &[CacheDigest]) -> Duration {
954 let bytes = digests
955 .iter()
956 .fold(0_u64, |total, digest| total.saturating_add(digest.size));
957 let byte_units = bytes.div_ceil(BLOB_PACK_TIMEOUT_BYTES_PER_UNIT);
958 let item_units = digests.len().div_ceil(BLOB_PACK_TIMEOUT_ITEMS_PER_UNIT);
959 let item_units = u64::try_from(item_units).unwrap_or(u64::MAX);
960 let multiplier = byte_units.max(item_units).max(1);
961 base.saturating_mul(u32::try_from(multiplier).unwrap_or(u32::MAX))
962}
963
964async fn read_bounded_json(response: reqwest::Response, what: &str) -> Result<Vec<u8>> {
969 if let Some(length) = response.content_length()
970 && length > MAX_REMOTE_JSON_BYTES
971 {
972 bail!(
973 "remote cache {what} declared {length} bytes, over the {MAX_REMOTE_JSON_BYTES} byte limit"
974 );
975 }
976 let mut response = response;
977 let mut bytes = Vec::new();
978 while let Some(chunk) = response.chunk().await? {
979 if bytes.len() as u64 + chunk.len() as u64 > MAX_REMOTE_JSON_BYTES {
980 bail!("remote cache {what} exceeded the {MAX_REMOTE_JSON_BYTES} byte limit");
981 }
982 bytes.extend_from_slice(&chunk);
983 }
984 Ok(bytes)
985}
986
987async fn decode_blob_pack(
988 response: reqwest::Response,
989 requested: &[CacheDigest],
990 staging_dir: &Path,
991) -> Result<DownloadedBlobPack> {
992 let metadata = BlobPackResponseMetadata::from_headers(response.headers())?;
993 let requested = requested.iter().cloned().collect::<BTreeSet<_>>();
994 let stream = response.bytes_stream().map_err(std::io::Error::other);
995 let mut reader = tokio_util::io::StreamReader::new(stream);
996 let mut magic = [0_u8; BLOB_PACK_MAGIC.len()];
997 reader.read_exact(&mut magic).await?;
998 if &magic != BLOB_PACK_MAGIC {
999 bail!("remote cache blob pack has invalid magic");
1000 }
1001
1002 let directory = tempfile::tempdir_in(staging_dir)?;
1003 let mut seen = BTreeSet::new();
1004 let mut blobs = Vec::new();
1005 let mut payload_bytes = 0_u64;
1006 let mut framed_bytes = BLOB_PACK_MAGIC.len() as u64;
1007 loop {
1008 let mut algorithm = [0_u8; 1];
1009 if reader.read(&mut algorithm).await? == 0 {
1010 break;
1011 }
1012 let (algorithm, mut hasher) = match algorithm[0] {
1013 1 => (
1014 "blake3",
1015 BlobPackHasher::Blake3(Box::new(blake3::Hasher::new())),
1016 ),
1017 2 => ("sha256", BlobPackHasher::Sha256(sha2::Sha256::new())),
1018 _ => bail!("remote cache blob pack has an invalid digest algorithm"),
1019 };
1020 let mut hash = [0_u8; 32];
1021 reader.read_exact(&mut hash).await?;
1022 let mut size = [0_u8; 8];
1023 reader.read_exact(&mut size).await?;
1024 let digest = CacheDigest {
1025 algorithm: algorithm.into(),
1026 hash: hex::encode(hash),
1027 size: u64::from_be_bytes(size),
1028 };
1029 if !requested.contains(&digest) {
1030 bail!("remote cache blob pack returned an unrequested digest");
1031 }
1032 if !seen.insert(digest.clone()) {
1033 bail!("remote cache blob pack returned a duplicate digest");
1034 }
1035 framed_bytes = framed_bytes
1036 .checked_add(BLOB_PACK_HEADER_BYTES)
1037 .and_then(|bytes| bytes.checked_add(digest.size))
1038 .ok_or_else(|| eyre!("remote cache blob pack is too large"))?;
1039 payload_bytes = payload_bytes
1040 .checked_add(digest.size)
1041 .ok_or_else(|| eyre!("remote cache blob pack payload is too large"))?;
1042
1043 let path = directory.path().join(blobs.len().to_string());
1044 let mut output = tokio::fs::File::create(&path).await?;
1045 let mut remaining = digest.size;
1046 let mut buffer = [0_u8; 64 * 1024];
1047 while remaining > 0 {
1048 let limit = usize::try_from(remaining.min(buffer.len() as u64)).unwrap();
1049 let count = reader.read(&mut buffer[..limit]).await?;
1050 if count == 0 {
1051 bail!("remote cache blob pack ended before a blob was complete");
1052 }
1053 output.write_all(&buffer[..count]).await?;
1054 hasher.update(&buffer[..count]);
1055 remaining -= count as u64;
1056 }
1057 output.flush().await?;
1058 drop(output);
1059 if !hasher.matches(&digest.hash) {
1060 bail!("remote cache blob pack failed digest verification");
1061 }
1062 blobs.push((digest, path));
1063 }
1064 let blob_count = blobs.len().try_into().unwrap_or(u64::MAX);
1065 let metadata = metadata.validate(BlobPackResponseStats {
1066 blob_count,
1067 payload_bytes,
1068 framed_bytes,
1069 })?;
1070 Ok(DownloadedBlobPack {
1071 directory,
1072 blobs,
1073 metadata,
1074 })
1075}
1076
1077enum BlobPackHasher {
1078 Blake3(Box<blake3::Hasher>),
1079 Sha256(sha2::Sha256),
1080}
1081
1082impl BlobPackHasher {
1083 fn update(&mut self, bytes: &[u8]) {
1084 match self {
1085 Self::Blake3(hasher) => {
1086 hasher.update(bytes);
1087 }
1088 Self::Sha256(hasher) => {
1089 hasher.update(bytes);
1090 }
1091 }
1092 }
1093
1094 fn matches(self, expected: &str) -> bool {
1095 match self {
1096 Self::Blake3(hasher) => hasher.finalize().to_hex().as_str() == expected,
1097 Self::Sha256(hasher) => hex::encode(hasher.finalize()) == expected,
1098 }
1099 }
1100}
1101
1102fn parse_strong_etag(value: Option<&HeaderValue>) -> Result<String> {
1103 let value = value
1104 .and_then(|value| value.to_str().ok())
1105 .ok_or_else(|| eyre!("remote action manifest response is missing an ETag"))?;
1106 let etag = value
1107 .strip_prefix('"')
1108 .and_then(|value| value.strip_suffix('"'))
1109 .filter(|value| is_lower_hex_digest(value))
1110 .ok_or_else(|| eyre!("remote action manifest response has an invalid ETag"))?;
1111 Ok(etag.to_owned())
1112}
1113
1114fn quoted_etag(etag: &str) -> Result<HeaderValue> {
1115 if !is_lower_hex_digest(etag) {
1116 bail!("invalid remote action manifest ETag");
1117 }
1118 Ok(HeaderValue::from_str(&format!("\"{etag}\""))?)
1119}
1120
1121fn is_lower_hex_digest(value: &str) -> bool {
1122 value.len() == 64
1123 && value
1124 .bytes()
1125 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
1126}
1127
1128#[derive(Clone)]
1129enum RemoteCacheCredential {
1130 None,
1131 Static(HeaderValue),
1132 File(PathBuf),
1133 GithubActions(Arc<GithubActionsOidcCredential>),
1134}
1135
1136struct GithubActionsOidcCredential {
1137 audience: String,
1138 request_url: Url,
1139 request_token: HeaderValue,
1140 client: reqwest::Client,
1141 retries: i64,
1142 cached: tokio::sync::Mutex<Option<CachedOidcToken>>,
1143}
1144
1145struct CachedOidcToken {
1146 authorization: HeaderValue,
1147 expires_at: u64,
1148}
1149
1150#[derive(Deserialize)]
1151struct GithubActionsOidcResponse {
1152 value: String,
1153}
1154
1155#[derive(Deserialize)]
1156struct JwtExpiry {
1157 exp: u64,
1158}
1159
1160fn remote_credential(
1161 config: &RemoteCacheConfig,
1162 client: reqwest::Client,
1163) -> Result<RemoteCacheCredential> {
1164 if let Some(authorization) = authorization_header(config.token.as_deref())? {
1165 return Ok(RemoteCacheCredential::Static(authorization));
1166 }
1167 if let Some(path) = &config.token_file {
1168 return Ok(RemoteCacheCredential::File(path.clone()));
1169 }
1170 let Some(audience) = config
1171 .oidc_audience
1172 .as_deref()
1173 .map(str::trim)
1174 .filter(|audience| !audience.is_empty())
1175 else {
1176 return Ok(RemoteCacheCredential::None);
1177 };
1178 Ok(RemoteCacheCredential::GithubActions(Arc::new(
1179 GithubActionsOidcCredential::from_env(audience, client, config.retries)?,
1180 )))
1181}
1182
1183fn authorization_header(token: Option<&str>) -> Result<Option<HeaderValue>> {
1184 let Some(token) = token.map(str::trim).filter(|token| !token.is_empty()) else {
1185 return Ok(None);
1186 };
1187 let mut value = HeaderValue::from_str(&format!("Bearer {token}"))?;
1188 value.set_sensitive(true);
1189 Ok(Some(value))
1190}
1191
1192impl RemoteCacheCredential {
1193 async fn authorization(&self) -> Result<Option<HeaderValue>> {
1194 match self {
1195 Self::None => Ok(None),
1196 Self::Static(value) => Ok(Some(value.clone())),
1197 Self::File(path) => {
1198 let token = tokio::fs::read_to_string(path).await.map_err(|err| {
1199 eyre!(
1200 "failed to read remote cache token file {}: {err}",
1201 path.display()
1202 )
1203 })?;
1204 authorization_header(Some(&token))?
1205 .ok_or_else(|| eyre!("remote cache token file {} is empty", path.display()))
1206 .map(Some)
1207 }
1208 Self::GithubActions(credential) => credential.authorization().await.map(Some),
1209 }
1210 }
1211}
1212
1213impl GithubActionsOidcCredential {
1214 fn from_env(audience: &str, client: reqwest::Client, retries: i64) -> Result<Self> {
1215 let request_url = std::env::var("ACTIONS_ID_TOKEN_REQUEST_URL").map_err(|_| {
1216 eyre!(
1217 "remote cache OIDC audience requires GitHub Actions OIDC; \
1218 grant `id-token: write` or set MBX_REMOTE_TOKEN"
1219 )
1220 })?;
1221 let request_token = std::env::var("ACTIONS_ID_TOKEN_REQUEST_TOKEN").map_err(|_| {
1222 eyre!(
1223 "remote cache OIDC audience requires GitHub Actions OIDC; \
1224 ACTIONS_ID_TOKEN_REQUEST_TOKEN is missing"
1225 )
1226 })?;
1227 let request_url: Url = request_url
1228 .parse()
1229 .map_err(|err| eyre!("invalid GitHub Actions OIDC request URL: {err}"))?;
1230 Self::new(audience, request_url, &request_token, client, retries)
1231 }
1232
1233 fn new(
1234 audience: &str,
1235 mut request_url: Url,
1236 request_token: &str,
1237 client: reqwest::Client,
1238 retries: i64,
1239 ) -> Result<Self> {
1240 validate_oidc_request_url(&request_url)?;
1241 let query = request_url
1242 .query_pairs()
1243 .filter(|(key, _)| key != "audience")
1244 .map(|(key, value)| (key.into_owned(), value.into_owned()))
1245 .collect::<Vec<_>>();
1246 request_url.set_query(None);
1247 request_url
1248 .query_pairs_mut()
1249 .extend_pairs(query)
1250 .append_pair("audience", audience);
1251 let request_token = authorization_header(Some(request_token))?
1252 .ok_or_else(|| eyre!("GitHub Actions OIDC request token is empty"))?;
1253 Ok(Self {
1254 audience: audience.to_string(),
1255 request_url,
1256 request_token,
1257 client,
1258 retries,
1259 cached: tokio::sync::Mutex::new(None),
1260 })
1261 }
1262
1263 async fn authorization(&self) -> Result<HeaderValue> {
1264 const REFRESH_LEEWAY_SECONDS: u64 = 60;
1265 let mut cached = self.cached.lock().await;
1266 let now = unix_timestamp()?;
1267 if let Some(token) = cached.as_ref()
1268 && token.expires_at > now.saturating_add(REFRESH_LEEWAY_SECONDS)
1269 {
1270 return Ok(token.authorization.clone());
1271 }
1272 let response: GithubActionsOidcResponse =
1273 retry_async("GET", &self.request_url, self.retries, || async {
1274 Ok(self
1275 .client
1276 .get(self.request_url.clone())
1277 .header(AUTHORIZATION, self.request_token.clone())
1278 .send()
1279 .await?
1280 .error_for_status()?
1281 .json()
1282 .await?)
1283 })
1284 .await
1285 .map_err(|err| {
1286 eyre!(
1287 "failed to acquire GitHub Actions OIDC token for audience {:?}: {err}",
1288 self.audience
1289 )
1290 })?;
1291 let expires_at = jwt_expiry(&response.value)?;
1292 if expires_at <= now.saturating_add(REFRESH_LEEWAY_SECONDS) {
1293 bail!("GitHub Actions OIDC token expires too soon");
1294 }
1295 let authorization = authorization_header(Some(&response.value))?
1296 .ok_or_else(|| eyre!("GitHub Actions returned an empty OIDC token"))?;
1297 *cached = Some(CachedOidcToken {
1298 authorization: authorization.clone(),
1299 expires_at,
1300 });
1301 Ok(authorization)
1302 }
1303}
1304
1305fn jwt_expiry(token: &str) -> Result<u64> {
1306 let payload = token
1307 .split('.')
1308 .nth(1)
1309 .ok_or_else(|| eyre!("GitHub Actions returned a malformed OIDC token"))?;
1310 let payload = URL_SAFE_NO_PAD
1311 .decode(payload)
1312 .map_err(|_| eyre!("GitHub Actions returned a malformed OIDC token"))?;
1313 let claims: JwtExpiry = serde_json::from_slice(&payload)
1314 .map_err(|_| eyre!("GitHub Actions OIDC token is missing a valid expiry"))?;
1315 Ok(claims.exp)
1316}
1317
1318fn unix_timestamp() -> Result<u64> {
1319 Ok(SystemTime::now()
1320 .duration_since(UNIX_EPOCH)
1321 .map_err(|err| eyre!("system clock is before the Unix epoch: {err}"))?
1322 .as_secs())
1323}
1324
1325fn validate_oidc_request_url(url: &Url) -> Result<()> {
1326 if url.scheme() == "https"
1327 || url.scheme() == "http"
1328 && url.host().is_some_and(|host| match host {
1329 Host::Domain(host) => host.eq_ignore_ascii_case("localhost"),
1330 Host::Ipv4(address) => address.is_loopback(),
1331 Host::Ipv6(address) => address.is_loopback(),
1332 })
1333 {
1334 Ok(())
1335 } else {
1336 bail!("GitHub Actions OIDC request URL must use HTTPS")
1337 }
1338}
1339
1340fn validate_remote_url(base_url: &Url, authenticated: bool) -> Result<()> {
1341 if base_url.scheme() == "https" {
1342 return Ok(());
1343 }
1344 if base_url.scheme() != "http" {
1345 bail!("remote cache URL must use HTTPS");
1346 }
1347 let is_loopback = base_url.host().is_some_and(|host| match host {
1348 Host::Domain(host) => host.eq_ignore_ascii_case("localhost"),
1349 Host::Ipv4(address) => address.is_loopback(),
1350 Host::Ipv6(address) => address.is_loopback(),
1351 });
1352 if !is_loopback && authenticated {
1353 bail!("remote cache URL must use HTTPS except for loopback development servers");
1354 }
1355 if !is_loopback {
1356 warn!(
1357 "using an unauthenticated remote build cache over plain HTTP; cache traffic can be read \
1358 or modified in transit"
1359 );
1360 }
1361 Ok(())
1362}
1363
1364fn normalized_base_url(mut url: Url) -> Url {
1365 if !url.path().ends_with('/') {
1366 url.set_path(&format!("{}/", url.path()));
1367 }
1368 url
1369}
1370
1371fn retry_delays(retries: i64) -> impl Iterator<Item = Duration> {
1372 [200u64, 1_000, 4_000, 15_000]
1373 .into_iter()
1374 .chain(std::iter::repeat(15_000))
1375 .map(Duration::from_millis)
1376 .map(|duration| {
1377 let factor = 0.5 + rand::random::<f64>() * 0.5;
1378 Duration::from_secs_f64(duration.as_secs_f64() * factor)
1379 })
1380 .take(retries.max(0) as usize)
1381}
1382
1383fn is_dns_error(error: &(dyn std::error::Error + 'static)) -> bool {
1387 let mut current = Some(error);
1388 while let Some(source) = current {
1389 if source.to_string() == "dns error" {
1390 return true;
1391 }
1392 current = source.source();
1393 }
1394 false
1395}
1396
1397fn is_transient(error: &eyre::Report) -> bool {
1398 if is_dns_error(error.as_ref()) {
1401 return false;
1402 }
1403 error.chain().any(|source| {
1404 let Some(error) = source.downcast_ref::<reqwest::Error>() else {
1405 return false;
1406 };
1407 if error.is_timeout() || error.is_connect() || error.is_body() {
1408 return true;
1409 }
1410 error.status().is_some_and(|status| {
1411 let status = status.as_u16();
1412 status == 408 || status == 429 || (500..600).contains(&status)
1413 })
1414 })
1415}
1416
1417async fn retry_async<F, Fut, T>(verb: &str, url: &Url, retries: i64, mut operation: F) -> Result<T>
1418where
1419 F: FnMut() -> Fut,
1420 Fut: std::future::Future<Output = Result<T>>,
1421{
1422 let mut delays = retry_delays(retries);
1423 let mut attempt = 1;
1424 loop {
1425 let started_at = Instant::now();
1426 match operation().await {
1427 Ok(value) => return Ok(value),
1428 Err(error) if is_transient(&error) => {
1429 let Some(delay) = delays.next() else {
1430 return Err(error);
1431 };
1432 warn!(
1433 "HTTP {verb} {url} attempt {attempt} failed after {:?} (transient): {error}; retrying in {delay:?}",
1434 started_at.elapsed()
1435 );
1436 tokio::time::sleep(delay).await;
1437 attempt += 1;
1438 }
1439 Err(error) => return Err(error),
1440 }
1441 }
1442}
1443
1444#[cfg(test)]
1445mod tests {
1446 use super::*;
1447
1448 #[test]
1449 fn protocol_json_uses_jcs_key_and_number_encoding() {
1450 let value = serde_json::json!({"z": 1.0e30, "a": {"d": true, "c": null}});
1451 assert_eq!(
1452 canonical_json(&value).unwrap(),
1453 br#"{"a":{"c":null,"d":true},"z":1e+30}"#
1454 );
1455 }
1456
1457 #[test]
1458 fn dns_errors_are_not_transient() {
1459 #[derive(Debug)]
1460 struct DnsError;
1461
1462 impl std::fmt::Display for DnsError {
1463 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1464 formatter.write_str("dns error")
1465 }
1466 }
1467
1468 impl std::error::Error for DnsError {}
1469
1470 let error = eyre::Report::new(DnsError);
1471 assert!(is_dns_error(error.as_ref()));
1472 assert!(!is_transient(&error));
1473 }
1474
1475 #[test]
1476 fn cache_digest_verifies_its_declared_algorithm() {
1477 let bytes = b"remote cache blob";
1478 let sha256 = CacheDigest {
1479 algorithm: "sha256".into(),
1480 hash: hex::encode(sha2::Sha256::digest(bytes)),
1481 size: bytes.len() as u64,
1482 };
1483 assert!(sha256.matches_bytes(bytes).unwrap());
1484 assert!(!sha256.matches_bytes(b"different").unwrap());
1485
1486 let file = tempfile::NamedTempFile::new().unwrap();
1487 fs::write(file.path(), bytes).unwrap();
1488 assert!(sha256.matches_file(file.path()).unwrap());
1489 assert_eq!(
1490 CacheDigest::blake3_file(file.path()).unwrap().size,
1491 bytes.len() as u64
1492 );
1493 assert!(
1494 CacheDigest::blake3_file(file.path())
1495 .unwrap()
1496 .matches_bytes(bytes)
1497 .unwrap()
1498 );
1499 }
1500
1501 #[test]
1502 fn action_result_keys_require_blake3() {
1503 let client = RemoteCacheClient::new(RemoteCacheConfig {
1504 base_url: "http://127.0.0.1:1".parse().unwrap(),
1505 namespace: "test".into(),
1506 token: None,
1507 token_file: None,
1508 oidc_audience: None,
1509 connect_timeout: Duration::from_secs(1),
1510 read_timeout: Duration::from_secs(1),
1511 download_timeout: Duration::from_secs(1),
1512 retries: 0,
1513 })
1514 .unwrap();
1515 let action = CacheDigest {
1516 algorithm: "sha256".into(),
1517 hash: "0".repeat(64),
1518 size: 0,
1519 };
1520
1521 assert!(
1522 client
1523 .action_result_endpoint(&action)
1524 .unwrap_err()
1525 .to_string()
1526 .contains("must use blake3")
1527 );
1528 }
1529
1530 #[tokio::test]
1531 async fn downloads_negotiated_blob_packs_and_omits_missing_objects() {
1532 let mut server = mockito::Server::new_async().await;
1533 let first_bytes = b"first packed blob";
1534 let second_bytes = b"second packed blob";
1535 let first = CacheDigest::blake3(first_bytes);
1536 let second = CacheDigest::blake3(second_bytes);
1537 let missing = CacheDigest::blake3(b"missing packed blob");
1538 let capabilities = server
1539 .mock("GET", "/v1/capabilities")
1540 .match_header(PROTOCOL_HEADER, "1")
1541 .match_header(AUTHORIZATION.as_str(), "Bearer test-token")
1542 .with_status(200)
1543 .with_header("content-type", "application/json")
1544 .with_body(
1545 serde_json::json!({
1546 "protocol":{"major":1},
1547 "features":{"blob_packs":true},
1548 "limits":{"max_batch_items":100,"max_pack_bytes":1024}
1549 })
1550 .to_string(),
1551 )
1552 .expect(1)
1553 .create_async()
1554 .await;
1555 let packed = encode_blob_pack(&[
1556 (&first, first_bytes.as_slice()),
1557 (&second, second_bytes.as_slice()),
1558 ]);
1559 let packed_len = packed.len().to_string();
1560 let packed_blobs = 2.to_string();
1561 let packed_payload_bytes = (first.size + second.size).to_string();
1562 let request = server
1563 .mock("POST", "/v1/blobs:pack")
1564 .match_header(PROTOCOL_HEADER, "1")
1565 .match_header(NAMESPACE_HEADER, "test")
1566 .match_header("content-type", DIGEST_LIST_MEDIA_TYPE)
1567 .with_status(200)
1568 .with_header("content-type", BLOB_PACK_MEDIA_TYPE)
1569 .with_header("content-length", &packed_len)
1570 .with_header(BLOB_PACK_BLOBS_HEADER, &packed_blobs)
1571 .with_header(BLOB_PACK_BYTES_HEADER, &packed_payload_bytes)
1572 .with_body(packed)
1573 .expect(1)
1574 .create_async()
1575 .await;
1576 let client = test_client(&server);
1577 let staging = tempfile::tempdir().unwrap();
1578
1579 let pack = client
1580 .get_blob_pack(
1581 &[first.clone(), missing, second.clone(), first.clone()],
1582 staging.path(),
1583 )
1584 .await
1585 .unwrap()
1586 .unwrap();
1587
1588 assert_eq!(pack.requests, 1);
1589 assert_eq!(pack.blob_count, 2);
1590 assert_eq!(pack.payload_bytes, first.size + second.size);
1591 assert_eq!(
1592 pack.framed_bytes,
1593 BLOB_PACK_MAGIC.len() as u64 + 2 * BLOB_PACK_HEADER_BYTES + first.size + second.size
1594 );
1595 assert_eq!(pack.blobs.len(), 2);
1596 assert_eq!(fs::read(&pack.blobs[0].1).unwrap(), first_bytes);
1597 assert_eq!(fs::read(&pack.blobs[1].1).unwrap(), second_bytes);
1598 capabilities.assert_async().await;
1599 request.assert_async().await;
1600 }
1601
1602 #[tokio::test]
1603 async fn rejects_mismatched_blob_pack_metadata() {
1604 let mut server = mockito::Server::new_async().await;
1605 let contents = b"packed blob";
1606 let digest = CacheDigest::blake3(contents);
1607 mock_blob_pack_capabilities(&mut server).await;
1608 server
1609 .mock("POST", "/v1/blobs:pack")
1610 .with_status(200)
1611 .with_header("content-type", BLOB_PACK_MEDIA_TYPE)
1612 .with_header(BLOB_PACK_BLOBS_HEADER, "2")
1613 .with_body(encode_blob_pack(&[(&digest, contents.as_slice())]))
1614 .create_async()
1615 .await;
1616 let client = test_client(&server);
1617 let staging = tempfile::tempdir().unwrap();
1618
1619 let error = client
1620 .get_blob_pack(&[digest], staging.path())
1621 .await
1622 .err()
1623 .unwrap();
1624
1625 assert!(error.to_string().contains("blob count metadata mismatch"));
1626 }
1627
1628 #[tokio::test]
1629 async fn rejects_malformed_blob_pack_metadata() {
1630 let mut server = mockito::Server::new_async().await;
1631 let contents = b"packed blob";
1632 let digest = CacheDigest::blake3(contents);
1633 mock_blob_pack_capabilities(&mut server).await;
1634 server
1635 .mock("POST", "/v1/blobs:pack")
1636 .with_status(200)
1637 .with_header("content-type", BLOB_PACK_MEDIA_TYPE)
1638 .with_header(BLOB_PACK_BYTES_HEADER, "not-a-number")
1639 .with_body(encode_blob_pack(&[(&digest, contents.as_slice())]))
1640 .create_async()
1641 .await;
1642 let client = test_client(&server);
1643 let staging = tempfile::tempdir().unwrap();
1644
1645 let error = client
1646 .get_blob_pack(&[digest], staging.path())
1647 .await
1648 .err()
1649 .unwrap();
1650
1651 assert!(error.to_string().contains("not an unsigned integer"));
1652 }
1653
1654 #[tokio::test]
1655 async fn rejects_unrequested_blob_pack_frames() {
1656 let mut server = mockito::Server::new_async().await;
1657 let requested = CacheDigest::blake3(b"requested");
1658 let injected_bytes = b"not requested";
1659 let injected = CacheDigest::blake3(injected_bytes);
1660 server
1661 .mock("GET", "/v1/capabilities")
1662 .with_status(200)
1663 .with_header("content-type", "application/json")
1664 .with_body(
1665 serde_json::json!({
1666 "protocol":{"major":1},
1667 "features":{"blob_packs":true},
1668 "limits":{"max_batch_items":100,"max_pack_bytes":1024}
1669 })
1670 .to_string(),
1671 )
1672 .create_async()
1673 .await;
1674 server
1675 .mock("POST", "/v1/blobs:pack")
1676 .with_status(200)
1677 .with_header("content-type", BLOB_PACK_MEDIA_TYPE)
1678 .with_body(encode_blob_pack(&[(&injected, injected_bytes.as_slice())]))
1679 .create_async()
1680 .await;
1681 let client = test_client(&server);
1682 let staging = tempfile::tempdir().unwrap();
1683
1684 let error = client
1685 .get_blob_pack(&[requested], staging.path())
1686 .await
1687 .err()
1688 .unwrap();
1689
1690 assert!(error.to_string().contains("unrequested digest"));
1691 }
1692
1693 #[tokio::test]
1694 async fn falls_back_when_blob_packs_are_not_advertised() {
1695 let mut server = mockito::Server::new_async().await;
1696 let capabilities = server
1697 .mock("GET", "/v1/capabilities")
1698 .with_status(404)
1699 .expect(1)
1700 .create_async()
1701 .await;
1702 let client = test_client(&server);
1703 let staging = tempfile::tempdir().unwrap();
1704
1705 assert!(
1706 client
1707 .get_blob_pack(&[CacheDigest::blake3(b"blob")], staging.path())
1708 .await
1709 .unwrap()
1710 .is_none()
1711 );
1712 capabilities.assert_async().await;
1713 }
1714
1715 #[tokio::test]
1716 async fn disables_blob_packs_when_the_advertised_endpoint_is_unavailable() {
1717 let mut server = mockito::Server::new_async().await;
1718 let capabilities = server
1719 .mock("GET", "/v1/capabilities")
1720 .with_status(200)
1721 .with_header("content-type", "application/json")
1722 .with_body(
1723 serde_json::json!({
1724 "protocol":{"major":1},
1725 "features":{"blob_packs":true},
1726 "limits":{"max_batch_items":100,"max_pack_bytes":1024}
1727 })
1728 .to_string(),
1729 )
1730 .expect(1)
1731 .create_async()
1732 .await;
1733 let request = server
1734 .mock("POST", "/v1/blobs:pack")
1735 .with_status(404)
1736 .expect(1)
1737 .create_async()
1738 .await;
1739 let client = test_client(&server);
1740 let staging = tempfile::tempdir().unwrap();
1741 let digest = CacheDigest::blake3(b"blob");
1742
1743 assert!(
1744 client
1745 .get_blob_pack(std::slice::from_ref(&digest), staging.path())
1746 .await
1747 .unwrap()
1748 .is_none()
1749 );
1750 assert!(
1751 client
1752 .get_blob_pack(&[digest], staging.path())
1753 .await
1754 .unwrap()
1755 .is_none()
1756 );
1757 capabilities.assert_async().await;
1758 request.assert_async().await;
1759 }
1760
1761 #[tokio::test]
1762 async fn rejects_truncated_blob_pack_frames() {
1763 let mut server = mockito::Server::new_async().await;
1764 let contents = b"complete blob";
1765 let digest = CacheDigest::blake3(contents);
1766 let mut pack = encode_blob_pack(&[(&digest, contents.as_slice())]);
1767 pack.truncate(pack.len() - 3);
1768 mock_blob_pack_capabilities(&mut server).await;
1769 server
1770 .mock("POST", "/v1/blobs:pack")
1771 .with_status(200)
1772 .with_header("content-type", BLOB_PACK_MEDIA_TYPE)
1773 .with_body(pack)
1774 .create_async()
1775 .await;
1776 let client = test_client(&server);
1777 let staging = tempfile::tempdir().unwrap();
1778
1779 let error = match client.get_blob_pack(&[digest], staging.path()).await {
1780 Err(error) => error,
1781 Ok(_) => panic!("truncated pack should be rejected"),
1782 };
1783
1784 assert!(
1785 error
1786 .to_string()
1787 .contains("ended before a blob was complete")
1788 );
1789 }
1790
1791 #[tokio::test]
1792 async fn rejects_blob_pack_frames_with_corrupt_content() {
1793 let mut server = mockito::Server::new_async().await;
1794 let digest = CacheDigest::blake3(b"expected");
1795 let corrupt = b"corrupt!";
1796 let pack = encode_blob_pack(&[(&digest, corrupt.as_slice())]);
1797 mock_blob_pack_capabilities(&mut server).await;
1798 server
1799 .mock("POST", "/v1/blobs:pack")
1800 .with_status(200)
1801 .with_header("content-type", BLOB_PACK_MEDIA_TYPE)
1802 .with_body(pack)
1803 .create_async()
1804 .await;
1805 let client = test_client(&server);
1806 let staging = tempfile::tempdir().unwrap();
1807
1808 let error = match client.get_blob_pack(&[digest], staging.path()).await {
1809 Err(error) => error,
1810 Ok(_) => panic!("corrupt pack should be rejected"),
1811 };
1812
1813 assert!(error.to_string().contains("failed digest verification"));
1814 }
1815
1816 #[test]
1817 fn blob_pack_chunk_honors_item_and_byte_limits() {
1818 let first = CacheDigest::blake3(b"1234");
1819 let second = CacheDigest::blake3(b"5678");
1820 let oversized = CacheDigest::blake3(b"123456789");
1821 let chunk = blob_pack_chunk(
1822 &[first.clone(), second.clone(), first.clone(), oversized],
1823 BlobPackLimits {
1824 max_items: 10,
1825 max_bytes: 7,
1826 },
1827 )
1828 .unwrap();
1829
1830 assert_eq!(chunk, vec![first]);
1831
1832 let chunk = blob_pack_chunk(
1833 &[CacheDigest::blake3(b"a"), CacheDigest::blake3(b"b")],
1834 BlobPackLimits {
1835 max_items: 1,
1836 max_bytes: 100,
1837 },
1838 )
1839 .unwrap();
1840 assert_eq!(chunk.len(), 1);
1841 }
1842
1843 #[test]
1844 fn blob_pack_timeout_scales_with_declared_work() {
1845 let base = Duration::from_secs(10);
1846 let small = CacheDigest::blake3(b"small");
1847 assert_eq!(blob_pack_download_timeout(base, &[small]), base);
1848
1849 let large = CacheDigest {
1850 algorithm: "blake3".into(),
1851 hash: "0".repeat(64),
1852 size: MAX_STAGED_BLOB_PACK_BYTES,
1853 };
1854 assert_eq!(
1855 blob_pack_download_timeout(base, &[large]),
1856 base.saturating_mul(4)
1857 );
1858
1859 let many = (0..=BLOB_PACK_TIMEOUT_ITEMS_PER_UNIT)
1860 .map(|index| CacheDigest::blake3(index.to_string().as_bytes()))
1861 .collect::<Vec<_>>();
1862 assert_eq!(
1863 blob_pack_download_timeout(base, &many),
1864 base.saturating_mul(2)
1865 );
1866 }
1867
1868 #[test]
1869 fn bearer_authorization_headers_are_sensitive() {
1870 let header = authorization_header(Some(" test-token ")).unwrap().unwrap();
1871 assert_eq!(header, "Bearer test-token");
1872 assert!(header.is_sensitive());
1873 assert!(authorization_header(Some(" ")).unwrap().is_none());
1874 }
1875
1876 #[tokio::test]
1877 async fn rejects_blob_larger_than_its_digest() {
1878 let mut server = mockito::Server::new_async().await;
1879 let digest = CacheDigest::blake3(b"small");
1880 let endpoint = format!(
1881 "/v{PROTOCOL_VERSION}/blobs/{}/{}/{}",
1882 digest.algorithm, digest.hash, digest.size
1883 );
1884 server
1885 .mock("GET", endpoint.as_str())
1886 .with_status(200)
1887 .with_header("content-type", BLOB_MEDIA_TYPE)
1888 .with_body(vec![b'x'; 4096])
1889 .expect(2)
1890 .create_async()
1891 .await;
1892 let client = test_client(&server);
1893 let staging = tempfile::tempdir().unwrap();
1894
1895 let buffered = client
1896 .get_blob(&digest, BLOB_MEDIA_TYPE)
1897 .await
1898 .err()
1899 .unwrap();
1900 let streamed = client
1901 .get_blob_file(&digest, staging.path())
1902 .await
1903 .err()
1904 .unwrap();
1905
1906 for error in [buffered, streamed] {
1907 assert!(
1908 error
1909 .to_string()
1910 .contains("exceeded the size of its digest"),
1911 "unexpected error: {error}"
1912 );
1913 }
1914 }
1915
1916 #[tokio::test]
1917 async fn rejects_oversized_capabilities() {
1918 let mut server = mockito::Server::new_async().await;
1919 server
1920 .mock("GET", format!("/v{PROTOCOL_VERSION}/capabilities").as_str())
1921 .with_status(200)
1922 .with_body(vec![b'x'; MAX_REMOTE_JSON_BYTES as usize + 1])
1923 .create_async()
1924 .await;
1925
1926 let error = test_client(&server)
1929 .blob_pack_limits()
1930 .await
1931 .err()
1932 .unwrap()
1933 .to_string();
1934 assert!(
1935 error.contains("over the") || error.contains("exceeded the"),
1936 "unexpected error: {error}"
1937 );
1938 }
1939
1940 #[tokio::test]
1941 async fn rejects_action_json_larger_than_the_limit() {
1942 let mut server = mockito::Server::new_async().await;
1943 let key = CacheDigest::blake3(b"action");
1944 let oversized = vec![b'x'; MAX_REMOTE_JSON_BYTES as usize + 1];
1945 for kind in ["action-results", "action-manifests"] {
1946 server
1947 .mock(
1948 "GET",
1949 format!(
1950 "/v{PROTOCOL_VERSION}/{kind}/{}/{}/{}",
1951 key.algorithm, key.hash, key.size
1952 )
1953 .as_str(),
1954 )
1955 .with_status(200)
1956 .with_header("etag", &format!("\"{}\"", blake3::hash(b"any").to_hex()))
1959 .with_body(oversized.clone())
1960 .create_async()
1961 .await;
1962 }
1963 let client = test_client(&server);
1964
1965 for error in [
1968 client.get_action_result(&key).await.err().unwrap(),
1969 client.get_action_manifest(&key).await.err().unwrap(),
1970 ] {
1971 assert!(
1972 error.to_string().contains("over the")
1973 || error.to_string().contains("exceeded the"),
1974 "unexpected error: {error}"
1975 );
1976 }
1977 }
1978
1979 #[tokio::test]
1980 async fn uploads_compress_when_the_server_offers_zstd() {
1981 let mut server = mockito::Server::new_async().await;
1982 server
1983 .mock("GET", "/v1/capabilities")
1984 .with_status(200)
1985 .with_header("content-type", "application/json")
1986 .with_body(
1987 serde_json::json!({
1988 "protocol":{"major":1},
1989 "compressors":["identity","zstd"]
1990 })
1991 .to_string(),
1992 )
1993 .create_async()
1994 .await;
1995 let payload = b"compressible cached output ".repeat(64);
1996 let expected = payload.clone();
1997 let put = server
1998 .mock("PUT", mockito::Matcher::Regex("^/v1/blobs/".into()))
1999 .match_header("content-encoding", "zstd")
2000 .match_request(move |request| {
2001 let body = request.body().expect("upload body");
2002 body.len() < expected.len()
2005 && zstd::decode_all(body.as_slice()).ok().as_deref() == Some(&expected[..])
2006 })
2007 .with_status(201)
2008 .create_async()
2009 .await;
2010
2011 let client = test_client(&server);
2012 client
2013 .put_blob(&BlobUpload {
2014 digest: CacheDigest::blake3(&payload),
2015 source: BlobSource::Bytes(payload.clone()),
2016 })
2017 .await
2018 .unwrap();
2019 put.assert_async().await;
2020 }
2021
2022 #[tokio::test]
2023 async fn uploads_stay_identity_without_the_advertisement() {
2024 let mut server = mockito::Server::new_async().await;
2025 server
2026 .mock("GET", "/v1/capabilities")
2027 .with_status(200)
2028 .with_header("content-type", "application/json")
2029 .with_body(serde_json::json!({"protocol":{"major":1}}).to_string())
2030 .create_async()
2031 .await;
2032 let payload = b"uncompressed cached output".to_vec();
2033 let put = server
2034 .mock("PUT", mockito::Matcher::Regex("^/v1/blobs/".into()))
2035 .match_body(mockito::Matcher::from(payload.clone()))
2036 .match_request(|request| request.header("content-encoding").is_empty())
2037 .with_status(201)
2038 .create_async()
2039 .await;
2040
2041 let client = test_client(&server);
2042 client
2043 .put_blob(&BlobUpload {
2044 digest: CacheDigest::blake3(&payload),
2045 source: BlobSource::Bytes(payload.clone()),
2046 })
2047 .await
2048 .unwrap();
2049 put.assert_async().await;
2050 }
2051
2052 #[tokio::test]
2053 async fn downloads_decompress_zstd_responses() {
2054 let mut server = mockito::Server::new_async().await;
2055 let payload = b"compressible cached output ".repeat(64);
2056 let digest = CacheDigest::blake3(&payload);
2057 let compressed = zstd::encode_all(payload.as_slice(), 0).unwrap();
2058 assert!(compressed.len() < payload.len());
2059 server
2060 .mock(
2061 "GET",
2062 format!("/v1/blobs/blake3/{}/{}", digest.hash, digest.size).as_str(),
2063 )
2064 .with_status(200)
2065 .with_header("content-type", BLOB_MEDIA_TYPE)
2066 .with_header("content-encoding", "zstd")
2067 .with_body(compressed)
2068 .create_async()
2069 .await;
2070
2071 let client = test_client(&server);
2072 let bytes = client.get_blob(&digest, BLOB_MEDIA_TYPE).await.unwrap();
2075 assert_eq!(bytes, payload);
2076 }
2077
2078 fn test_client(server: &mockito::ServerGuard) -> RemoteCacheClient {
2079 RemoteCacheClient::new(RemoteCacheConfig {
2080 base_url: server.url().parse().unwrap(),
2081 namespace: "test".into(),
2082 token: Some("test-token".into()),
2083 token_file: None,
2084 oidc_audience: None,
2085 connect_timeout: Duration::from_secs(1),
2086 read_timeout: Duration::from_secs(1),
2087 download_timeout: Duration::from_secs(1),
2088 retries: 0,
2089 })
2090 .unwrap()
2091 }
2092
2093 async fn mock_blob_pack_capabilities(server: &mut mockito::ServerGuard) {
2094 server
2095 .mock("GET", "/v1/capabilities")
2096 .with_status(200)
2097 .with_header("content-type", "application/json")
2098 .with_body(
2099 serde_json::json!({
2100 "protocol":{"major":1},
2101 "features":{"blob_packs":true},
2102 "limits":{"max_batch_items":100,"max_pack_bytes":1024}
2103 })
2104 .to_string(),
2105 )
2106 .create_async()
2107 .await;
2108 }
2109
2110 fn encode_blob_pack(entries: &[(&CacheDigest, &[u8])]) -> Vec<u8> {
2111 let mut pack = BLOB_PACK_MAGIC.to_vec();
2112 for (digest, contents) in entries {
2113 assert_eq!(digest.size, contents.len() as u64);
2114 pack.push(match digest.algorithm.as_str() {
2115 "blake3" => 1,
2116 "sha256" => 2,
2117 algorithm => panic!("unexpected test digest algorithm {algorithm}"),
2118 });
2119 pack.extend(hex::decode(&digest.hash).unwrap());
2120 pack.extend(digest.size.to_be_bytes());
2121 pack.extend_from_slice(contents);
2122 }
2123 pack
2124 }
2125
2126 #[tokio::test]
2127 async fn token_file_credentials_are_reloaded() {
2128 let directory = tempfile::tempdir().unwrap();
2129 let path = directory.path().join("cache-token");
2130 fs::write(&path, "first-token\n").unwrap();
2131 let credential = RemoteCacheCredential::File(path.clone());
2132
2133 let first = credential.authorization().await.unwrap().unwrap();
2134 assert_eq!(first, "Bearer first-token");
2135 assert!(first.is_sensitive());
2136
2137 fs::write(path, "rotated-token\n").unwrap();
2138 let rotated = credential.authorization().await.unwrap().unwrap();
2139 assert_eq!(rotated, "Bearer rotated-token");
2140 }
2141
2142 #[tokio::test]
2143 async fn github_actions_oidc_tokens_are_acquired_and_cached() {
2144 let mut server = mockito::Server::new_async().await;
2145 let expires_at = unix_timestamp().unwrap() + 3600;
2146 let token = test_jwt(expires_at);
2147 let token_response = serde_json::json!({"value":token}).to_string();
2148 let request = server
2149 .mock("GET", "/oidc")
2150 .match_query(mockito::Matcher::UrlEncoded(
2151 "audience".into(),
2152 "https://cache.example.com".into(),
2153 ))
2154 .match_header("authorization", "Bearer request-secret")
2155 .with_status(200)
2156 .with_header("content-type", "application/json")
2157 .with_body(token_response)
2158 .expect(1)
2159 .create_async()
2160 .await;
2161 let credential = GithubActionsOidcCredential::new(
2162 "https://cache.example.com",
2163 format!("{}/oidc?api-version=1&audience=old", server.url())
2164 .parse()
2165 .unwrap(),
2166 "request-secret",
2167 reqwest::Client::new(),
2168 0,
2169 )
2170 .unwrap();
2171 assert_eq!(
2172 credential.request_url.query_pairs().collect::<Vec<_>>(),
2173 vec![
2174 ("api-version".into(), "1".into()),
2175 ("audience".into(), "https://cache.example.com".into()),
2176 ]
2177 );
2178
2179 let first = credential.authorization().await.unwrap();
2180 let second = credential.authorization().await.unwrap();
2181
2182 assert_eq!(first, format!("Bearer {token}"));
2183 assert_eq!(first, second);
2184 assert!(first.is_sensitive());
2185 request.assert_async().await;
2186 }
2187
2188 #[test]
2189 fn oidc_request_urls_require_https_except_for_loopback() {
2190 validate_oidc_request_url(&"https://example.com/oidc".parse().unwrap()).unwrap();
2191 validate_oidc_request_url(&"http://127.0.0.1:3000/oidc".parse().unwrap()).unwrap();
2192 assert!(validate_oidc_request_url(&"http://example.com/oidc".parse().unwrap()).is_err());
2193 }
2194
2195 fn test_jwt(expires_at: u64) -> String {
2196 let header = URL_SAFE_NO_PAD.encode(b"{}");
2197 let claims = URL_SAFE_NO_PAD
2198 .encode(serde_json::to_vec(&serde_json::json!({"exp":expires_at})).unwrap());
2199 format!("{header}.{claims}.signature")
2200 }
2201
2202 #[test]
2203 fn remote_urls_require_https_for_authenticated_requests() {
2204 for url in [
2205 "http://localhost:3000",
2206 "http://127.0.0.1:3000",
2207 "http://[::1]:3000",
2208 "https://cache.example.com",
2209 ] {
2210 validate_remote_url(&url.parse().unwrap(), true).unwrap();
2211 }
2212 let insecure: Url = "http://cache.example.com".parse().unwrap();
2213 assert!(validate_remote_url(&insecure, true).is_err());
2214 validate_remote_url(&insecure, false).unwrap();
2215 assert!(validate_remote_url(&"ftp://localhost/cache".parse().unwrap(), false).is_err());
2216 }
2217}