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