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_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;
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}
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    /// Download verified CAS objects using the server's negotiated blob-pack extension.
569    ///
570    /// `None` means the server does not support blob packs. Objects omitted by a
571    /// supported server are absent from `blobs`, so callers can retry them through
572    /// the ordinary single-blob endpoint.
573    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            // Stop reading as soon as the response outgrows the digest it claims
781            // to satisfy. A server that streams more than it promised must not be
782            // able to exhaust this process before verification rejects it.
783            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            // Bound the download by the digest's own size so an oversized
815            // response cannot fill the disk before verification rejects it.
816            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
905/// Buffer a JSON response body, refusing to grow past [`MAX_REMOTE_JSON_BYTES`].
906/// A declared `Content-Length` is rejected up front so an oversized body costs
907/// nothing to refuse; the streaming check then covers servers that understate or
908/// omit it.
909async 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
1324/// hyper-util exposes DNS failures in the error chain as a `dns error` source,
1325/// but reqwest intentionally erases the concrete connector type. Match that
1326/// stable connector error label rather than platform-specific resolver text.
1327fn 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    // An unavailable hostname is a deterministic configuration error. reqwest
1340    // categorizes it as a connect error, but retrying only delays the diagnosis.
1341    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        // Negotiation runs before any other request, so an unbounded body here
1868        // would exhaust the process before the other limits ever apply.
1869        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                // The manifest path parses the ETag before the body, so the
1898                // limit only gets its say once a well-formed one is present.
1899                .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        // Neither endpoint's body is bounded by a digest, so the limit is the
1907        // only thing standing between a hostile server and this process's memory.
1908        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}