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_LENGTH, CONTENT_TYPE, ETAG, HeaderMap, HeaderValue, IF_MATCH,
8 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}
379
380#[derive(Debug, Deserialize)]
381struct CapabilityProtocol {
382 major: u8,
383}
384
385#[derive(Debug, Default, Deserialize)]
386struct CapabilityFeatures {
387 #[serde(default)]
388 blob_packs: bool,
389}
390
391#[derive(Debug, Default, Deserialize)]
392struct CapabilityLimits {
393 #[serde(default)]
394 max_batch_items: u64,
395 #[serde(default)]
396 max_pack_bytes: u64,
397}
398
399#[derive(Debug, Clone, Copy)]
400struct BlobPackLimits {
401 max_items: usize,
402 max_bytes: u64,
403}
404
405#[derive(Serialize)]
406struct DigestList<'a> {
407 digests: &'a [CacheDigest],
408}
409
410#[derive(Debug, Clone, Copy, PartialEq, Eq)]
411pub enum ManifestPutOutcome {
412 Stored,
413 PreconditionFailed,
414}
415
416pub struct RemoteCacheClient {
417 base_url: Url,
418 namespace: String,
419 client: reqwest::Client,
420 credential: RemoteCacheCredential,
421 download_timeout: Duration,
422 retries: i64,
423 capabilities: tokio::sync::OnceCell<Option<BlobPackLimits>>,
424 blob_packs_disabled: AtomicBool,
425}
426
427impl RemoteCacheClient {
428 pub fn new(config: RemoteCacheConfig) -> Result<Self> {
429 let authenticated = config
430 .token
431 .as_deref()
432 .is_some_and(|token| !token.trim().is_empty())
433 || config.token_file.is_some()
434 || config
435 .oidc_audience
436 .as_deref()
437 .is_some_and(|audience| !audience.trim().is_empty());
438 validate_remote_url(&config.base_url, authenticated)?;
439 let client = reqwest::Client::builder()
440 .connect_timeout(config.connect_timeout)
441 .read_timeout(config.read_timeout)
442 .redirect(reqwest::redirect::Policy::none())
443 .build()?;
444 let credential = remote_credential(&config, client.clone())?;
445 Ok(Self {
446 base_url: normalized_base_url(config.base_url),
447 namespace: config.namespace,
448 client,
449 credential,
450 download_timeout: config.download_timeout,
451 retries: config.retries,
452 capabilities: tokio::sync::OnceCell::new(),
453 blob_packs_disabled: AtomicBool::new(false),
454 })
455 }
456
457 fn action_result_endpoint(&self, action: &CacheDigest) -> Result<Url> {
458 action.validate()?;
459 if action.algorithm != "blake3" {
460 bail!("remote cache action keys must use blake3");
461 }
462 Ok(self.base_url.join(&format!(
463 "v{PROTOCOL_VERSION}/action-results/{}/{}/{}",
464 action.algorithm, action.hash, action.size
465 ))?)
466 }
467
468 fn blob_endpoint(&self, digest: &CacheDigest) -> Result<Url> {
469 digest.validate()?;
470 Ok(self.base_url.join(&format!(
471 "v{PROTOCOL_VERSION}/blobs/{}/{}/{}",
472 digest.algorithm, digest.hash, digest.size
473 ))?)
474 }
475
476 fn action_manifest_endpoint(&self, key: &CacheDigest) -> Result<Url> {
477 key.validate()?;
478 if key.algorithm != "blake3" {
479 bail!("remote action manifest keys must use blake3");
480 }
481 Ok(self.base_url.join(&format!(
482 "v{PROTOCOL_VERSION}/action-manifests/{}/{}/{}",
483 key.algorithm, key.hash, key.size
484 ))?)
485 }
486
487 fn capabilities_endpoint(&self) -> Result<Url> {
488 Ok(self
489 .base_url
490 .join(&format!("v{PROTOCOL_VERSION}/capabilities"))?)
491 }
492
493 fn blob_pack_endpoint(&self) -> Result<Url> {
494 Ok(self
495 .base_url
496 .join(&format!("v{PROTOCOL_VERSION}/blobs:pack"))?)
497 }
498
499 async fn request(
500 &self,
501 method: reqwest::Method,
502 url: Url,
503 media_type: &'static str,
504 ) -> Result<reqwest::RequestBuilder> {
505 let request = self
506 .client
507 .request(method, url)
508 .header(PROTOCOL_HEADER, u16::from(PROTOCOL_VERSION))
509 .header(NAMESPACE_HEADER, &self.namespace)
510 .header(ACCEPT, media_type);
511 if let Some(authorization) = self.credential.authorization().await? {
512 Ok(request.header(AUTHORIZATION, authorization))
513 } else {
514 Ok(request)
515 }
516 }
517
518 async fn blob_pack_limits(&self) -> Result<Option<BlobPackLimits>> {
519 self.capabilities
520 .get_or_try_init(|| async {
521 let url = self.capabilities_endpoint()?;
522 let response = self
523 .request(reqwest::Method::GET, url, "application/json")
524 .await?
525 .send()
526 .await?;
527 if matches!(
528 response.status(),
529 StatusCode::NOT_FOUND
530 | StatusCode::METHOD_NOT_ALLOWED
531 | StatusCode::NOT_IMPLEMENTED
532 ) {
533 return Ok(None);
534 }
535 let bytes =
536 read_bounded_json(response.error_for_status()?, "capabilities").await?;
537 let capabilities: RemoteCacheCapabilities = serde_json::from_slice(&bytes)?;
538 if capabilities.protocol.major != PROTOCOL_VERSION {
539 bail!(
540 "remote cache capability protocol {} is incompatible with client protocol {PROTOCOL_VERSION}",
541 capabilities.protocol.major
542 );
543 }
544 if !capabilities.features.blob_packs {
545 return Ok(None);
546 }
547 let max_items = usize::try_from(capabilities.limits.max_batch_items)
548 .ok()
549 .filter(|limit| *limit > 0)
550 .ok_or_else(|| {
551 eyre!("remote cache blob packs require a positive max_batch_items limit")
552 })?;
553 if capabilities.limits.max_pack_bytes == 0 {
554 bail!("remote cache blob packs require a positive max_pack_bytes limit");
555 }
556 Ok(Some(BlobPackLimits {
557 max_items: max_items.min(MAX_STAGED_BLOB_PACK_ITEMS),
558 max_bytes: capabilities
559 .limits
560 .max_pack_bytes
561 .min(MAX_STAGED_BLOB_PACK_BYTES),
562 }))
563 })
564 .await
565 .copied()
566 }
567
568 pub async fn get_blob_pack(
574 &self,
575 digests: &[CacheDigest],
576 staging_dir: &Path,
577 ) -> Result<Option<RemoteBlobPack>> {
578 if digests.is_empty() || self.blob_packs_disabled.load(Ordering::Relaxed) {
579 return Ok(None);
580 }
581 let Some(limits) = self.blob_pack_limits().await? else {
582 return Ok(None);
583 };
584 fs::create_dir_all(staging_dir)?;
585 let chunk = blob_pack_chunk(digests, limits)?;
586 if chunk.is_empty() {
587 return Ok(Some(RemoteBlobPack {
588 _directory: tempfile::tempdir_in(staging_dir)?,
589 blobs: Vec::new(),
590 requests: 0,
591 requested: Vec::new(),
592 blob_count: 0,
593 payload_bytes: 0,
594 framed_bytes: BLOB_PACK_MAGIC.len() as u64,
595 }));
596 }
597 match self.download_blob_pack_chunk(&chunk, staging_dir).await? {
598 Some(pack) => Ok(Some(RemoteBlobPack {
599 _directory: pack.directory,
600 blobs: pack.blobs,
601 requests: 1,
602 requested: chunk,
603 blob_count: pack.metadata.blob_count,
604 payload_bytes: pack.metadata.payload_bytes,
605 framed_bytes: pack.metadata.framed_bytes,
606 })),
607 None => {
608 self.blob_packs_disabled.store(true, Ordering::Relaxed);
609 Ok(None)
610 }
611 }
612 }
613
614 async fn download_blob_pack_chunk(
615 &self,
616 digests: &[CacheDigest],
617 staging_dir: &Path,
618 ) -> Result<Option<DownloadedBlobPack>> {
619 let url = self.blob_pack_endpoint()?;
620 let body = serde_json::to_vec(&DigestList { digests })?;
621 let download_timeout = blob_pack_download_timeout(self.download_timeout, digests);
622 let download = retry_async("POST", &url, self.retries, || async {
623 let response = self
624 .request(reqwest::Method::POST, url.clone(), BLOB_PACK_MEDIA_TYPE)
625 .await?
626 .header(CONTENT_TYPE, DIGEST_LIST_MEDIA_TYPE)
627 .body(body.clone())
628 .send()
629 .await?;
630 if matches!(
631 response.status(),
632 StatusCode::NOT_FOUND
633 | StatusCode::METHOD_NOT_ALLOWED
634 | StatusCode::NOT_IMPLEMENTED
635 ) {
636 return Ok(None);
637 }
638 let response = response.error_for_status()?;
639 let media_type = response
640 .headers()
641 .get(CONTENT_TYPE)
642 .and_then(|value| value.to_str().ok())
643 .and_then(|value| value.split(';').next())
644 .map(str::trim);
645 if media_type != Some(BLOB_PACK_MEDIA_TYPE) {
646 bail!("remote cache blob pack has an invalid content type");
647 }
648 Ok(Some(
649 decode_blob_pack(response, digests, staging_dir).await?,
650 ))
651 });
652 tokio::time::timeout(download_timeout, download)
653 .await
654 .map_err(|_| eyre!("remote cache blob pack download timed out for {url}"))?
655 }
656
657 pub async fn get_action_result(
658 &self,
659 action: &CacheDigest,
660 ) -> Result<Option<RemoteActionResult>> {
661 let url = self.action_result_endpoint(action)?;
662 let result = retry_async("GET", &url, self.retries, || async {
663 let response = self
664 .request(reqwest::Method::GET, url.clone(), ACTION_RESULT_MEDIA_TYPE)
665 .await?
666 .send()
667 .await?;
668 if response.status() == StatusCode::NOT_FOUND {
669 return Ok(None);
670 }
671 let bytes = read_bounded_json(response.error_for_status()?, "action result").await?;
672 Ok(Some(serde_json::from_slice::<RemoteActionResult>(&bytes)?))
673 })
674 .await?;
675 if let Some(result) = &result
676 && (result.version != 1 || result.action != *action)
677 {
678 bail!("remote action result does not match requested action");
679 }
680 Ok(result)
681 }
682
683 pub async fn put_action_result(&self, result: &RemoteActionResult) -> Result<()> {
684 let url = self.action_result_endpoint(&result.action)?;
685 let body = serde_json::to_vec(result)?;
686 retry_async("PUT", &url, self.retries, || async {
687 let response = self
688 .request(reqwest::Method::PUT, url.clone(), ACTION_RESULT_MEDIA_TYPE)
689 .await?
690 .header(CONTENT_TYPE, ACTION_RESULT_MEDIA_TYPE)
691 .header(IF_NONE_MATCH, "*")
692 .body(body.clone())
693 .send()
694 .await?;
695 if response.status() != StatusCode::PRECONDITION_FAILED {
696 response.error_for_status()?;
697 }
698 Ok(())
699 })
700 .await
701 }
702
703 pub async fn get_action_manifest(
704 &self,
705 key: &CacheDigest,
706 ) -> Result<Option<RemoteActionManifest>> {
707 let url = self.action_manifest_endpoint(key)?;
708 retry_async("GET", &url, self.retries, || async {
709 let response = self
710 .request(
711 reqwest::Method::GET,
712 url.clone(),
713 TASK_ACTION_MANIFEST_MEDIA_TYPE,
714 )
715 .await?
716 .send()
717 .await?;
718 if response.status() == StatusCode::NOT_FOUND {
719 return Ok(None);
720 }
721 let response = response.error_for_status()?;
722 let etag = parse_strong_etag(response.headers().get(ETAG))?;
723 let bytes = read_bounded_json(response, "action manifest").await?;
724 if blake3::hash(&bytes).to_hex().as_str() != etag {
725 bail!("remote action manifest ETag does not match its body");
726 }
727 Ok(Some(RemoteActionManifest { bytes, etag }))
728 })
729 .await
730 }
731
732 pub async fn put_action_manifest(
733 &self,
734 key: &CacheDigest,
735 bytes: &[u8],
736 expected_etag: Option<&str>,
737 ) -> Result<ManifestPutOutcome> {
738 let url = self.action_manifest_endpoint(key)?;
739 let body = bytes.to_vec();
740 let expected_etag = expected_etag.map(quoted_etag).transpose()?;
741 retry_async("PUT", &url, self.retries, || async {
742 let mut request = self
743 .request(
744 reqwest::Method::PUT,
745 url.clone(),
746 TASK_ACTION_MANIFEST_MEDIA_TYPE,
747 )
748 .await?
749 .header(CONTENT_TYPE, TASK_ACTION_MANIFEST_MEDIA_TYPE)
750 .body(body.clone());
751 request = if let Some(etag) = &expected_etag {
752 request.header(IF_MATCH, etag)
753 } else {
754 request.header(IF_NONE_MATCH, "*")
755 };
756 let response = request.send().await?;
757 if response.status() == StatusCode::PRECONDITION_FAILED {
758 return Ok(ManifestPutOutcome::PreconditionFailed);
759 }
760 response.error_for_status()?;
761 Ok(ManifestPutOutcome::Stored)
762 })
763 .await
764 }
765
766 pub async fn get_blob(
767 &self,
768 digest: &CacheDigest,
769 media_type: &'static str,
770 ) -> Result<Vec<u8>> {
771 digest.validate()?;
772 let url = self.blob_endpoint(digest)?;
773 retry_async("GET", &url, self.retries, || async {
774 let mut response = self
775 .request(reqwest::Method::GET, url.clone(), media_type)
776 .await?
777 .send()
778 .await?
779 .error_for_status()?;
780 let mut bytes = Vec::new();
784 while let Some(chunk) = response.chunk().await? {
785 if bytes.len() as u64 + chunk.len() as u64 > digest.size {
786 bail!("remote cache blob exceeded the size of its digest");
787 }
788 bytes.extend_from_slice(&chunk);
789 }
790 if !digest.matches_bytes(&bytes)? {
791 bail!("remote cache blob failed digest verification");
792 }
793 Ok(bytes)
794 })
795 .await
796 }
797
798 pub async fn get_blob_file(
799 &self,
800 digest: &CacheDigest,
801 staging_dir: &Path,
802 ) -> Result<tempfile::NamedTempFile> {
803 let url = self.blob_endpoint(digest)?;
804 let download = retry_async("GET", &url, self.retries, || async {
805 let mut response = self
806 .request(reqwest::Method::GET, url.clone(), BLOB_MEDIA_TYPE)
807 .await?
808 .send()
809 .await?;
810 response.error_for_status_ref()?;
811 fs::create_dir_all(staging_dir)?;
812 let temporary = tempfile::NamedTempFile::new_in(staging_dir)?;
813 let mut output = tokio::fs::File::from_std(temporary.reopen()?);
814 let mut written = 0u64;
817 while let Some(chunk) = response.chunk().await? {
818 written += chunk.len() as u64;
819 if written > digest.size {
820 bail!("remote cache blob exceeded the size of its digest");
821 }
822 output.write_all(&chunk).await?;
823 }
824 output.flush().await?;
825 drop(output);
826 if !digest.matches_file(temporary.path())? {
827 bail!("remote cache blob failed digest verification");
828 }
829 Ok(temporary)
830 });
831 tokio::time::timeout(self.download_timeout, download)
832 .await
833 .map_err(|_| eyre!("remote cache blob download timed out for {url}"))?
834 }
835
836 pub async fn put_blob(&self, upload: &BlobUpload) -> Result<()> {
837 let url = self.blob_endpoint(&upload.digest)?;
838 retry_async("PUT", &url, self.retries, || async {
839 let (length, body) = match &upload.source {
840 BlobSource::Bytes(bytes) => {
841 (bytes.len() as u64, reqwest::Body::from(bytes.clone()))
842 }
843 BlobSource::File(file) => {
844 let file = tokio::fs::File::open(file.path()).await?;
845 let length = file.metadata().await?.len();
846 let stream = tokio_util::io::ReaderStream::new(file);
847 (length, reqwest::Body::wrap_stream(stream))
848 }
849 BlobSource::Path(path) => {
850 let file = tokio::fs::File::open(path).await?;
851 let length = file.metadata().await?.len();
852 let stream = tokio_util::io::ReaderStream::new(file);
853 (length, reqwest::Body::wrap_stream(stream))
854 }
855 };
856 let response = self
857 .request(reqwest::Method::PUT, url.clone(), BLOB_MEDIA_TYPE)
858 .await?
859 .header(CONTENT_TYPE, BLOB_MEDIA_TYPE)
860 .header(CONTENT_LENGTH, length)
861 .header(IF_NONE_MATCH, "*")
862 .body(body)
863 .send()
864 .await?;
865 if response.status() != StatusCode::PRECONDITION_FAILED {
866 response.error_for_status()?;
867 }
868 Ok(())
869 })
870 .await
871 }
872}
873
874fn blob_pack_chunk(digests: &[CacheDigest], limits: BlobPackLimits) -> Result<Vec<CacheDigest>> {
875 let mut seen = BTreeSet::new();
876 let mut chunk = Vec::new();
877 let mut chunk_bytes = 0_u64;
878 for digest in digests {
879 digest.validate()?;
880 if !seen.insert(digest.clone()) || digest.size > limits.max_bytes {
881 continue;
882 }
883 if chunk.len() == limits.max_items
884 || chunk_bytes.saturating_add(digest.size) > limits.max_bytes
885 {
886 break;
887 }
888 chunk_bytes = chunk_bytes.saturating_add(digest.size);
889 chunk.push(digest.clone());
890 }
891 Ok(chunk)
892}
893
894fn blob_pack_download_timeout(base: Duration, digests: &[CacheDigest]) -> Duration {
895 let bytes = digests
896 .iter()
897 .fold(0_u64, |total, digest| total.saturating_add(digest.size));
898 let byte_units = bytes.div_ceil(BLOB_PACK_TIMEOUT_BYTES_PER_UNIT);
899 let item_units = digests.len().div_ceil(BLOB_PACK_TIMEOUT_ITEMS_PER_UNIT);
900 let item_units = u64::try_from(item_units).unwrap_or(u64::MAX);
901 let multiplier = byte_units.max(item_units).max(1);
902 base.saturating_mul(u32::try_from(multiplier).unwrap_or(u32::MAX))
903}
904
905async fn read_bounded_json(response: reqwest::Response, what: &str) -> Result<Vec<u8>> {
910 if let Some(length) = response.content_length()
911 && length > MAX_REMOTE_JSON_BYTES
912 {
913 bail!(
914 "remote cache {what} declared {length} bytes, over the {MAX_REMOTE_JSON_BYTES} byte limit"
915 );
916 }
917 let mut response = response;
918 let mut bytes = Vec::new();
919 while let Some(chunk) = response.chunk().await? {
920 if bytes.len() as u64 + chunk.len() as u64 > MAX_REMOTE_JSON_BYTES {
921 bail!("remote cache {what} exceeded the {MAX_REMOTE_JSON_BYTES} byte limit");
922 }
923 bytes.extend_from_slice(&chunk);
924 }
925 Ok(bytes)
926}
927
928async fn decode_blob_pack(
929 response: reqwest::Response,
930 requested: &[CacheDigest],
931 staging_dir: &Path,
932) -> Result<DownloadedBlobPack> {
933 let metadata = BlobPackResponseMetadata::from_headers(response.headers())?;
934 let requested = requested.iter().cloned().collect::<BTreeSet<_>>();
935 let stream = response.bytes_stream().map_err(std::io::Error::other);
936 let mut reader = tokio_util::io::StreamReader::new(stream);
937 let mut magic = [0_u8; BLOB_PACK_MAGIC.len()];
938 reader.read_exact(&mut magic).await?;
939 if &magic != BLOB_PACK_MAGIC {
940 bail!("remote cache blob pack has invalid magic");
941 }
942
943 let directory = tempfile::tempdir_in(staging_dir)?;
944 let mut seen = BTreeSet::new();
945 let mut blobs = Vec::new();
946 let mut payload_bytes = 0_u64;
947 let mut framed_bytes = BLOB_PACK_MAGIC.len() as u64;
948 loop {
949 let mut algorithm = [0_u8; 1];
950 if reader.read(&mut algorithm).await? == 0 {
951 break;
952 }
953 let (algorithm, mut hasher) = match algorithm[0] {
954 1 => (
955 "blake3",
956 BlobPackHasher::Blake3(Box::new(blake3::Hasher::new())),
957 ),
958 2 => ("sha256", BlobPackHasher::Sha256(sha2::Sha256::new())),
959 _ => bail!("remote cache blob pack has an invalid digest algorithm"),
960 };
961 let mut hash = [0_u8; 32];
962 reader.read_exact(&mut hash).await?;
963 let mut size = [0_u8; 8];
964 reader.read_exact(&mut size).await?;
965 let digest = CacheDigest {
966 algorithm: algorithm.into(),
967 hash: hex::encode(hash),
968 size: u64::from_be_bytes(size),
969 };
970 if !requested.contains(&digest) {
971 bail!("remote cache blob pack returned an unrequested digest");
972 }
973 if !seen.insert(digest.clone()) {
974 bail!("remote cache blob pack returned a duplicate digest");
975 }
976 framed_bytes = framed_bytes
977 .checked_add(BLOB_PACK_HEADER_BYTES)
978 .and_then(|bytes| bytes.checked_add(digest.size))
979 .ok_or_else(|| eyre!("remote cache blob pack is too large"))?;
980 payload_bytes = payload_bytes
981 .checked_add(digest.size)
982 .ok_or_else(|| eyre!("remote cache blob pack payload is too large"))?;
983
984 let path = directory.path().join(blobs.len().to_string());
985 let mut output = tokio::fs::File::create(&path).await?;
986 let mut remaining = digest.size;
987 let mut buffer = [0_u8; 64 * 1024];
988 while remaining > 0 {
989 let limit = usize::try_from(remaining.min(buffer.len() as u64)).unwrap();
990 let count = reader.read(&mut buffer[..limit]).await?;
991 if count == 0 {
992 bail!("remote cache blob pack ended before a blob was complete");
993 }
994 output.write_all(&buffer[..count]).await?;
995 hasher.update(&buffer[..count]);
996 remaining -= count as u64;
997 }
998 output.flush().await?;
999 drop(output);
1000 if !hasher.matches(&digest.hash) {
1001 bail!("remote cache blob pack failed digest verification");
1002 }
1003 blobs.push((digest, path));
1004 }
1005 let blob_count = blobs.len().try_into().unwrap_or(u64::MAX);
1006 let metadata = metadata.validate(BlobPackResponseStats {
1007 blob_count,
1008 payload_bytes,
1009 framed_bytes,
1010 })?;
1011 Ok(DownloadedBlobPack {
1012 directory,
1013 blobs,
1014 metadata,
1015 })
1016}
1017
1018enum BlobPackHasher {
1019 Blake3(Box<blake3::Hasher>),
1020 Sha256(sha2::Sha256),
1021}
1022
1023impl BlobPackHasher {
1024 fn update(&mut self, bytes: &[u8]) {
1025 match self {
1026 Self::Blake3(hasher) => {
1027 hasher.update(bytes);
1028 }
1029 Self::Sha256(hasher) => {
1030 hasher.update(bytes);
1031 }
1032 }
1033 }
1034
1035 fn matches(self, expected: &str) -> bool {
1036 match self {
1037 Self::Blake3(hasher) => hasher.finalize().to_hex().as_str() == expected,
1038 Self::Sha256(hasher) => hex::encode(hasher.finalize()) == expected,
1039 }
1040 }
1041}
1042
1043fn parse_strong_etag(value: Option<&HeaderValue>) -> Result<String> {
1044 let value = value
1045 .and_then(|value| value.to_str().ok())
1046 .ok_or_else(|| eyre!("remote action manifest response is missing an ETag"))?;
1047 let etag = value
1048 .strip_prefix('"')
1049 .and_then(|value| value.strip_suffix('"'))
1050 .filter(|value| is_lower_hex_digest(value))
1051 .ok_or_else(|| eyre!("remote action manifest response has an invalid ETag"))?;
1052 Ok(etag.to_owned())
1053}
1054
1055fn quoted_etag(etag: &str) -> Result<HeaderValue> {
1056 if !is_lower_hex_digest(etag) {
1057 bail!("invalid remote action manifest ETag");
1058 }
1059 Ok(HeaderValue::from_str(&format!("\"{etag}\""))?)
1060}
1061
1062fn is_lower_hex_digest(value: &str) -> bool {
1063 value.len() == 64
1064 && value
1065 .bytes()
1066 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
1067}
1068
1069#[derive(Clone)]
1070enum RemoteCacheCredential {
1071 None,
1072 Static(HeaderValue),
1073 File(PathBuf),
1074 GithubActions(Arc<GithubActionsOidcCredential>),
1075}
1076
1077struct GithubActionsOidcCredential {
1078 audience: String,
1079 request_url: Url,
1080 request_token: HeaderValue,
1081 client: reqwest::Client,
1082 retries: i64,
1083 cached: tokio::sync::Mutex<Option<CachedOidcToken>>,
1084}
1085
1086struct CachedOidcToken {
1087 authorization: HeaderValue,
1088 expires_at: u64,
1089}
1090
1091#[derive(Deserialize)]
1092struct GithubActionsOidcResponse {
1093 value: String,
1094}
1095
1096#[derive(Deserialize)]
1097struct JwtExpiry {
1098 exp: u64,
1099}
1100
1101fn remote_credential(
1102 config: &RemoteCacheConfig,
1103 client: reqwest::Client,
1104) -> Result<RemoteCacheCredential> {
1105 if let Some(authorization) = authorization_header(config.token.as_deref())? {
1106 return Ok(RemoteCacheCredential::Static(authorization));
1107 }
1108 if let Some(path) = &config.token_file {
1109 return Ok(RemoteCacheCredential::File(path.clone()));
1110 }
1111 let Some(audience) = config
1112 .oidc_audience
1113 .as_deref()
1114 .map(str::trim)
1115 .filter(|audience| !audience.is_empty())
1116 else {
1117 return Ok(RemoteCacheCredential::None);
1118 };
1119 Ok(RemoteCacheCredential::GithubActions(Arc::new(
1120 GithubActionsOidcCredential::from_env(audience, client, config.retries)?,
1121 )))
1122}
1123
1124fn authorization_header(token: Option<&str>) -> Result<Option<HeaderValue>> {
1125 let Some(token) = token.map(str::trim).filter(|token| !token.is_empty()) else {
1126 return Ok(None);
1127 };
1128 let mut value = HeaderValue::from_str(&format!("Bearer {token}"))?;
1129 value.set_sensitive(true);
1130 Ok(Some(value))
1131}
1132
1133impl RemoteCacheCredential {
1134 async fn authorization(&self) -> Result<Option<HeaderValue>> {
1135 match self {
1136 Self::None => Ok(None),
1137 Self::Static(value) => Ok(Some(value.clone())),
1138 Self::File(path) => {
1139 let token = tokio::fs::read_to_string(path).await.map_err(|err| {
1140 eyre!(
1141 "failed to read remote cache token file {}: {err}",
1142 path.display()
1143 )
1144 })?;
1145 authorization_header(Some(&token))?
1146 .ok_or_else(|| eyre!("remote cache token file {} is empty", path.display()))
1147 .map(Some)
1148 }
1149 Self::GithubActions(credential) => credential.authorization().await.map(Some),
1150 }
1151 }
1152}
1153
1154impl GithubActionsOidcCredential {
1155 fn from_env(audience: &str, client: reqwest::Client, retries: i64) -> Result<Self> {
1156 let request_url = std::env::var("ACTIONS_ID_TOKEN_REQUEST_URL").map_err(|_| {
1157 eyre!(
1158 "remote cache OIDC audience requires GitHub Actions OIDC; \
1159 grant `id-token: write` or set MBX_REMOTE_TOKEN"
1160 )
1161 })?;
1162 let request_token = std::env::var("ACTIONS_ID_TOKEN_REQUEST_TOKEN").map_err(|_| {
1163 eyre!(
1164 "remote cache OIDC audience requires GitHub Actions OIDC; \
1165 ACTIONS_ID_TOKEN_REQUEST_TOKEN is missing"
1166 )
1167 })?;
1168 let request_url: Url = request_url
1169 .parse()
1170 .map_err(|err| eyre!("invalid GitHub Actions OIDC request URL: {err}"))?;
1171 Self::new(audience, request_url, &request_token, client, retries)
1172 }
1173
1174 fn new(
1175 audience: &str,
1176 mut request_url: Url,
1177 request_token: &str,
1178 client: reqwest::Client,
1179 retries: i64,
1180 ) -> Result<Self> {
1181 validate_oidc_request_url(&request_url)?;
1182 let query = request_url
1183 .query_pairs()
1184 .filter(|(key, _)| key != "audience")
1185 .map(|(key, value)| (key.into_owned(), value.into_owned()))
1186 .collect::<Vec<_>>();
1187 request_url.set_query(None);
1188 request_url
1189 .query_pairs_mut()
1190 .extend_pairs(query)
1191 .append_pair("audience", audience);
1192 let request_token = authorization_header(Some(request_token))?
1193 .ok_or_else(|| eyre!("GitHub Actions OIDC request token is empty"))?;
1194 Ok(Self {
1195 audience: audience.to_string(),
1196 request_url,
1197 request_token,
1198 client,
1199 retries,
1200 cached: tokio::sync::Mutex::new(None),
1201 })
1202 }
1203
1204 async fn authorization(&self) -> Result<HeaderValue> {
1205 const REFRESH_LEEWAY_SECONDS: u64 = 60;
1206 let mut cached = self.cached.lock().await;
1207 let now = unix_timestamp()?;
1208 if let Some(token) = cached.as_ref()
1209 && token.expires_at > now.saturating_add(REFRESH_LEEWAY_SECONDS)
1210 {
1211 return Ok(token.authorization.clone());
1212 }
1213 let response: GithubActionsOidcResponse =
1214 retry_async("GET", &self.request_url, self.retries, || async {
1215 Ok(self
1216 .client
1217 .get(self.request_url.clone())
1218 .header(AUTHORIZATION, self.request_token.clone())
1219 .send()
1220 .await?
1221 .error_for_status()?
1222 .json()
1223 .await?)
1224 })
1225 .await
1226 .map_err(|err| {
1227 eyre!(
1228 "failed to acquire GitHub Actions OIDC token for audience {:?}: {err}",
1229 self.audience
1230 )
1231 })?;
1232 let expires_at = jwt_expiry(&response.value)?;
1233 if expires_at <= now.saturating_add(REFRESH_LEEWAY_SECONDS) {
1234 bail!("GitHub Actions OIDC token expires too soon");
1235 }
1236 let authorization = authorization_header(Some(&response.value))?
1237 .ok_or_else(|| eyre!("GitHub Actions returned an empty OIDC token"))?;
1238 *cached = Some(CachedOidcToken {
1239 authorization: authorization.clone(),
1240 expires_at,
1241 });
1242 Ok(authorization)
1243 }
1244}
1245
1246fn jwt_expiry(token: &str) -> Result<u64> {
1247 let payload = token
1248 .split('.')
1249 .nth(1)
1250 .ok_or_else(|| eyre!("GitHub Actions returned a malformed OIDC token"))?;
1251 let payload = URL_SAFE_NO_PAD
1252 .decode(payload)
1253 .map_err(|_| eyre!("GitHub Actions returned a malformed OIDC token"))?;
1254 let claims: JwtExpiry = serde_json::from_slice(&payload)
1255 .map_err(|_| eyre!("GitHub Actions OIDC token is missing a valid expiry"))?;
1256 Ok(claims.exp)
1257}
1258
1259fn unix_timestamp() -> Result<u64> {
1260 Ok(SystemTime::now()
1261 .duration_since(UNIX_EPOCH)
1262 .map_err(|err| eyre!("system clock is before the Unix epoch: {err}"))?
1263 .as_secs())
1264}
1265
1266fn validate_oidc_request_url(url: &Url) -> Result<()> {
1267 if url.scheme() == "https"
1268 || url.scheme() == "http"
1269 && url.host().is_some_and(|host| match host {
1270 Host::Domain(host) => host.eq_ignore_ascii_case("localhost"),
1271 Host::Ipv4(address) => address.is_loopback(),
1272 Host::Ipv6(address) => address.is_loopback(),
1273 })
1274 {
1275 Ok(())
1276 } else {
1277 bail!("GitHub Actions OIDC request URL must use HTTPS")
1278 }
1279}
1280
1281fn validate_remote_url(base_url: &Url, authenticated: bool) -> Result<()> {
1282 if base_url.scheme() == "https" {
1283 return Ok(());
1284 }
1285 if base_url.scheme() != "http" {
1286 bail!("remote cache URL must use HTTPS");
1287 }
1288 let is_loopback = base_url.host().is_some_and(|host| match host {
1289 Host::Domain(host) => host.eq_ignore_ascii_case("localhost"),
1290 Host::Ipv4(address) => address.is_loopback(),
1291 Host::Ipv6(address) => address.is_loopback(),
1292 });
1293 if !is_loopback && authenticated {
1294 bail!("remote cache URL must use HTTPS except for loopback development servers");
1295 }
1296 if !is_loopback {
1297 warn!(
1298 "using an unauthenticated remote build cache over plain HTTP; cache traffic can be read \
1299 or modified in transit"
1300 );
1301 }
1302 Ok(())
1303}
1304
1305fn normalized_base_url(mut url: Url) -> Url {
1306 if !url.path().ends_with('/') {
1307 url.set_path(&format!("{}/", url.path()));
1308 }
1309 url
1310}
1311
1312fn retry_delays(retries: i64) -> impl Iterator<Item = Duration> {
1313 [200u64, 1_000, 4_000, 15_000]
1314 .into_iter()
1315 .chain(std::iter::repeat(15_000))
1316 .map(Duration::from_millis)
1317 .map(|duration| {
1318 let factor = 0.5 + rand::random::<f64>() * 0.5;
1319 Duration::from_secs_f64(duration.as_secs_f64() * factor)
1320 })
1321 .take(retries.max(0) as usize)
1322}
1323
1324fn is_dns_error(error: &(dyn std::error::Error + 'static)) -> bool {
1328 let mut current = Some(error);
1329 while let Some(source) = current {
1330 if source.to_string() == "dns error" {
1331 return true;
1332 }
1333 current = source.source();
1334 }
1335 false
1336}
1337
1338fn is_transient(error: &eyre::Report) -> bool {
1339 if is_dns_error(error.as_ref()) {
1342 return false;
1343 }
1344 error.chain().any(|source| {
1345 let Some(error) = source.downcast_ref::<reqwest::Error>() else {
1346 return false;
1347 };
1348 if error.is_timeout() || error.is_connect() || error.is_body() {
1349 return true;
1350 }
1351 error.status().is_some_and(|status| {
1352 let status = status.as_u16();
1353 status == 408 || status == 429 || (500..600).contains(&status)
1354 })
1355 })
1356}
1357
1358async fn retry_async<F, Fut, T>(verb: &str, url: &Url, retries: i64, mut operation: F) -> Result<T>
1359where
1360 F: FnMut() -> Fut,
1361 Fut: std::future::Future<Output = Result<T>>,
1362{
1363 let mut delays = retry_delays(retries);
1364 let mut attempt = 1;
1365 loop {
1366 let started_at = Instant::now();
1367 match operation().await {
1368 Ok(value) => return Ok(value),
1369 Err(error) if is_transient(&error) => {
1370 let Some(delay) = delays.next() else {
1371 return Err(error);
1372 };
1373 warn!(
1374 "HTTP {verb} {url} attempt {attempt} failed after {:?} (transient): {error}; retrying in {delay:?}",
1375 started_at.elapsed()
1376 );
1377 tokio::time::sleep(delay).await;
1378 attempt += 1;
1379 }
1380 Err(error) => return Err(error),
1381 }
1382 }
1383}
1384
1385#[cfg(test)]
1386mod tests {
1387 use super::*;
1388
1389 #[test]
1390 fn protocol_json_uses_jcs_key_and_number_encoding() {
1391 let value = serde_json::json!({"z": 1.0e30, "a": {"d": true, "c": null}});
1392 assert_eq!(
1393 canonical_json(&value).unwrap(),
1394 br#"{"a":{"c":null,"d":true},"z":1e+30}"#
1395 );
1396 }
1397
1398 #[test]
1399 fn dns_errors_are_not_transient() {
1400 #[derive(Debug)]
1401 struct DnsError;
1402
1403 impl std::fmt::Display for DnsError {
1404 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1405 formatter.write_str("dns error")
1406 }
1407 }
1408
1409 impl std::error::Error for DnsError {}
1410
1411 let error = eyre::Report::new(DnsError);
1412 assert!(is_dns_error(error.as_ref()));
1413 assert!(!is_transient(&error));
1414 }
1415
1416 #[test]
1417 fn cache_digest_verifies_its_declared_algorithm() {
1418 let bytes = b"remote cache blob";
1419 let sha256 = CacheDigest {
1420 algorithm: "sha256".into(),
1421 hash: hex::encode(sha2::Sha256::digest(bytes)),
1422 size: bytes.len() as u64,
1423 };
1424 assert!(sha256.matches_bytes(bytes).unwrap());
1425 assert!(!sha256.matches_bytes(b"different").unwrap());
1426
1427 let file = tempfile::NamedTempFile::new().unwrap();
1428 fs::write(file.path(), bytes).unwrap();
1429 assert!(sha256.matches_file(file.path()).unwrap());
1430 assert_eq!(
1431 CacheDigest::blake3_file(file.path()).unwrap().size,
1432 bytes.len() as u64
1433 );
1434 assert!(
1435 CacheDigest::blake3_file(file.path())
1436 .unwrap()
1437 .matches_bytes(bytes)
1438 .unwrap()
1439 );
1440 }
1441
1442 #[test]
1443 fn action_result_keys_require_blake3() {
1444 let client = RemoteCacheClient::new(RemoteCacheConfig {
1445 base_url: "http://127.0.0.1:1".parse().unwrap(),
1446 namespace: "test".into(),
1447 token: None,
1448 token_file: None,
1449 oidc_audience: None,
1450 connect_timeout: Duration::from_secs(1),
1451 read_timeout: Duration::from_secs(1),
1452 download_timeout: Duration::from_secs(1),
1453 retries: 0,
1454 })
1455 .unwrap();
1456 let action = CacheDigest {
1457 algorithm: "sha256".into(),
1458 hash: "0".repeat(64),
1459 size: 0,
1460 };
1461
1462 assert!(
1463 client
1464 .action_result_endpoint(&action)
1465 .unwrap_err()
1466 .to_string()
1467 .contains("must use blake3")
1468 );
1469 }
1470
1471 #[tokio::test]
1472 async fn downloads_negotiated_blob_packs_and_omits_missing_objects() {
1473 let mut server = mockito::Server::new_async().await;
1474 let first_bytes = b"first packed blob";
1475 let second_bytes = b"second packed blob";
1476 let first = CacheDigest::blake3(first_bytes);
1477 let second = CacheDigest::blake3(second_bytes);
1478 let missing = CacheDigest::blake3(b"missing packed blob");
1479 let capabilities = server
1480 .mock("GET", "/v1/capabilities")
1481 .match_header(PROTOCOL_HEADER, "1")
1482 .match_header(AUTHORIZATION.as_str(), "Bearer test-token")
1483 .with_status(200)
1484 .with_header("content-type", "application/json")
1485 .with_body(
1486 serde_json::json!({
1487 "protocol":{"major":1},
1488 "features":{"blob_packs":true},
1489 "limits":{"max_batch_items":100,"max_pack_bytes":1024}
1490 })
1491 .to_string(),
1492 )
1493 .expect(1)
1494 .create_async()
1495 .await;
1496 let packed = encode_blob_pack(&[
1497 (&first, first_bytes.as_slice()),
1498 (&second, second_bytes.as_slice()),
1499 ]);
1500 let packed_len = packed.len().to_string();
1501 let packed_blobs = 2.to_string();
1502 let packed_payload_bytes = (first.size + second.size).to_string();
1503 let request = server
1504 .mock("POST", "/v1/blobs:pack")
1505 .match_header(PROTOCOL_HEADER, "1")
1506 .match_header(NAMESPACE_HEADER, "test")
1507 .match_header("content-type", DIGEST_LIST_MEDIA_TYPE)
1508 .with_status(200)
1509 .with_header("content-type", BLOB_PACK_MEDIA_TYPE)
1510 .with_header("content-length", &packed_len)
1511 .with_header(BLOB_PACK_BLOBS_HEADER, &packed_blobs)
1512 .with_header(BLOB_PACK_BYTES_HEADER, &packed_payload_bytes)
1513 .with_body(packed)
1514 .expect(1)
1515 .create_async()
1516 .await;
1517 let client = test_client(&server);
1518 let staging = tempfile::tempdir().unwrap();
1519
1520 let pack = client
1521 .get_blob_pack(
1522 &[first.clone(), missing, second.clone(), first.clone()],
1523 staging.path(),
1524 )
1525 .await
1526 .unwrap()
1527 .unwrap();
1528
1529 assert_eq!(pack.requests, 1);
1530 assert_eq!(pack.blob_count, 2);
1531 assert_eq!(pack.payload_bytes, first.size + second.size);
1532 assert_eq!(
1533 pack.framed_bytes,
1534 BLOB_PACK_MAGIC.len() as u64 + 2 * BLOB_PACK_HEADER_BYTES + first.size + second.size
1535 );
1536 assert_eq!(pack.blobs.len(), 2);
1537 assert_eq!(fs::read(&pack.blobs[0].1).unwrap(), first_bytes);
1538 assert_eq!(fs::read(&pack.blobs[1].1).unwrap(), second_bytes);
1539 capabilities.assert_async().await;
1540 request.assert_async().await;
1541 }
1542
1543 #[tokio::test]
1544 async fn rejects_mismatched_blob_pack_metadata() {
1545 let mut server = mockito::Server::new_async().await;
1546 let contents = b"packed blob";
1547 let digest = CacheDigest::blake3(contents);
1548 mock_blob_pack_capabilities(&mut server).await;
1549 server
1550 .mock("POST", "/v1/blobs:pack")
1551 .with_status(200)
1552 .with_header("content-type", BLOB_PACK_MEDIA_TYPE)
1553 .with_header(BLOB_PACK_BLOBS_HEADER, "2")
1554 .with_body(encode_blob_pack(&[(&digest, contents.as_slice())]))
1555 .create_async()
1556 .await;
1557 let client = test_client(&server);
1558 let staging = tempfile::tempdir().unwrap();
1559
1560 let error = client
1561 .get_blob_pack(&[digest], staging.path())
1562 .await
1563 .err()
1564 .unwrap();
1565
1566 assert!(error.to_string().contains("blob count metadata mismatch"));
1567 }
1568
1569 #[tokio::test]
1570 async fn rejects_malformed_blob_pack_metadata() {
1571 let mut server = mockito::Server::new_async().await;
1572 let contents = b"packed blob";
1573 let digest = CacheDigest::blake3(contents);
1574 mock_blob_pack_capabilities(&mut server).await;
1575 server
1576 .mock("POST", "/v1/blobs:pack")
1577 .with_status(200)
1578 .with_header("content-type", BLOB_PACK_MEDIA_TYPE)
1579 .with_header(BLOB_PACK_BYTES_HEADER, "not-a-number")
1580 .with_body(encode_blob_pack(&[(&digest, contents.as_slice())]))
1581 .create_async()
1582 .await;
1583 let client = test_client(&server);
1584 let staging = tempfile::tempdir().unwrap();
1585
1586 let error = client
1587 .get_blob_pack(&[digest], staging.path())
1588 .await
1589 .err()
1590 .unwrap();
1591
1592 assert!(error.to_string().contains("not an unsigned integer"));
1593 }
1594
1595 #[tokio::test]
1596 async fn rejects_unrequested_blob_pack_frames() {
1597 let mut server = mockito::Server::new_async().await;
1598 let requested = CacheDigest::blake3(b"requested");
1599 let injected_bytes = b"not requested";
1600 let injected = CacheDigest::blake3(injected_bytes);
1601 server
1602 .mock("GET", "/v1/capabilities")
1603 .with_status(200)
1604 .with_header("content-type", "application/json")
1605 .with_body(
1606 serde_json::json!({
1607 "protocol":{"major":1},
1608 "features":{"blob_packs":true},
1609 "limits":{"max_batch_items":100,"max_pack_bytes":1024}
1610 })
1611 .to_string(),
1612 )
1613 .create_async()
1614 .await;
1615 server
1616 .mock("POST", "/v1/blobs:pack")
1617 .with_status(200)
1618 .with_header("content-type", BLOB_PACK_MEDIA_TYPE)
1619 .with_body(encode_blob_pack(&[(&injected, injected_bytes.as_slice())]))
1620 .create_async()
1621 .await;
1622 let client = test_client(&server);
1623 let staging = tempfile::tempdir().unwrap();
1624
1625 let error = client
1626 .get_blob_pack(&[requested], staging.path())
1627 .await
1628 .err()
1629 .unwrap();
1630
1631 assert!(error.to_string().contains("unrequested digest"));
1632 }
1633
1634 #[tokio::test]
1635 async fn falls_back_when_blob_packs_are_not_advertised() {
1636 let mut server = mockito::Server::new_async().await;
1637 let capabilities = server
1638 .mock("GET", "/v1/capabilities")
1639 .with_status(404)
1640 .expect(1)
1641 .create_async()
1642 .await;
1643 let client = test_client(&server);
1644 let staging = tempfile::tempdir().unwrap();
1645
1646 assert!(
1647 client
1648 .get_blob_pack(&[CacheDigest::blake3(b"blob")], staging.path())
1649 .await
1650 .unwrap()
1651 .is_none()
1652 );
1653 capabilities.assert_async().await;
1654 }
1655
1656 #[tokio::test]
1657 async fn disables_blob_packs_when_the_advertised_endpoint_is_unavailable() {
1658 let mut server = mockito::Server::new_async().await;
1659 let capabilities = server
1660 .mock("GET", "/v1/capabilities")
1661 .with_status(200)
1662 .with_header("content-type", "application/json")
1663 .with_body(
1664 serde_json::json!({
1665 "protocol":{"major":1},
1666 "features":{"blob_packs":true},
1667 "limits":{"max_batch_items":100,"max_pack_bytes":1024}
1668 })
1669 .to_string(),
1670 )
1671 .expect(1)
1672 .create_async()
1673 .await;
1674 let request = server
1675 .mock("POST", "/v1/blobs:pack")
1676 .with_status(404)
1677 .expect(1)
1678 .create_async()
1679 .await;
1680 let client = test_client(&server);
1681 let staging = tempfile::tempdir().unwrap();
1682 let digest = CacheDigest::blake3(b"blob");
1683
1684 assert!(
1685 client
1686 .get_blob_pack(std::slice::from_ref(&digest), staging.path())
1687 .await
1688 .unwrap()
1689 .is_none()
1690 );
1691 assert!(
1692 client
1693 .get_blob_pack(&[digest], staging.path())
1694 .await
1695 .unwrap()
1696 .is_none()
1697 );
1698 capabilities.assert_async().await;
1699 request.assert_async().await;
1700 }
1701
1702 #[tokio::test]
1703 async fn rejects_truncated_blob_pack_frames() {
1704 let mut server = mockito::Server::new_async().await;
1705 let contents = b"complete blob";
1706 let digest = CacheDigest::blake3(contents);
1707 let mut pack = encode_blob_pack(&[(&digest, contents.as_slice())]);
1708 pack.truncate(pack.len() - 3);
1709 mock_blob_pack_capabilities(&mut server).await;
1710 server
1711 .mock("POST", "/v1/blobs:pack")
1712 .with_status(200)
1713 .with_header("content-type", BLOB_PACK_MEDIA_TYPE)
1714 .with_body(pack)
1715 .create_async()
1716 .await;
1717 let client = test_client(&server);
1718 let staging = tempfile::tempdir().unwrap();
1719
1720 let error = match client.get_blob_pack(&[digest], staging.path()).await {
1721 Err(error) => error,
1722 Ok(_) => panic!("truncated pack should be rejected"),
1723 };
1724
1725 assert!(
1726 error
1727 .to_string()
1728 .contains("ended before a blob was complete")
1729 );
1730 }
1731
1732 #[tokio::test]
1733 async fn rejects_blob_pack_frames_with_corrupt_content() {
1734 let mut server = mockito::Server::new_async().await;
1735 let digest = CacheDigest::blake3(b"expected");
1736 let corrupt = b"corrupt!";
1737 let pack = encode_blob_pack(&[(&digest, corrupt.as_slice())]);
1738 mock_blob_pack_capabilities(&mut server).await;
1739 server
1740 .mock("POST", "/v1/blobs:pack")
1741 .with_status(200)
1742 .with_header("content-type", BLOB_PACK_MEDIA_TYPE)
1743 .with_body(pack)
1744 .create_async()
1745 .await;
1746 let client = test_client(&server);
1747 let staging = tempfile::tempdir().unwrap();
1748
1749 let error = match client.get_blob_pack(&[digest], staging.path()).await {
1750 Err(error) => error,
1751 Ok(_) => panic!("corrupt pack should be rejected"),
1752 };
1753
1754 assert!(error.to_string().contains("failed digest verification"));
1755 }
1756
1757 #[test]
1758 fn blob_pack_chunk_honors_item_and_byte_limits() {
1759 let first = CacheDigest::blake3(b"1234");
1760 let second = CacheDigest::blake3(b"5678");
1761 let oversized = CacheDigest::blake3(b"123456789");
1762 let chunk = blob_pack_chunk(
1763 &[first.clone(), second.clone(), first.clone(), oversized],
1764 BlobPackLimits {
1765 max_items: 10,
1766 max_bytes: 7,
1767 },
1768 )
1769 .unwrap();
1770
1771 assert_eq!(chunk, vec![first]);
1772
1773 let chunk = blob_pack_chunk(
1774 &[CacheDigest::blake3(b"a"), CacheDigest::blake3(b"b")],
1775 BlobPackLimits {
1776 max_items: 1,
1777 max_bytes: 100,
1778 },
1779 )
1780 .unwrap();
1781 assert_eq!(chunk.len(), 1);
1782 }
1783
1784 #[test]
1785 fn blob_pack_timeout_scales_with_declared_work() {
1786 let base = Duration::from_secs(10);
1787 let small = CacheDigest::blake3(b"small");
1788 assert_eq!(blob_pack_download_timeout(base, &[small]), base);
1789
1790 let large = CacheDigest {
1791 algorithm: "blake3".into(),
1792 hash: "0".repeat(64),
1793 size: MAX_STAGED_BLOB_PACK_BYTES,
1794 };
1795 assert_eq!(
1796 blob_pack_download_timeout(base, &[large]),
1797 base.saturating_mul(4)
1798 );
1799
1800 let many = (0..=BLOB_PACK_TIMEOUT_ITEMS_PER_UNIT)
1801 .map(|index| CacheDigest::blake3(index.to_string().as_bytes()))
1802 .collect::<Vec<_>>();
1803 assert_eq!(
1804 blob_pack_download_timeout(base, &many),
1805 base.saturating_mul(2)
1806 );
1807 }
1808
1809 #[test]
1810 fn bearer_authorization_headers_are_sensitive() {
1811 let header = authorization_header(Some(" test-token ")).unwrap().unwrap();
1812 assert_eq!(header, "Bearer test-token");
1813 assert!(header.is_sensitive());
1814 assert!(authorization_header(Some(" ")).unwrap().is_none());
1815 }
1816
1817 #[tokio::test]
1818 async fn rejects_blob_larger_than_its_digest() {
1819 let mut server = mockito::Server::new_async().await;
1820 let digest = CacheDigest::blake3(b"small");
1821 let endpoint = format!(
1822 "/v{PROTOCOL_VERSION}/blobs/{}/{}/{}",
1823 digest.algorithm, digest.hash, digest.size
1824 );
1825 server
1826 .mock("GET", endpoint.as_str())
1827 .with_status(200)
1828 .with_header("content-type", BLOB_MEDIA_TYPE)
1829 .with_body(vec![b'x'; 4096])
1830 .expect(2)
1831 .create_async()
1832 .await;
1833 let client = test_client(&server);
1834 let staging = tempfile::tempdir().unwrap();
1835
1836 let buffered = client
1837 .get_blob(&digest, BLOB_MEDIA_TYPE)
1838 .await
1839 .err()
1840 .unwrap();
1841 let streamed = client
1842 .get_blob_file(&digest, staging.path())
1843 .await
1844 .err()
1845 .unwrap();
1846
1847 for error in [buffered, streamed] {
1848 assert!(
1849 error
1850 .to_string()
1851 .contains("exceeded the size of its digest"),
1852 "unexpected error: {error}"
1853 );
1854 }
1855 }
1856
1857 #[tokio::test]
1858 async fn rejects_oversized_capabilities() {
1859 let mut server = mockito::Server::new_async().await;
1860 server
1861 .mock("GET", format!("/v{PROTOCOL_VERSION}/capabilities").as_str())
1862 .with_status(200)
1863 .with_body(vec![b'x'; MAX_REMOTE_JSON_BYTES as usize + 1])
1864 .create_async()
1865 .await;
1866
1867 let error = test_client(&server)
1870 .blob_pack_limits()
1871 .await
1872 .err()
1873 .unwrap()
1874 .to_string();
1875 assert!(
1876 error.contains("over the") || error.contains("exceeded the"),
1877 "unexpected error: {error}"
1878 );
1879 }
1880
1881 #[tokio::test]
1882 async fn rejects_action_json_larger_than_the_limit() {
1883 let mut server = mockito::Server::new_async().await;
1884 let key = CacheDigest::blake3(b"action");
1885 let oversized = vec![b'x'; MAX_REMOTE_JSON_BYTES as usize + 1];
1886 for kind in ["action-results", "action-manifests"] {
1887 server
1888 .mock(
1889 "GET",
1890 format!(
1891 "/v{PROTOCOL_VERSION}/{kind}/{}/{}/{}",
1892 key.algorithm, key.hash, key.size
1893 )
1894 .as_str(),
1895 )
1896 .with_status(200)
1897 .with_header("etag", &format!("\"{}\"", blake3::hash(b"any").to_hex()))
1900 .with_body(oversized.clone())
1901 .create_async()
1902 .await;
1903 }
1904 let client = test_client(&server);
1905
1906 for error in [
1909 client.get_action_result(&key).await.err().unwrap(),
1910 client.get_action_manifest(&key).await.err().unwrap(),
1911 ] {
1912 assert!(
1913 error.to_string().contains("over the")
1914 || error.to_string().contains("exceeded the"),
1915 "unexpected error: {error}"
1916 );
1917 }
1918 }
1919
1920 fn test_client(server: &mockito::ServerGuard) -> RemoteCacheClient {
1921 RemoteCacheClient::new(RemoteCacheConfig {
1922 base_url: server.url().parse().unwrap(),
1923 namespace: "test".into(),
1924 token: Some("test-token".into()),
1925 token_file: None,
1926 oidc_audience: None,
1927 connect_timeout: Duration::from_secs(1),
1928 read_timeout: Duration::from_secs(1),
1929 download_timeout: Duration::from_secs(1),
1930 retries: 0,
1931 })
1932 .unwrap()
1933 }
1934
1935 async fn mock_blob_pack_capabilities(server: &mut mockito::ServerGuard) {
1936 server
1937 .mock("GET", "/v1/capabilities")
1938 .with_status(200)
1939 .with_header("content-type", "application/json")
1940 .with_body(
1941 serde_json::json!({
1942 "protocol":{"major":1},
1943 "features":{"blob_packs":true},
1944 "limits":{"max_batch_items":100,"max_pack_bytes":1024}
1945 })
1946 .to_string(),
1947 )
1948 .create_async()
1949 .await;
1950 }
1951
1952 fn encode_blob_pack(entries: &[(&CacheDigest, &[u8])]) -> Vec<u8> {
1953 let mut pack = BLOB_PACK_MAGIC.to_vec();
1954 for (digest, contents) in entries {
1955 assert_eq!(digest.size, contents.len() as u64);
1956 pack.push(match digest.algorithm.as_str() {
1957 "blake3" => 1,
1958 "sha256" => 2,
1959 algorithm => panic!("unexpected test digest algorithm {algorithm}"),
1960 });
1961 pack.extend(hex::decode(&digest.hash).unwrap());
1962 pack.extend(digest.size.to_be_bytes());
1963 pack.extend_from_slice(contents);
1964 }
1965 pack
1966 }
1967
1968 #[tokio::test]
1969 async fn token_file_credentials_are_reloaded() {
1970 let directory = tempfile::tempdir().unwrap();
1971 let path = directory.path().join("cache-token");
1972 fs::write(&path, "first-token\n").unwrap();
1973 let credential = RemoteCacheCredential::File(path.clone());
1974
1975 let first = credential.authorization().await.unwrap().unwrap();
1976 assert_eq!(first, "Bearer first-token");
1977 assert!(first.is_sensitive());
1978
1979 fs::write(path, "rotated-token\n").unwrap();
1980 let rotated = credential.authorization().await.unwrap().unwrap();
1981 assert_eq!(rotated, "Bearer rotated-token");
1982 }
1983
1984 #[tokio::test]
1985 async fn github_actions_oidc_tokens_are_acquired_and_cached() {
1986 let mut server = mockito::Server::new_async().await;
1987 let expires_at = unix_timestamp().unwrap() + 3600;
1988 let token = test_jwt(expires_at);
1989 let token_response = serde_json::json!({"value":token}).to_string();
1990 let request = server
1991 .mock("GET", "/oidc")
1992 .match_query(mockito::Matcher::UrlEncoded(
1993 "audience".into(),
1994 "https://cache.example.com".into(),
1995 ))
1996 .match_header("authorization", "Bearer request-secret")
1997 .with_status(200)
1998 .with_header("content-type", "application/json")
1999 .with_body(token_response)
2000 .expect(1)
2001 .create_async()
2002 .await;
2003 let credential = GithubActionsOidcCredential::new(
2004 "https://cache.example.com",
2005 format!("{}/oidc?api-version=1&audience=old", server.url())
2006 .parse()
2007 .unwrap(),
2008 "request-secret",
2009 reqwest::Client::new(),
2010 0,
2011 )
2012 .unwrap();
2013 assert_eq!(
2014 credential.request_url.query_pairs().collect::<Vec<_>>(),
2015 vec![
2016 ("api-version".into(), "1".into()),
2017 ("audience".into(), "https://cache.example.com".into()),
2018 ]
2019 );
2020
2021 let first = credential.authorization().await.unwrap();
2022 let second = credential.authorization().await.unwrap();
2023
2024 assert_eq!(first, format!("Bearer {token}"));
2025 assert_eq!(first, second);
2026 assert!(first.is_sensitive());
2027 request.assert_async().await;
2028 }
2029
2030 #[test]
2031 fn oidc_request_urls_require_https_except_for_loopback() {
2032 validate_oidc_request_url(&"https://example.com/oidc".parse().unwrap()).unwrap();
2033 validate_oidc_request_url(&"http://127.0.0.1:3000/oidc".parse().unwrap()).unwrap();
2034 assert!(validate_oidc_request_url(&"http://example.com/oidc".parse().unwrap()).is_err());
2035 }
2036
2037 fn test_jwt(expires_at: u64) -> String {
2038 let header = URL_SAFE_NO_PAD.encode(b"{}");
2039 let claims = URL_SAFE_NO_PAD
2040 .encode(serde_json::to_vec(&serde_json::json!({"exp":expires_at})).unwrap());
2041 format!("{header}.{claims}.signature")
2042 }
2043
2044 #[test]
2045 fn remote_urls_require_https_for_authenticated_requests() {
2046 for url in [
2047 "http://localhost:3000",
2048 "http://127.0.0.1:3000",
2049 "http://[::1]:3000",
2050 "https://cache.example.com",
2051 ] {
2052 validate_remote_url(&url.parse().unwrap(), true).unwrap();
2053 }
2054 let insecure: Url = "http://cache.example.com".parse().unwrap();
2055 assert!(validate_remote_url(&insecure, true).is_err());
2056 validate_remote_url(&insecure, false).unwrap();
2057 assert!(validate_remote_url(&"ftp://localhost/cache".parse().unwrap(), false).is_err());
2058 }
2059}