1#![deny(missing_docs)]
30
31use eyre::{Result, bail, eyre};
32use log::warn;
33use reqwest::header::HeaderValue;
34use serde::{Deserialize, Serialize};
35use std::path::{Path, PathBuf};
36use std::time::{Duration, Instant};
37use url::Url;
38
39mod agent;
40mod client;
41mod local;
42mod remote_http;
43mod remote_s3;
44mod sigv4;
45mod uploads;
46
47pub use agent::{
48 AGENT_PROTOCOL_VERSION, AgentEvent, AgentEventObserver, AgentRemoteCache, AgentRequest,
49 AgentResponse, AgentStats, CacheAgent, CompilerStats, FileDigestCache, FileDigestScope,
50 FileIdentity, NoFileDigestCache, RecordedFileDigest, RestoreStats, is_task_identity,
51 task_manifest_actions,
52};
53pub use client::BlockingAgentClient;
54pub use local::{LocalActionCache, LocalCas};
55pub use mbx_cache_protocol::{
56 ACTION_RESULT_BATCH_MEDIA_TYPE, ACTION_RESULT_MEDIA_TYPE, ActionPrediction,
57 ActionResult as RemoteActionResult, BLOB_MEDIA_TYPE, BLOB_PACK_BLOBS_HEADER,
58 BLOB_PACK_BYTES_HEADER, BLOB_PACK_HEADER_BYTES, BLOB_PACK_MAGIC, BLOB_PACK_MEDIA_TYPE,
59 BLOB_PACK_RECEIPT_MEDIA_TYPE, CLIENT_METADATA_MEDIA_TYPE, Capabilities, CapabilityFeatures,
60 CapabilityLimits, CapabilityProtocol, CcMetadata, DIGEST_LIST_MEDIA_TYPE, DIRECTORY_MEDIA_TYPE,
61 Digest as CacheDigest, DigestAlgorithm, Directory as CacheDirectory,
62 DirectoryNode as CacheDirectoryNode, FileNode as CacheFileNode, MAX_ACTION_PREDICTION_PAYLOAD,
63 NAMESPACE_HEADER, PROTOCOL_HEADER, PROTOCOL_VERSION, RustcMetadata,
64 SymlinkNode as CacheSymlinkNode, TASK_ACTION_MANIFEST_MEDIA_TYPE, TaskActionManifest,
65};
66use remote_http::HttpRemoteCache;
67#[cfg(feature = "fuzzing")]
68#[doc(hidden)]
69pub use remote_http::fuzz_decode_blob_pack;
70pub(crate) use remote_http::{BlobPackLimits, blob_pack_chunk};
71use remote_s3::S3RemoteCache;
72pub use remote_s3::{S3ConditionalWrites, S3RemoteCacheConfig};
73pub use sigv4::S3Credentials;
74const MAX_REMOTE_JSON_BYTES: u64 = 16 * 1024 * 1024;
81const MAX_ETAG_BYTES: usize = 256;
86const MAX_REMOTE_BLOB_BYTES: u64 = 5 * 1024 * 1024 * 1024;
89const MAX_STAGED_BLOB_PACK_BYTES: u64 = 256 * 1024 * 1024;
90const MAX_STAGED_BLOB_PACK_ITEMS: usize = 2 * 1024;
91const BLOB_PACK_TIMEOUT_BYTES_PER_UNIT: u64 = MAX_STAGED_BLOB_PACK_BYTES / 4;
92const BLOB_PACK_TIMEOUT_ITEMS_PER_UNIT: usize = MAX_STAGED_BLOB_PACK_ITEMS / 4;
93const PACK_STREAM_CHUNK_BYTES: usize = 64 * 1024;
95const MAX_ACTION_BATCH_ITEMS: usize = 256;
101const MAX_ACTION_RESULT_BYTES: u64 = 64 * 1024;
103
104pub fn canonical_json(value: &impl Serialize) -> Result<Vec<u8>> {
109 Ok(mbx_cache_protocol::canonical_json(value)?)
110}
111
112#[derive(
113 Debug,
114 Clone,
115 Copy,
116 Serialize,
117 Deserialize,
118 Default,
119 strum::EnumString,
120 strum::Display,
121 PartialEq,
122 Eq,
123)]
124#[serde(rename_all = "kebab-case")]
125#[strum(serialize_all = "kebab-case")]
126pub enum RemoteCacheMode {
128 #[default]
130 ReadWrite,
131 ReadOnly,
133 WriteOnly,
135}
136
137impl RemoteCacheMode {
138 pub fn reads(self) -> bool {
140 matches!(self, Self::ReadWrite | Self::ReadOnly)
141 }
142
143 pub fn writes(self) -> bool {
145 matches!(self, Self::ReadWrite | Self::WriteOnly)
146 }
147}
148
149pub struct RemoteCacheConfig {
151 pub base_url: Url,
153 pub namespace: String,
155 pub token: Option<String>,
157 pub token_file: Option<PathBuf>,
159 pub oidc_audience: Option<String>,
161 pub connect_timeout: Duration,
163 pub read_timeout: Duration,
165 pub download_timeout: Duration,
174 pub retries: i64,
176}
177
178pub enum BlobSource {
180 Bytes(Vec<u8>),
182 File(tempfile::NamedTempFile),
184 Path(PathBuf),
186}
187
188pub struct BlobUpload {
190 pub digest: CacheDigest,
192 pub source: BlobSource,
194}
195
196pub struct RemoteActionManifest {
198 pub bytes: Vec<u8>,
200 pub etag: String,
202}
203
204pub struct RemoteBlobPack {
206 _directory: tempfile::TempDir,
207 pub blobs: Vec<(CacheDigest, PathBuf)>,
209 pub requests: u64,
211 pub requested: Vec<CacheDigest>,
213 pub blob_count: u64,
215 pub payload_bytes: u64,
217 pub framed_bytes: u64,
219}
220
221#[derive(Debug, Clone, Copy, Deserialize)]
223pub struct BlobPackReceipt {
224 #[serde(default)]
226 pub created: u64,
227 #[serde(default)]
229 pub existing: u64,
230}
231
232#[derive(Debug, Clone, Copy, PartialEq, Eq)]
233pub enum ManifestPutOutcome {
235 Stored,
237 PreconditionFailed,
239}
240
241pub struct RemoteCacheClient {
251 backend: Backend,
252}
253
254enum Backend {
260 Http(HttpRemoteCache),
261 S3(S3RemoteCache),
262}
263
264impl RemoteCacheClient {
265 pub fn new(config: RemoteCacheConfig) -> Result<Self> {
267 Ok(Self {
268 backend: Backend::Http(HttpRemoteCache::new(config)?),
269 })
270 }
271
272 pub fn new_s3(config: S3RemoteCacheConfig) -> Result<Self> {
277 Ok(Self {
278 backend: Backend::S3(S3RemoteCache::new(config)?),
279 })
280 }
281
282 pub async fn check_connection(&self) -> Result<()> {
288 match &self.backend {
289 Backend::Http(client) => client.check_connection().await,
290 Backend::S3(store) => store.check_connection().await,
291 }
292 }
293
294 pub async fn get_blob_pack(
300 &self,
301 digests: &[CacheDigest],
302 staging_dir: &Path,
303 ) -> Result<Option<RemoteBlobPack>> {
304 match &self.backend {
305 Backend::Http(client) => client.get_blob_pack(digests, staging_dir).await,
306 Backend::S3(store) => store.get_blob_pack(digests, staging_dir).await,
307 }
308 }
309
310 pub(crate) async fn get_blob_pack_with_limit(
311 &self,
312 digests: &[CacheDigest],
313 staging_dir: &Path,
314 max_bytes: u64,
315 ) -> Result<Option<RemoteBlobPack>> {
316 match &self.backend {
317 Backend::Http(client) => {
318 client
319 .get_blob_pack_with_limit(digests, staging_dir, max_bytes)
320 .await
321 }
322 Backend::S3(store) => {
323 store
324 .get_blob_pack_with_limit(digests, staging_dir, max_bytes)
325 .await
326 }
327 }
328 }
329
330 pub async fn get_action_result(
332 &self,
333 action: &CacheDigest,
334 ) -> Result<Option<RemoteActionResult>> {
335 match &self.backend {
336 Backend::Http(client) => client.get_action_result(action).await,
337 Backend::S3(store) => store.get_action_result(action).await,
338 }
339 }
340
341 pub(crate) async fn action_batch_limit(&self) -> Result<Option<usize>> {
345 match &self.backend {
346 Backend::Http(client) => client.action_batch_limit().await,
347 Backend::S3(store) => store.action_batch_limit().await,
348 }
349 }
350
351 pub async fn get_action_results(
358 &self,
359 actions: &[CacheDigest],
360 ) -> Result<Option<Vec<RemoteActionResult>>> {
361 match &self.backend {
362 Backend::Http(client) => client.get_action_results(actions).await,
363 Backend::S3(store) => store.get_action_results(actions).await,
364 }
365 }
366
367 pub async fn put_action_result(&self, result: &RemoteActionResult) -> Result<()> {
369 match &self.backend {
370 Backend::Http(client) => client.put_action_result(result).await,
371 Backend::S3(store) => store.put_action_result(result).await,
372 }
373 }
374
375 pub async fn get_action_manifest(
377 &self,
378 key: &CacheDigest,
379 ) -> Result<Option<RemoteActionManifest>> {
380 match &self.backend {
381 Backend::Http(client) => client.get_action_manifest(key).await,
382 Backend::S3(store) => store.get_action_manifest(key).await,
383 }
384 }
385
386 pub async fn put_action_manifest(
388 &self,
389 key: &CacheDigest,
390 bytes: &[u8],
391 expected_etag: Option<&str>,
392 ) -> Result<ManifestPutOutcome> {
393 match &self.backend {
394 Backend::Http(client) => client.put_action_manifest(key, bytes, expected_etag).await,
395 Backend::S3(store) => store.put_action_manifest(key, bytes, expected_etag).await,
396 }
397 }
398
399 pub async fn get_blob(
401 &self,
402 digest: &CacheDigest,
403 media_type: &'static str,
404 ) -> Result<Vec<u8>> {
405 match &self.backend {
406 Backend::Http(client) => client.get_blob(digest, media_type).await,
407 Backend::S3(store) => store.get_blob(digest, media_type).await,
408 }
409 }
410
411 pub async fn get_blob_file(
413 &self,
414 digest: &CacheDigest,
415 staging_dir: &Path,
416 ) -> Result<tempfile::NamedTempFile> {
417 match &self.backend {
418 Backend::Http(client) => client.get_blob_file(digest, staging_dir).await,
419 Backend::S3(store) => store.get_blob_file(digest, staging_dir).await,
420 }
421 }
422
423 pub(crate) async fn blob_pack_upload_limits(&self) -> Result<Option<BlobPackLimits>> {
427 match &self.backend {
428 Backend::Http(client) => client.blob_pack_upload_limits().await,
429 Backend::S3(store) => store.blob_pack_upload_limits().await,
430 }
431 }
432
433 pub async fn put_blob_pack(&self, uploads: &[BlobUpload]) -> Result<Option<BlobPackReceipt>> {
441 match &self.backend {
442 Backend::Http(client) => client.put_blob_pack(uploads).await,
443 Backend::S3(store) => store.put_blob_pack(uploads).await,
444 }
445 }
446
447 pub async fn put_blob(&self, upload: &BlobUpload) -> Result<()> {
449 match &self.backend {
450 Backend::Http(client) => client.put_blob(upload).await,
451 Backend::S3(store) => store.put_blob(upload).await,
452 }
453 }
454}
455
456async fn read_bounded_json(response: reqwest::Response, what: &str) -> Result<Vec<u8>> {
462 read_json_within(response, what, MAX_REMOTE_JSON_BYTES).await
463}
464
465async fn read_json_within(response: reqwest::Response, what: &str, limit: u64) -> Result<Vec<u8>> {
466 if let Some(length) = response.content_length()
467 && length > limit
468 {
469 bail!("remote cache {what} declared {length} bytes, over the {limit} byte limit");
470 }
471 let mut response = response;
472 let mut bytes = Vec::new();
473 while let Some(chunk) = response.chunk().await? {
474 if bytes.len() as u64 + chunk.len() as u64 > limit {
475 bail!("remote cache {what} exceeded the {limit} byte limit");
476 }
477 bytes.extend_from_slice(&chunk);
478 }
479 Ok(bytes)
480}
481fn parse_strong_etag(value: Option<&HeaderValue>) -> Result<String> {
490 let value = value
491 .and_then(|value| value.to_str().ok())
492 .ok_or_else(|| eyre!("remote action manifest response is missing an ETag"))?;
493 if value.starts_with("W/") {
494 bail!("remote action manifest response has a weak ETag");
497 }
498 let etag = value
499 .strip_prefix('"')
500 .and_then(|value| value.strip_suffix('"'))
501 .filter(|value| is_entity_tag(value))
502 .ok_or_else(|| eyre!("remote action manifest response has an invalid ETag"))?;
503 Ok(etag.to_owned())
504}
505
506fn quoted_etag(etag: &str) -> Result<HeaderValue> {
507 if !is_entity_tag(etag) {
508 bail!("invalid remote action manifest ETag");
509 }
510 Ok(HeaderValue::from_str(&format!("\"{etag}\""))?)
511}
512
513fn is_entity_tag(value: &str) -> bool {
518 !value.is_empty()
519 && value.len() <= MAX_ETAG_BYTES
520 && value.bytes().all(|byte| matches!(byte, 0x21 | 0x23..=0x7e))
521}
522fn retry_delays(retries: i64) -> impl Iterator<Item = Duration> {
523 [200u64, 1_000, 4_000, 15_000]
524 .into_iter()
525 .chain(std::iter::repeat(15_000))
526 .map(Duration::from_millis)
527 .map(|duration| {
528 let factor = 0.5 + rand::random::<f64>() * 0.5;
529 Duration::from_secs_f64(duration.as_secs_f64() * factor)
530 })
531 .take(retries.max(0) as usize)
532}
533
534fn is_dns_error(error: &(dyn std::error::Error + 'static)) -> bool {
538 let mut current = Some(error);
539 while let Some(source) = current {
540 if source.to_string() == "dns error" {
541 return true;
542 }
543 current = source.source();
544 }
545 false
546}
547
548#[derive(Debug)]
555pub(crate) struct TransientRequest(pub(crate) &'static str);
556
557impl std::fmt::Display for TransientRequest {
558 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
559 formatter.write_str(self.0)
560 }
561}
562
563impl std::error::Error for TransientRequest {}
564
565fn is_transient(error: &eyre::Report) -> bool {
566 if is_dns_error(error.as_ref()) {
569 return false;
570 }
571 error.chain().any(|source| {
572 if source.downcast_ref::<TransientRequest>().is_some() {
573 return true;
574 }
575 let Some(error) = source.downcast_ref::<reqwest::Error>() else {
576 return false;
577 };
578 if error.is_timeout() || error.is_connect() || error.is_body() {
579 return true;
580 }
581 error.status().is_some_and(|status| {
582 let status = status.as_u16();
583 status == 408 || status == 429 || (500..600).contains(&status)
584 })
585 })
586}
587
588async fn retry_async<F, Fut, T>(verb: &str, url: &Url, retries: i64, mut operation: F) -> Result<T>
589where
590 F: FnMut() -> Fut,
591 Fut: std::future::Future<Output = Result<T>>,
592{
593 let mut delays = retry_delays(retries);
594 let mut attempt = 1;
595 loop {
596 let started_at = Instant::now();
597 match operation().await {
598 Ok(value) => return Ok(value),
599 Err(error) if is_transient(&error) => {
600 let Some(delay) = delays.next() else {
601 return Err(error);
602 };
603 warn!(
604 "HTTP {verb} {url} attempt {attempt} failed after {:?} (transient): {error}; retrying in {delay:?}",
605 started_at.elapsed()
606 );
607 tokio::time::sleep(delay).await;
608 attempt += 1;
609 }
610 Err(error) => return Err(error),
611 }
612 }
613}