Skip to main content

mise_cache_core/
lib.rs

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