Skip to main content

mbx_cache_core/
remote_s3.rs

1//! The remote cache backed directly by an S3-compatible object store.
2//!
3//! This is the other backend behind [`crate::RemoteCacheClient`]. Where the
4//! protocol server answers lookups, validates uploads, and advertises
5//! extensions, a bucket only stores objects. The v1 protocol is designed for
6//! that: blobs and action results are immutable and content-addressed, so they
7//! are written create-only and re-storing one is not an error, and the single
8//! record that is updated in place -- the task action manifest -- carries an
9//! entity tag that S3's conditional writes can honour.
10//!
11//! What a bucket cannot do it declines rather than approximates. Blob packs,
12//! batched lookups, and negotiated compression all report themselves absent,
13//! which is the same answer the client already handles from a server that does
14//! not implement them.
15
16use crate::sigv4::{PayloadHash, S3Credentials, SigningContext, sign};
17use crate::{
18    BlobPackReceipt, BlobSource, BlobUpload, CacheDigest, MAX_REMOTE_BLOB_BYTES,
19    MAX_REMOTE_JSON_BYTES, ManifestPutOutcome, RemoteActionManifest, RemoteActionResult,
20    RemoteBlobPack, TransientRequest, parse_strong_etag, quoted_etag, read_bounded_json,
21    retry_async,
22};
23use eyre::{Result, bail, eyre};
24use log::warn;
25use reqwest::StatusCode;
26use reqwest::header::{CONTENT_LENGTH, ETAG, IF_MATCH, IF_NONE_MATCH};
27use std::fs;
28use std::path::Path;
29use std::sync::atomic::{AtomicBool, Ordering};
30use std::time::{Duration, SystemTime};
31use tokio::io::AsyncWriteExt;
32use url::Url;
33
34/// The object-key layout this client reads and writes.
35///
36/// Independent of the protocol version, which describes a wire format rather
37/// than a bucket layout, though today they are both 1.
38const LAYOUT_VERSION: u8 = 1;
39/// Object read to prove an endpoint answers, credentials work, and the bucket
40/// exists. It is never written, so a diagnostic stays read-only.
41const CONNECTIVITY_PROBE_KEY: &str = "connectivity-probe";
42/// Bytes of an S3 error document read before giving up on a diagnosis.
43const MAX_ERROR_BODY_BYTES: usize = 8 * 1024;
44
45/// Whether conditional writes are required, refused, or tried and given up on.
46#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, strum::EnumString, strum::Display)]
47#[strum(serialize_all = "kebab-case")]
48pub enum S3ConditionalWrites {
49    /// Use conditional writes, and stop using them if the store rejects them.
50    #[default]
51    Auto,
52    /// Require conditional writes, failing if the store does not implement them.
53    Required,
54    /// Never send conditional headers.
55    Off,
56}
57
58/// Connection, addressing, and credential settings for an S3 remote cache.
59pub struct S3RemoteCacheConfig {
60    /// Bucket holding the cache.
61    pub bucket: String,
62    /// Key prefix within the bucket, empty for the bucket root.
63    pub prefix: String,
64    /// Namespace isolating one project's cache, used as a key prefix.
65    pub namespace: String,
66    /// Region used to sign requests.
67    pub region: String,
68    /// Endpoint override for a non-AWS store such as MinIO or R2.
69    pub endpoint: Option<Url>,
70    /// Force bucket-in-path addressing, overriding what the endpoint implies.
71    pub force_path_style: Option<bool>,
72    /// How to treat a store that does not implement conditional writes.
73    pub conditional_writes: S3ConditionalWrites,
74    /// Credentials used to sign requests.
75    pub credentials: S3Credentials,
76    /// Maximum time allowed to establish a connection.
77    pub connect_timeout: Duration,
78    /// Maximum time without response progress for ordinary requests.
79    pub read_timeout: Duration,
80    /// Deadline for one blob download, spanning every retry attempt and the
81    /// backoff between them rather than bounding a single attempt.
82    ///
83    /// A single stalled attempt is already bounded by `connect_timeout` and
84    /// `read_timeout`, so this budget exists to cap the total wall-clock one
85    /// logical download may spend: exhausting it fails the download even when
86    /// retries remain. Size it for the largest artifact worth waiting on, not
87    /// for one attempt at it.
88    pub download_timeout: Duration,
89    /// Number of attempts after the initial request for retryable failures.
90    pub retries: i64,
91}
92
93/// Kinds of object the cache stores, which are also their key prefixes.
94#[derive(Clone, Copy)]
95enum ObjectKind {
96    Blob,
97    ActionResult,
98    ActionManifest,
99}
100
101impl ObjectKind {
102    fn as_str(self) -> &'static str {
103        match self {
104            Self::Blob => "blobs",
105            Self::ActionResult => "action-results",
106            Self::ActionManifest => "action-manifests",
107        }
108    }
109}
110
111pub(crate) struct S3RemoteCache {
112    client: reqwest::Client,
113    /// Bucket root, always ending in `/` so keys join onto it.
114    base_url: Url,
115    /// Key prefix covering the configured prefix, namespace, and layout version.
116    root: String,
117    region: String,
118    credentials: S3Credentials,
119    conditional_writes: S3ConditionalWrites,
120    /// Latched once a store has told us it does not implement conditional
121    /// writes, so the rest of the session stops asking.
122    conditionals_disabled: AtomicBool,
123    /// Latched once a read has been refused where an absent object would have
124    /// been, so the explanation is offered once rather than per lookup.
125    absence_is_ambiguous: AtomicBool,
126    download_timeout: Duration,
127    retries: i64,
128}
129
130impl S3RemoteCache {
131    pub(crate) fn new(config: S3RemoteCacheConfig) -> Result<Self> {
132        validate_bucket(&config.bucket)?;
133        let prefix = normalize_prefix(&config.prefix)?;
134        validate_key_path(&config.namespace, "remote cache namespace")?;
135        if config.region.trim().is_empty() {
136            bail!("an S3 remote cache needs a region");
137        }
138        let client = reqwest::Client::builder()
139            .connect_timeout(config.connect_timeout)
140            .read_timeout(config.read_timeout)
141            .redirect(reqwest::redirect::Policy::none())
142            .build()?;
143        Ok(Self {
144            client,
145            base_url: base_url(&config)?,
146            root: format!("{prefix}{}/v{LAYOUT_VERSION}/", config.namespace.trim()),
147            region: config.region.trim().to_string(),
148            credentials: config.credentials,
149            conditional_writes: config.conditional_writes,
150            conditionals_disabled: AtomicBool::new(false),
151            absence_is_ambiguous: AtomicBool::new(false),
152            download_timeout: config.download_timeout,
153            retries: config.retries,
154        })
155    }
156
157    fn object_url(&self, kind: ObjectKind, digest: &CacheDigest) -> Result<Url> {
158        digest.validate()?;
159        if matches!(kind, ObjectKind::ActionResult | ObjectKind::ActionManifest)
160            && digest.algorithm != "blake3"
161        {
162            bail!("remote cache action keys must use blake3");
163        }
164        self.key_url(&format!(
165            "{}/{}/{}/{}",
166            kind.as_str(),
167            digest.algorithm,
168            digest.hash,
169            digest.size
170        ))
171    }
172
173    fn key_url(&self, key: &str) -> Result<Url> {
174        Ok(self.base_url.join(&format!("{}{key}", self.root))?)
175    }
176
177    /// Build a request carrying a valid signature for this instant.
178    ///
179    /// Signing happens per attempt rather than once per operation: a retry
180    /// after a long backoff would otherwise present a stale `x-amz-date` and be
181    /// refused for clock skew.
182    fn signed(
183        &self,
184        method: reqwest::Method,
185        url: &Url,
186        payload: &PayloadHash,
187    ) -> Result<reqwest::RequestBuilder> {
188        let context = SigningContext {
189            credentials: &self.credentials,
190            region: &self.region,
191            timestamp: SystemTime::now(),
192        };
193        let mut request = self.client.request(method.clone(), url.clone());
194        for (name, value) in sign(method.as_str(), url, &context, payload)? {
195            request = request.header(name, value);
196        }
197        Ok(request)
198    }
199
200    /// Whether a conditional header should be attached to the next write.
201    fn conditionals_enabled(&self) -> bool {
202        self.conditional_writes != S3ConditionalWrites::Off
203            && !self.conditionals_disabled.load(Ordering::Relaxed)
204    }
205
206    /// Whether the same write may be tried again without its condition.
207    ///
208    /// Under `required` it never may: a caller that asked for the guarantee gets
209    /// an error rather than a silent downgrade.
210    fn may_drop_conditionals(&self) -> bool {
211        self.conditional_writes == S3ConditionalWrites::Auto
212    }
213
214    /// Record that this store does not implement conditional writes.
215    ///
216    /// Called only once an unconditional write has actually succeeded, which is
217    /// what distinguishes a store that refuses conditions from one that refused
218    /// this request for some other reason and would have refused it anyway. A
219    /// `501` from an intermediary is not evidence about the store, and latching
220    /// on it would quietly turn every later manifest update into a
221    /// last-writer-wins one.
222    fn note_conditionals_unsupported(&self) {
223        if !self.conditionals_disabled.swap(true, Ordering::Relaxed) {
224            warn!(
225                "the remote object store does not implement conditional writes; \
226                 continuing without them. Blobs and action results are content-addressed, so \
227                 this is safe; concurrent task manifest updates can now lose predictions, \
228                 which costs prefetch coverage on later builds"
229            );
230        }
231    }
232
233    pub(crate) async fn check_connection(&self) -> Result<()> {
234        let url = self.key_url(CONNECTIVITY_PROBE_KEY)?;
235        // A GET rather than a HEAD, because S3 explains a refusal in the
236        // response body and a HEAD has none. The key is never written, so the
237        // expected answer is a 404 carrying an error document.
238        retry_async("GET", &url, self.retries, || async {
239            let response = self
240                .signed(reqwest::Method::GET, &url, &PayloadHash::empty())?
241                .send()
242                .await?;
243            match response.status() {
244                // The bucket answered and authorized the request. Whether this
245                // one key exists is beside the point.
246                StatusCode::OK | StatusCode::NOT_FOUND => Ok(()),
247                StatusCode::FORBIDDEN => {
248                    let failure = FailedRequest::read(response).await;
249                    if failure.is_credentials_rejected() {
250                        bail!(
251                            "the remote object store rejected these credentials for {url}: {}. \
252                             Check AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY, and that this \
253                             machine's clock is correct",
254                            failure.code.as_deref().unwrap_or("forbidden")
255                        );
256                    }
257                    // The signature was accepted; something declined this one
258                    // object. The probe key is never written, so without
259                    // s3:ListBucket that is exactly what a working
260                    // configuration looks like -- and it is also what a
261                    // credential with no access to the prefix looks like.
262                    warn!(
263                        "the remote object store did not confirm access to {url}. That is \
264                         expected without s3:ListBucket on the bucket, where S3 refuses a \
265                         read rather than reporting the object absent; grant it to tell the \
266                         two apart. If the cache never hits, these credentials may not be \
267                         allowed to read the prefix"
268                    );
269                    Ok(())
270                }
271                StatusCode::MOVED_PERMANENTLY | StatusCode::TEMPORARY_REDIRECT => {
272                    let region = response
273                        .headers()
274                        .get("x-amz-bucket-region")
275                        .and_then(|value| value.to_str().ok())
276                        .unwrap_or("another region");
277                    bail!(
278                        "the bucket is in {region}, not {}; set the remote region to match",
279                        self.region
280                    )
281                }
282                _ => Err(FailedRequest::read(response)
283                    .await
284                    .report("connect to", &url)),
285            }
286        })
287        .await
288    }
289
290    pub(crate) async fn get_blob(
291        &self,
292        digest: &CacheDigest,
293        _media_type: &'static str,
294    ) -> Result<Vec<u8>> {
295        if digest.size > MAX_REMOTE_JSON_BYTES {
296            bail!(
297                "remote cache in-memory blob declared {} bytes, over the {} byte limit",
298                digest.size,
299                MAX_REMOTE_JSON_BYTES
300            );
301        }
302        let url = self.object_url(ObjectKind::Blob, digest)?;
303        retry_async("GET", &url, self.retries, || async {
304            let mut response = self.get(&url).await?;
305            // Stop reading as soon as the response outgrows the digest it claims
306            // to satisfy, exactly as the protocol client does.
307            let mut bytes = Vec::new();
308            while let Some(chunk) = response.chunk().await? {
309                if bytes.len() as u64 + chunk.len() as u64 > digest.size {
310                    bail!("remote cache blob exceeded the size of its digest");
311                }
312                bytes.extend_from_slice(&chunk);
313            }
314            if !digest.matches_bytes(&bytes)? {
315                bail!("remote cache blob failed digest verification");
316            }
317            Ok(bytes)
318        })
319        .await
320    }
321
322    pub(crate) async fn get_blob_file(
323        &self,
324        digest: &CacheDigest,
325        staging_dir: &Path,
326    ) -> Result<tempfile::NamedTempFile> {
327        if digest.size > MAX_REMOTE_BLOB_BYTES {
328            bail!(
329                "remote cache blob declared {} bytes, over the {} byte limit",
330                digest.size,
331                MAX_REMOTE_BLOB_BYTES
332            );
333        }
334        let url = self.object_url(ObjectKind::Blob, digest)?;
335        let download = retry_async("GET", &url, self.retries, || async {
336            let mut response = self.get(&url).await?;
337            fs::create_dir_all(staging_dir)?;
338            let temporary = tempfile::NamedTempFile::new_in(staging_dir)?;
339            let mut output = tokio::fs::File::from_std(temporary.reopen()?);
340            let mut written = 0u64;
341            while let Some(chunk) = response.chunk().await? {
342                written += chunk.len() as u64;
343                if written > digest.size {
344                    bail!("remote cache blob exceeded the size of its digest");
345                }
346                output.write_all(&chunk).await?;
347            }
348            output.flush().await?;
349            drop(output);
350            if !digest.matches_file(temporary.path())? {
351                bail!("remote cache blob failed digest verification");
352            }
353            Ok(temporary)
354        });
355        let download_timeout = self.download_timeout;
356        // Deliberately outside `retry_async`: `download_timeout` is a deadline
357        // for the whole download, not a per-attempt bound. A stalled attempt is
358        // already caught by the client's connect and read timeouts.
359        tokio::time::timeout(download_timeout, download)
360            .await
361            .map_err(|_| {
362                eyre!(
363                    "remote cache blob download for {url} exceeded its {download_timeout:?} budget across all attempts"
364                )
365            })?
366    }
367
368    /// Fetch an object that must exist, turning any other status into an error.
369    async fn get(&self, url: &Url) -> Result<reqwest::Response> {
370        let response = self
371            .signed(reqwest::Method::GET, url, &PayloadHash::empty())?
372            .send()
373            .await?;
374        if response.status().is_success() {
375            Ok(response)
376        } else {
377            Err(FailedRequest::read(response).await.report("read", url))
378        }
379    }
380
381    /// Whether a failed read should be treated as the object not being there.
382    ///
383    /// S3 answers `403` rather than `404` for an absent object when the caller
384    /// may not list the bucket, so a miss under a least-privilege policy is
385    /// indistinguishable from a denial by status alone. The error code does
386    /// distinguish the one case that matters: credentials S3 itself rejected.
387    /// Anything else is treated as a miss, since reporting every cold lookup as
388    /// a failure would make such a policy unusable, and a warning explains it
389    /// once.
390    fn reads_as_absent(&self, failure: &FailedRequest) -> bool {
391        if failure.status == StatusCode::NOT_FOUND {
392            return true;
393        }
394        if failure.status != StatusCode::FORBIDDEN || failure.is_credentials_rejected() {
395            return false;
396        }
397        if !self.absence_is_ambiguous.swap(true, Ordering::Relaxed) {
398            warn!(
399                "the remote object store refused a read instead of reporting the object \
400                 absent, which is what S3 does without s3:ListBucket on the bucket. \
401                 Treating it as a cache miss. Grant s3:ListBucket so a miss is a miss; \
402                 if the cache never hits, these credentials may simply not be allowed to \
403                 read it"
404            );
405        }
406        true
407    }
408
409    pub(crate) async fn get_action_result(
410        &self,
411        action: &CacheDigest,
412    ) -> Result<Option<RemoteActionResult>> {
413        let url = self.object_url(ObjectKind::ActionResult, action)?;
414        let result = retry_async("GET", &url, self.retries, || async {
415            let response = self
416                .signed(reqwest::Method::GET, &url, &PayloadHash::empty())?
417                .send()
418                .await?;
419            if !response.status().is_success() {
420                let failure = FailedRequest::read(response).await;
421                return if self.reads_as_absent(&failure) {
422                    Ok(None)
423                } else {
424                    Err(failure.report("read", &url))
425                };
426            }
427            let bytes = read_bounded_json(response, "action result").await?;
428            Ok(Some(serde_json::from_slice::<RemoteActionResult>(&bytes)?))
429        })
430        .await?;
431        if let Some(result) = &result
432            && (result.version != 1 || result.action != *action)
433        {
434            bail!("remote action result does not match requested action");
435        }
436        Ok(result)
437    }
438
439    pub(crate) async fn put_action_result(&self, result: &RemoteActionResult) -> Result<()> {
440        let url = self.object_url(ObjectKind::ActionResult, &result.action)?;
441        let body = serde_json::to_vec(result)?;
442        retry_async("PUT", &url, self.retries, || async {
443            // Storing the same record twice is not an error: the key is its
444            // content address, so an existing object already holds these bytes.
445            self.put_create(&url, &body).await.map(drop)
446        })
447        .await
448    }
449
450    pub(crate) async fn get_action_manifest(
451        &self,
452        key: &CacheDigest,
453    ) -> Result<Option<RemoteActionManifest>> {
454        let url = self.object_url(ObjectKind::ActionManifest, key)?;
455        retry_async("GET", &url, self.retries, || async {
456            let response = self
457                .signed(reqwest::Method::GET, &url, &PayloadHash::empty())?
458                .send()
459                .await?;
460            if !response.status().is_success() {
461                let failure = FailedRequest::read(response).await;
462                return if self.reads_as_absent(&failure) {
463                    Ok(None)
464                } else {
465                    Err(failure.report("read", &url))
466                };
467            }
468            let etag = parse_strong_etag(response.headers().get(ETAG))?;
469            let bytes = read_bounded_json(response, "action manifest").await?;
470            Ok(Some(RemoteActionManifest { bytes, etag }))
471        })
472        .await
473    }
474
475    pub(crate) async fn put_action_manifest(
476        &self,
477        key: &CacheDigest,
478        bytes: &[u8],
479        expected_etag: Option<&str>,
480    ) -> Result<ManifestPutOutcome> {
481        let url = self.object_url(ObjectKind::ActionManifest, key)?;
482        let body = bytes.to_vec();
483        let expected_etag = expected_etag.map(quoted_etag).transpose()?;
484        retry_async("PUT", &url, self.retries, || async {
485            // Set once the store has refused a condition and the same write is
486            // being tried without one, so that success can be attributed.
487            let mut dropped_condition = false;
488            let outcome = loop {
489                let conditional = self.conditionals_enabled() && !dropped_condition;
490                let mut request = self
491                    .signed(reqwest::Method::PUT, &url, &PayloadHash::of(&body))?
492                    .header(CONTENT_LENGTH, body.len())
493                    .body(body.clone());
494                if conditional {
495                    request = match &expected_etag {
496                        Some(etag) => request.header(IF_MATCH, etag),
497                        None => request.header(IF_NONE_MATCH, "*"),
498                    };
499                }
500                let response = request.send().await?;
501                let status = response.status();
502                if status.is_success() {
503                    if dropped_condition {
504                        self.note_conditionals_unsupported();
505                    }
506                    break ManifestPutOutcome::Stored;
507                }
508                if conditional && status == StatusCode::PRECONDITION_FAILED {
509                    // Either the manifest moved under us or, for a create, one
510                    // already exists. Both mean re-reading and merging.
511                    break ManifestPutOutcome::PreconditionFailed;
512                }
513                if status == StatusCode::CONFLICT {
514                    // A concurrent conditional write on the same key. S3 asks
515                    // that this be retried rather than treated as a conflict of
516                    // content.
517                    return Err(conditional_request_conflict(&url));
518                }
519                let failure = FailedRequest::read(response).await;
520                if conditional && failure.is_not_implemented() {
521                    if self.may_drop_conditionals() {
522                        dropped_condition = true;
523                        continue;
524                    }
525                    return Err(failure.report("update", &url).wrap_err(
526                        "conditional writes are required but this store does not implement them",
527                    ));
528                }
529                return Err(failure.report("update", &url));
530            };
531            Ok(outcome)
532        })
533        .await
534    }
535
536    pub(crate) async fn put_blob(&self, upload: &BlobUpload) -> Result<()> {
537        upload.digest.validate()?;
538        let url = self.object_url(ObjectKind::Blob, &upload.digest)?;
539        retry_async("PUT", &url, self.retries, || async {
540            match &upload.source {
541                BlobSource::Bytes(bytes) => self.put_create(&url, bytes).await.map(drop),
542                BlobSource::File(file) => self.put_create_file(&url, file.path()).await,
543                BlobSource::Path(path) => self.put_create_file(&url, path).await,
544            }
545        })
546        .await
547    }
548
549    /// Store bytes under a key that is their content address.
550    ///
551    /// Returns whether this request is what created the object; an object that
552    /// was already there is success, since its key names these exact bytes.
553    async fn put_create(&self, url: &Url, body: &[u8]) -> Result<bool> {
554        let mut dropped_condition = false;
555        loop {
556            let conditional = self.conditionals_enabled() && !dropped_condition;
557            let mut request = self
558                .signed(reqwest::Method::PUT, url, &PayloadHash::of(body))?
559                .header(CONTENT_LENGTH, body.len())
560                .body(body.to_vec());
561            if conditional {
562                request = request.header(IF_NONE_MATCH, "*");
563            }
564            match self
565                .finish_create(url, request.send().await?, conditional)
566                .await?
567            {
568                Some(created) => {
569                    if dropped_condition {
570                        self.note_conditionals_unsupported();
571                    }
572                    return Ok(created);
573                }
574                None => dropped_condition = true,
575            }
576        }
577    }
578
579    /// Store a file under a key that is its content address.
580    ///
581    /// The body is streamed with an explicit length rather than chunked, which
582    /// S3 requires, and is signed as an unsigned payload so that a multi-gigabyte
583    /// artifact is not read twice just to compute a hash TLS and the content
584    /// address already stand behind.
585    async fn put_create_file(&self, url: &Url, path: &Path) -> Result<()> {
586        let mut dropped_condition = false;
587        loop {
588            let conditional = self.conditionals_enabled() && !dropped_condition;
589            let file = tokio::fs::File::open(path).await?;
590            let length = file.metadata().await?.len();
591            let mut request = self
592                .signed(reqwest::Method::PUT, url, &PayloadHash::Unsigned)?
593                .header(CONTENT_LENGTH, length)
594                .body(reqwest::Body::wrap_stream(
595                    tokio_util::io::ReaderStream::new(file),
596                ));
597            if conditional {
598                request = request.header(IF_NONE_MATCH, "*");
599            }
600            match self
601                .finish_create(url, request.send().await?, conditional)
602                .await?
603            {
604                Some(_) => {
605                    if dropped_condition {
606                        self.note_conditionals_unsupported();
607                    }
608                    return Ok(());
609                }
610                None => dropped_condition = true,
611            }
612        }
613    }
614
615    /// Interpret the response to a create-only write.
616    ///
617    /// `None` asks the caller to send the same request again without its
618    /// conditional header, the store having just refused one.
619    async fn finish_create(
620        &self,
621        url: &Url,
622        response: reqwest::Response,
623        conditional: bool,
624    ) -> Result<Option<bool>> {
625        let status = response.status();
626        if status.is_success() {
627            return Ok(Some(true));
628        }
629        // Only a condition this request actually carried can have failed. A 412
630        // against an unconditional write means something else refused it, and
631        // reporting that as a stored object would lose the write silently.
632        if conditional && status == StatusCode::PRECONDITION_FAILED {
633            return Ok(Some(false));
634        }
635        if status == StatusCode::CONFLICT {
636            return Err(conditional_request_conflict(url));
637        }
638        let failure = FailedRequest::read(response).await;
639        if conditional && failure.is_not_implemented() {
640            if self.may_drop_conditionals() {
641                return Ok(None);
642            }
643            return Err(failure.report("store", url).wrap_err(
644                "conditional writes are required but this store does not implement them",
645            ));
646        }
647        Err(failure.report("store", url))
648    }
649
650    // Everything below is an extension a protocol server offers and a bucket
651    // does not. Reporting absence is what routes the caller to the per-object
652    // requests that every version of the protocol has made.
653
654    pub(crate) async fn get_action_results(
655        &self,
656        actions: &[CacheDigest],
657    ) -> Result<Option<Vec<RemoteActionResult>>> {
658        Ok(actions.is_empty().then(Vec::new))
659    }
660
661    pub(crate) async fn action_batch_limit(&self) -> Result<Option<usize>> {
662        Ok(None)
663    }
664
665    pub(crate) async fn get_blob_pack(
666        &self,
667        _digests: &[CacheDigest],
668        _staging_dir: &Path,
669    ) -> Result<Option<RemoteBlobPack>> {
670        Ok(None)
671    }
672
673    pub(crate) async fn get_blob_pack_with_limit(
674        &self,
675        _digests: &[CacheDigest],
676        _staging_dir: &Path,
677        _max_bytes: u64,
678    ) -> Result<Option<RemoteBlobPack>> {
679        Ok(None)
680    }
681
682    pub(crate) async fn blob_pack_upload_limits(&self) -> Result<Option<crate::BlobPackLimits>> {
683        Ok(None)
684    }
685
686    pub(crate) async fn put_blob_pack(
687        &self,
688        uploads: &[BlobUpload],
689    ) -> Result<Option<BlobPackReceipt>> {
690        Ok(uploads.is_empty().then_some(BlobPackReceipt {
691            created: 0,
692            existing: 0,
693        }))
694    }
695}
696
697/// A concurrent conditional write, which S3 asks callers to retry.
698fn conditional_request_conflict(url: &Url) -> eyre::Report {
699    eyre::Report::new(TransientRequest("conditional request conflict")).wrap_err(format!(
700        "a concurrent conditional write to {url} conflicted"
701    ))
702}
703
704/// A request that failed, read far enough to say why.
705///
706/// The body is consumed here because deciding what a failure means can depend
707/// on the store's own error code, and a response cannot be read twice.
708struct FailedRequest {
709    status: StatusCode,
710    code: Option<String>,
711}
712
713impl FailedRequest {
714    async fn read(mut response: reqwest::Response) -> Self {
715        let status = response.status();
716        // Bounded like every other body this client reads. An error document is
717        // a diagnostic, and a store that streams without end must not be able to
718        // exhaust this process through one.
719        let mut body = Vec::new();
720        while body.len() < MAX_ERROR_BODY_BYTES {
721            match response.chunk().await {
722                Ok(Some(chunk)) => body.extend_from_slice(&chunk),
723                Ok(None) | Err(_) => break,
724            }
725        }
726        body.truncate(MAX_ERROR_BODY_BYTES);
727        Self {
728            status,
729            code: error_code(&String::from_utf8_lossy(&body)).map(str::to_string),
730        }
731    }
732
733    /// Whether the store is saying it does not implement what was asked of it.
734    ///
735    /// S3-compatible stores disagree about how to say this. AWS answers `501`
736    /// for an unimplemented feature, while some others answer `400` with
737    /// `NotImplemented` in the document. A bare `400` is not enough on its own:
738    /// it is also how a store reports a request it merely disliked.
739    fn is_not_implemented(&self) -> bool {
740        self.status == StatusCode::NOT_IMPLEMENTED
741            || (self.status == StatusCode::BAD_REQUEST
742                && self.code.as_deref() == Some("NotImplemented"))
743    }
744
745    /// Whether S3 rejected the credentials themselves, rather than declining
746    /// this one object.
747    ///
748    /// These codes say the request never authenticated, which no bucket policy
749    /// can explain away. `AccessDenied` deliberately is not among them: it is
750    /// what an absent object looks like without `s3:ListBucket`.
751    fn is_credentials_rejected(&self) -> bool {
752        self.status == StatusCode::FORBIDDEN
753            && matches!(
754                self.code.as_deref(),
755                Some(
756                    "SignatureDoesNotMatch"
757                        | "InvalidAccessKeyId"
758                        | "InvalidSecurity"
759                        | "ExpiredToken"
760                        | "TokenRefreshRequired"
761                        | "RequestTimeTooSkewed"
762                )
763            )
764    }
765
766    /// Whether the store is asking to be tried again.
767    ///
768    /// `500` and `503` are how S3 reports an internal error and a prefix under
769    /// too much load, and it documents both as the client's to retry. `501` is
770    /// deliberately absent: a store that does not implement something will not
771    /// implement it a moment later.
772    fn is_retryable(&self) -> bool {
773        matches!(self.status.as_u16(), 408 | 429 | 500 | 502 | 503 | 504)
774    }
775
776    fn report(&self, verb: &str, url: &Url) -> eyre::Report {
777        let detail = match &self.code {
778            Some(code) => format!("failed to {verb} {url}: {} ({code})", self.status),
779            None => format!("failed to {verb} {url}: {}", self.status),
780        };
781        if self.is_retryable() {
782            // The protocol client gets this for free: `error_for_status` hands
783            // `is_transient` a `reqwest::Error` carrying the status. Nothing
784            // here produces one, so a retryable status has to say so itself,
785            // or a `503` under load would fail a build that asked for retries.
786            return eyre::Report::new(TransientRequest("the store asked to be retried"))
787                .wrap_err(detail);
788        }
789        eyre!(detail)
790    }
791}
792
793/// Extract the `<Code>` of an S3 error document.
794///
795/// S3 reports errors as XML, but only the code is ever acted on, so it is
796/// scanned for rather than parsed with an XML reader this crate would otherwise
797/// not need.
798fn error_code(body: &str) -> Option<&str> {
799    let start = body.find("<Code>")? + "<Code>".len();
800    let end = body[start..].find("</Code>")? + start;
801    Some(body[start..end].trim()).filter(|code| !code.is_empty())
802}
803
804/// Reject a bucket name that could not address the store it names.
805fn validate_bucket(bucket: &str) -> Result<()> {
806    let bucket = bucket.trim();
807    if bucket.is_empty() {
808        bail!("an S3 remote cache needs a bucket");
809    }
810    if bucket.contains('/') || bucket.starts_with('.') || bucket.ends_with('.') {
811        bail!("invalid S3 bucket name {bucket:?}");
812    }
813    Ok(())
814}
815
816/// Normalize a key prefix to empty, or to something ending in a single `/`.
817fn normalize_prefix(prefix: &str) -> Result<String> {
818    let prefix = prefix.trim().trim_matches('/');
819    if prefix.is_empty() {
820        return Ok(String::new());
821    }
822    validate_key_path(prefix, "remote cache prefix")?;
823    Ok(format!("{prefix}/"))
824}
825
826/// Reject anything that would not survive being spliced into an object key.
827///
828/// The protocol carries the namespace in a header, where a server decides what
829/// it means. In a bucket it is part of the key, so it has to be something a key
830/// can hold and something that cannot climb out of its own prefix.
831fn validate_key_path(value: &str, what: &str) -> Result<()> {
832    let value = value.trim();
833    if value.is_empty() {
834        bail!("{what} must not be empty");
835    }
836    if value.starts_with('/') || value.ends_with('/') {
837        bail!("{what} {value:?} must not start or end with a slash");
838    }
839    for segment in value.split('/') {
840        if segment.is_empty() {
841            bail!("{what} {value:?} must not contain an empty path segment");
842        }
843        if segment == "." || segment == ".." {
844            bail!("{what} {value:?} must not contain a relative path segment");
845        }
846        if !segment
847            .bytes()
848            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
849        {
850            bail!(
851                "{what} {value:?} must use only letters, digits, '.', '_', '-', and '/' \
852                 when the remote cache is an object store"
853            );
854        }
855    }
856    Ok(())
857}
858
859/// Resolve the bucket root a key is joined onto.
860///
861/// A custom endpoint addresses the bucket in the path, which is what MinIO and
862/// R2 expect and what a bucket whose name is not a valid DNS label requires.
863/// AWS itself is addressed by host, since a bucket-named host is what its TLS
864/// certificate covers -- unless the name contains a dot, which the wildcard
865/// certificate does not match.
866fn base_url(config: &S3RemoteCacheConfig) -> Result<Url> {
867    let bucket = config.bucket.trim();
868    let (mut url, path_style) = match &config.endpoint {
869        Some(endpoint) => (endpoint.clone(), config.force_path_style.unwrap_or(true)),
870        None => (
871            format!("https://s3.{}.amazonaws.com", config.region.trim()).parse()?,
872            config
873                .force_path_style
874                .unwrap_or_else(|| bucket.contains('.')),
875        ),
876    };
877    if path_style {
878        let path = url.path().trim_end_matches('/').to_string();
879        url.set_path(&format!("{path}/{bucket}/"));
880    } else {
881        let host = url
882            .host_str()
883            .ok_or_else(|| eyre!("an S3 endpoint must have a host"))?;
884        url.set_host(Some(&format!("{bucket}.{host}")))?;
885        // An endpoint may carry a path, and moving the bucket into the host is
886        // no reason to drop it: a gateway at /s3 still wants to be asked at /s3.
887        let path = url.path().trim_end_matches('/').to_string();
888        url.set_path(&format!("{path}/"));
889    }
890    Ok(url)
891}
892
893#[cfg(test)]
894#[path = "remote_s3_tests.rs"]
895mod tests;