Skip to main content

mbx_cache_core/
lib.rs

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;
46/// Cap the JSON bodies a remote cache can hand back. Blob downloads are bounded
47/// by the size their digest promises, but action results and manifests carry no
48/// such claim, so without an explicit ceiling a hostile or broken server can
49/// stream until this process runs out of memory -- for manifests, long before
50/// `validate_task_manifest` ever sees the payload. The bound matches the agent's
51/// own request ceiling so both ends of the protocol refuse the same magnitude.
52const 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
58/// Serialize a protocol object using the JSON Canonicalization Scheme.
59///
60/// Action digests are computed from these bytes, so callers must not use
61/// serde's struct field order as part of the wire contract.
62pub 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    /// Hash a file while counting the bytes read in the same streaming pass.
126    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/// A canonical action-result record referencing objects in the CAS.
207#[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/// A canonical directory object stored in the CAS.
219#[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/// A child directory entry in a canonical cache directory.
229#[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/// A file entry in a canonical cache directory.
238#[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/// A symbolic-link entry in a canonical cache directory.
248#[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/// Rust-specific action metadata stored alongside compiled outputs.
257#[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
282/// A verified set of remote CAS objects downloaded through blob-pack streams.
283pub 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    /// Content codings the server accepts and produces; always includes
379    /// `identity`, and compression is used only when `zstd` is offered.
380    #[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/// What one capabilities exchange settled, cached for the session.
410///
411/// `Default` is also the answer for a server with no capabilities endpoint:
412/// no blob packs and no compression, which is exactly how every request
413/// behaved before either feature existed.
414#[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                // Compression is negotiated, never assumed: a body sent with a
564                // coding the server did not offer would be stored corrupt or
565                // rejected, so absence of the advertisement means identity.
566                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    /// Download verified CAS objects using the server's negotiated blob-pack extension.
602    ///
603    /// `None` means the server does not support blob packs. Objects omitted by a
604    /// supported server are absent from `blobs`, so callers can retry them through
605    /// the ordinary single-blob endpoint.
606    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            // Stop reading as soon as the response outgrows the digest it claims
814            // to satisfy. A server that streams more than it promised must not be
815            // able to exhaust this process before verification rejects it.
816            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            // Bound the download by the digest's own size so an oversized
848            // response cannot fill the disk before verification rejects it.
849            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        // A failed negotiation downgrades to identity rather than failing the
872        // upload: compression is an economy, not a requirement.
873        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                // Compressed and therefore chunked: the length of the encoded
886                // stream is not known up front, and the digest already tells
887                // the server the decompressed size it must enforce.
888                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
964/// Buffer a JSON response body, refusing to grow past [`MAX_REMOTE_JSON_BYTES`].
965/// A declared `Content-Length` is rejected up front so an oversized body costs
966/// nothing to refuse; the streaming check then covers servers that understate or
967/// omit it.
968async 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
1383/// hyper-util exposes DNS failures in the error chain as a `dns error` source,
1384/// but reqwest intentionally erases the concrete connector type. Match that
1385/// stable connector error label rather than platform-specific resolver text.
1386fn 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    // An unavailable hostname is a deterministic configuration error. reqwest
1399    // categorizes it as a connect error, but retrying only delays the diagnosis.
1400    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        // Negotiation runs before any other request, so an unbounded body here
1927        // would exhaust the process before the other limits ever apply.
1928        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                // The manifest path parses the ETag before the body, so the
1957                // limit only gets its say once a well-formed one is present.
1958                .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        // Neither endpoint's body is bounded by a digest, so the limit is the
1966        // only thing standing between a hostile server and this process's memory.
1967        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                // Smaller on the wire, and decoding returns the exact payload:
2003                // the compression is real, not just a header.
2004                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        // Digest verification runs on what the transport hands back, so this
2073        // passes only if the zstd body was transparently decompressed.
2074        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}