1use 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::sync::{Arc, Mutex};
32use std::time::{Duration, Instant, SystemTime};
33use tokio::io::AsyncWriteExt;
34use url::Url;
35
36const LAYOUT_VERSION: u8 = 1;
41const CONNECTIVITY_PROBE_KEY: &str = "connectivity-probe";
44const MAX_ERROR_BODY_BYTES: usize = 8 * 1024;
46const RENEWAL_MARGIN: Duration = Duration::from_secs(5 * 60);
50const RENEWAL_RETRY: Duration = Duration::from_secs(30);
53
54#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, strum::EnumString, strum::Display)]
56#[strum(serialize_all = "kebab-case")]
57pub enum S3ConditionalWrites {
58 #[default]
60 Auto,
61 Required,
63 Off,
65}
66
67pub struct S3RemoteCacheConfig {
69 pub bucket: String,
71 pub prefix: String,
73 pub namespace: String,
75 pub region: String,
77 pub endpoint: Option<Url>,
79 pub force_path_style: Option<bool>,
81 pub conditional_writes: S3ConditionalWrites,
83 pub credentials: S3Credentials,
85 pub connect_timeout: Duration,
87 pub read_timeout: Duration,
89 pub download_timeout: Duration,
98 pub retries: i64,
100}
101
102#[derive(Clone, Copy)]
104enum ObjectKind {
105 Blob,
106 ActionResult,
107 ActionManifest,
108}
109
110impl ObjectKind {
111 fn as_str(self) -> &'static str {
112 match self {
113 Self::Blob => "blobs",
114 Self::ActionResult => "action-results",
115 Self::ActionManifest => "action-manifests",
116 }
117 }
118}
119
120enum CredentialSource {
122 Fixed(S3Credentials),
124 InstanceRole(Arc<InstanceRole>),
126}
127
128struct InstanceRole {
129 provider: InstanceRoleCredentials,
130 state: Mutex<RenewalState>,
133 lookup: tokio::sync::Mutex<()>,
136 renewing: AtomicBool,
138}
139
140struct RenewalState {
141 credentials: S3Credentials,
142 expires_at: SystemTime,
143 retry_at: Option<Instant>,
146 failing: bool,
149}
150
151impl RenewalState {
152 fn renewal_due(&self) -> bool {
154 self.expires_at
155 .checked_sub(RENEWAL_MARGIN)
156 .is_none_or(|due| due <= SystemTime::now())
157 }
158
159 fn expired(&self) -> bool {
161 self.expires_at <= SystemTime::now()
162 }
163
164 fn backing_off(&self) -> bool {
166 self.retry_at.is_some_and(|at| at > Instant::now())
167 }
168}
169
170impl InstanceRole {
171 async fn renew(&self) {
178 let _lookup = self.lookup.lock().await;
179 {
180 let state = self.state.lock().unwrap();
181 if !state.renewal_due() || state.backing_off() {
182 return;
183 }
184 }
185 let fetched = self.provider.fetch().await;
186 let mut state = self.state.lock().unwrap();
187 match fetched {
188 Ok(fresh) => {
189 state.credentials = fresh.credentials;
190 state.expires_at = fresh.expires_at;
191 state.failing = false;
192 }
193 Err(error) => {
194 if !state.failing {
195 warn!(
196 "could not renew the EC2 instance role credentials, \
197 continuing with the current ones: {error:#}"
198 );
199 }
200 state.failing = true;
201 }
202 }
203 state.retry_at = state.renewal_due().then(|| Instant::now() + RENEWAL_RETRY);
204 }
205}
206
207impl CredentialSource {
208 async fn current(&self) -> S3Credentials {
216 let role = match self {
217 Self::Fixed(credentials) => return credentials.clone(),
218 Self::InstanceRole(role) => role,
219 };
220 let (due, expired) = {
221 let state = role.state.lock().unwrap();
222 (state.renewal_due() && !state.backing_off(), state.expired())
223 };
224 if due && expired {
225 role.renew().await;
226 } else if due && !role.renewing.swap(true, Ordering::AcqRel) {
227 let role = Arc::clone(role);
228 tokio::spawn(async move {
229 role.renew().await;
230 role.renewing.store(false, Ordering::Release);
231 });
232 }
233 role.state.lock().unwrap().credentials.clone()
234 }
235
236 fn rejection_hint(&self, code: Option<&str>) -> &'static str {
238 match self {
239 Self::Fixed(_) => "Check AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY",
240 Self::InstanceRole(_)
244 if matches!(code, Some("ExpiredToken" | "TokenRefreshRequired")) =>
245 {
246 "S3 says the EC2 instance role's credentials have expired. If a renewal \
247 warning was logged, the instance metadata service could not be reached; if \
248 not, the two clocks disagree"
249 }
250 Self::InstanceRole(_) => {
251 "These are the EC2 instance role's credentials, so check the role's \
252 permissions on the bucket"
253 }
254 }
255 }
256}
257
258pub(crate) struct S3RemoteCache {
259 client: reqwest::Client,
260 base_url: Url,
262 root: String,
264 region: String,
265 credentials: CredentialSource,
266 conditional_writes: S3ConditionalWrites,
267 conditionals_disabled: AtomicBool,
270 absence_is_ambiguous: AtomicBool,
273 download_timeout: Duration,
274 retries: i64,
275}
276
277impl S3RemoteCache {
278 pub(crate) fn new(config: S3RemoteCacheConfig) -> Result<Self> {
279 validate_bucket(&config.bucket)?;
280 let prefix = normalize_prefix(&config.prefix)?;
281 validate_key_path(&config.namespace, "remote cache namespace")?;
282 if config.region.trim().is_empty() {
283 bail!("an S3 remote cache needs a region");
284 }
285 let client = reqwest::Client::builder()
286 .connect_timeout(config.connect_timeout)
287 .read_timeout(config.read_timeout)
288 .redirect(reqwest::redirect::Policy::none())
289 .build()?;
290 Ok(Self {
291 client,
292 base_url: base_url(&config)?,
293 root: format!("{prefix}{}/v{LAYOUT_VERSION}/", config.namespace.trim()),
294 region: config.region.trim().to_string(),
295 credentials: CredentialSource::Fixed(config.credentials),
296 conditional_writes: config.conditional_writes,
297 conditionals_disabled: AtomicBool::new(false),
298 absence_is_ambiguous: AtomicBool::new(false),
299 download_timeout: config.download_timeout,
300 retries: config.retries,
301 })
302 }
303
304 pub(crate) fn with_instance_role(
309 mut self,
310 provider: InstanceRoleCredentials,
311 expires_at: SystemTime,
312 ) -> Self {
313 let CredentialSource::Fixed(credentials) = self.credentials else {
314 return self;
315 };
316 self.credentials = CredentialSource::InstanceRole(Arc::new(InstanceRole {
317 provider,
318 state: Mutex::new(RenewalState {
319 credentials,
320 expires_at,
321 retry_at: None,
322 failing: false,
323 }),
324 lookup: tokio::sync::Mutex::new(()),
325 renewing: AtomicBool::new(false),
326 }));
327 self
328 }
329
330 fn object_url(&self, kind: ObjectKind, digest: &CacheDigest) -> Result<Url> {
331 digest.validate()?;
332 if matches!(kind, ObjectKind::ActionResult | ObjectKind::ActionManifest)
333 && digest.algorithm != "blake3"
334 {
335 bail!("remote cache action keys must use blake3");
336 }
337 self.key_url(&format!(
338 "{}/{}/{}/{}",
339 kind.as_str(),
340 digest.algorithm,
341 digest.hash,
342 digest.size
343 ))
344 }
345
346 fn key_url(&self, key: &str) -> Result<Url> {
347 Ok(self.base_url.join(&format!("{}{key}", self.root))?)
348 }
349
350 async fn signed(
356 &self,
357 method: reqwest::Method,
358 url: &Url,
359 payload: &PayloadHash,
360 ) -> Result<reqwest::RequestBuilder> {
361 let credentials = self.credentials.current().await;
362 let context = SigningContext {
363 credentials: &credentials,
364 region: &self.region,
365 timestamp: SystemTime::now(),
366 };
367 let mut request = self.client.request(method.clone(), url.clone());
368 for (name, value) in sign(method.as_str(), url, &context, payload)? {
369 request = request.header(name, value);
370 }
371 Ok(request)
372 }
373
374 fn conditionals_enabled(&self) -> bool {
376 self.conditional_writes != S3ConditionalWrites::Off
377 && !self.conditionals_disabled.load(Ordering::Relaxed)
378 }
379
380 fn may_drop_conditionals(&self) -> bool {
385 self.conditional_writes == S3ConditionalWrites::Auto
386 }
387
388 fn note_conditionals_unsupported(&self) {
397 if !self.conditionals_disabled.swap(true, Ordering::Relaxed) {
398 warn!(
399 "the remote object store does not implement conditional writes; \
400 continuing without them. Blobs and action results are content-addressed, so \
401 this is safe; concurrent task manifest updates can now lose predictions, \
402 which costs prefetch coverage on later builds"
403 );
404 }
405 }
406
407 pub(crate) async fn check_connection(&self) -> Result<()> {
408 let url = self.key_url(CONNECTIVITY_PROBE_KEY)?;
409 retry_async("GET", &url, self.retries, || async {
413 let response = self
414 .signed(reqwest::Method::GET, &url, &PayloadHash::empty())
415 .await?
416 .send()
417 .await?;
418 match response.status() {
419 StatusCode::OK | StatusCode::NOT_FOUND => Ok(()),
422 StatusCode::BAD_REQUEST | StatusCode::FORBIDDEN => {
423 let failure = FailedRequest::read(response).await;
424 if failure.is_credentials_rejected() {
425 bail!(
426 "the remote object store rejected these credentials for {url}: {}. \
427 {}, and that this machine's clock is correct",
428 failure.code.as_deref().unwrap_or("forbidden"),
429 self.credentials.rejection_hint(failure.code.as_deref())
430 );
431 }
432 if failure.status != StatusCode::FORBIDDEN {
433 return Err(failure.report("connect to", &url));
434 }
435 warn!(
441 "the remote object store did not confirm access to {url}. That is \
442 expected without s3:ListBucket on the bucket, where S3 refuses a \
443 read rather than reporting the object absent; grant it to tell the \
444 two apart. If the cache never hits, these credentials may not be \
445 allowed to read the prefix"
446 );
447 Ok(())
448 }
449 StatusCode::MOVED_PERMANENTLY | StatusCode::TEMPORARY_REDIRECT => {
450 let region = response
451 .headers()
452 .get("x-amz-bucket-region")
453 .and_then(|value| value.to_str().ok())
454 .unwrap_or("another region");
455 bail!(
456 "the bucket is in {region}, not {}; set the remote region to match",
457 self.region
458 )
459 }
460 _ => Err(FailedRequest::read(response)
461 .await
462 .report("connect to", &url)),
463 }
464 })
465 .await
466 }
467
468 pub(crate) async fn get_blob(
469 &self,
470 digest: &CacheDigest,
471 _media_type: &'static str,
472 ) -> Result<Vec<u8>> {
473 if digest.size > MAX_REMOTE_JSON_BYTES {
474 bail!(
475 "remote cache in-memory blob declared {} bytes, over the {} byte limit",
476 digest.size,
477 MAX_REMOTE_JSON_BYTES
478 );
479 }
480 let url = self.object_url(ObjectKind::Blob, digest)?;
481 retry_async("GET", &url, self.retries, || async {
482 let mut response = self.get(&url).await?;
483 let mut bytes = Vec::new();
486 while let Some(chunk) = response.chunk().await? {
487 if bytes.len() as u64 + chunk.len() as u64 > digest.size {
488 bail!("remote cache blob exceeded the size of its digest");
489 }
490 bytes.extend_from_slice(&chunk);
491 }
492 if !digest.matches_bytes(&bytes)? {
493 bail!("remote cache blob failed digest verification");
494 }
495 Ok(bytes)
496 })
497 .await
498 }
499
500 pub(crate) async fn get_blob_file(
501 &self,
502 digest: &CacheDigest,
503 staging_dir: &Path,
504 ) -> Result<tempfile::NamedTempFile> {
505 if digest.size > MAX_REMOTE_BLOB_BYTES {
506 bail!(
507 "remote cache blob declared {} bytes, over the {} byte limit",
508 digest.size,
509 MAX_REMOTE_BLOB_BYTES
510 );
511 }
512 let url = self.object_url(ObjectKind::Blob, digest)?;
513 let download = retry_async("GET", &url, self.retries, || async {
514 let mut response = self.get(&url).await?;
515 fs::create_dir_all(staging_dir)?;
516 let temporary = tempfile::NamedTempFile::new_in(staging_dir)?;
517 let mut output = tokio::fs::File::from_std(temporary.reopen()?);
518 let mut written = 0u64;
519 while let Some(chunk) = response.chunk().await? {
520 written += chunk.len() as u64;
521 if written > digest.size {
522 bail!("remote cache blob exceeded the size of its digest");
523 }
524 output.write_all(&chunk).await?;
525 }
526 output.flush().await?;
527 drop(output);
528 if !digest.matches_file(temporary.path())? {
529 bail!("remote cache blob failed digest verification");
530 }
531 Ok(temporary)
532 });
533 let download_timeout = self.download_timeout;
534 tokio::time::timeout(download_timeout, download)
538 .await
539 .map_err(|_| {
540 eyre!(
541 "remote cache blob download for {url} exceeded its {download_timeout:?} budget across all attempts"
542 )
543 })?
544 }
545
546 async fn get(&self, url: &Url) -> Result<reqwest::Response> {
548 let response = self
549 .signed(reqwest::Method::GET, url, &PayloadHash::empty())
550 .await?
551 .send()
552 .await?;
553 if response.status().is_success() {
554 Ok(response)
555 } else {
556 Err(FailedRequest::read(response).await.report("read", url))
557 }
558 }
559
560 fn reads_as_absent(&self, failure: &FailedRequest) -> bool {
570 if failure.status == StatusCode::NOT_FOUND {
571 return true;
572 }
573 if failure.status != StatusCode::FORBIDDEN || failure.is_credentials_rejected() {
574 return false;
575 }
576 if !self.absence_is_ambiguous.swap(true, Ordering::Relaxed) {
577 warn!(
578 "the remote object store refused a read instead of reporting the object \
579 absent, which is what S3 does without s3:ListBucket on the bucket. \
580 Treating it as a cache miss. Grant s3:ListBucket so a miss is a miss; \
581 if the cache never hits, these credentials may simply not be allowed to \
582 read it"
583 );
584 }
585 true
586 }
587
588 pub(crate) async fn get_action_result(
589 &self,
590 action: &CacheDigest,
591 ) -> Result<Option<RemoteActionResult>> {
592 let url = self.object_url(ObjectKind::ActionResult, action)?;
593 let result = retry_async("GET", &url, self.retries, || async {
594 let response = self
595 .signed(reqwest::Method::GET, &url, &PayloadHash::empty())
596 .await?
597 .send()
598 .await?;
599 if !response.status().is_success() {
600 let failure = FailedRequest::read(response).await;
601 return if self.reads_as_absent(&failure) {
602 Ok(None)
603 } else {
604 Err(failure.report("read", &url))
605 };
606 }
607 let bytes = read_bounded_json(response, "action result").await?;
608 Ok(Some(serde_json::from_slice::<RemoteActionResult>(&bytes)?))
609 })
610 .await?;
611 if let Some(result) = &result
612 && (result.version != 1 || result.action != *action)
613 {
614 bail!("remote action result does not match requested action");
615 }
616 Ok(result)
617 }
618
619 pub(crate) async fn put_action_result(&self, result: &RemoteActionResult) -> Result<()> {
620 let url = self.object_url(ObjectKind::ActionResult, &result.action)?;
621 let body = serde_json::to_vec(result)?;
622 retry_async("PUT", &url, self.retries, || async {
623 self.put_create(&url, &body).await.map(drop)
626 })
627 .await
628 }
629
630 pub(crate) async fn get_action_manifest(
631 &self,
632 key: &CacheDigest,
633 ) -> Result<Option<RemoteActionManifest>> {
634 let url = self.object_url(ObjectKind::ActionManifest, key)?;
635 retry_async("GET", &url, self.retries, || async {
636 let response = self
637 .signed(reqwest::Method::GET, &url, &PayloadHash::empty())
638 .await?
639 .send()
640 .await?;
641 if !response.status().is_success() {
642 let failure = FailedRequest::read(response).await;
643 return if self.reads_as_absent(&failure) {
644 Ok(None)
645 } else {
646 Err(failure.report("read", &url))
647 };
648 }
649 let etag = parse_strong_etag(response.headers().get(ETAG))?;
650 let bytes = read_bounded_json(response, "action manifest").await?;
651 Ok(Some(RemoteActionManifest { bytes, etag }))
652 })
653 .await
654 }
655
656 pub(crate) async fn put_action_manifest(
657 &self,
658 key: &CacheDigest,
659 bytes: &[u8],
660 expected_etag: Option<&str>,
661 ) -> Result<ManifestPutOutcome> {
662 let url = self.object_url(ObjectKind::ActionManifest, key)?;
663 let body = bytes.to_vec();
664 let expected_etag = expected_etag.map(quoted_etag).transpose()?;
665 retry_async("PUT", &url, self.retries, || async {
666 let mut dropped_condition = false;
669 let outcome = loop {
670 let conditional = self.conditionals_enabled() && !dropped_condition;
671 let mut request = self
672 .signed(reqwest::Method::PUT, &url, &PayloadHash::of(&body))
673 .await?
674 .header(CONTENT_LENGTH, body.len())
675 .body(body.clone());
676 if conditional {
677 request = match &expected_etag {
678 Some(etag) => request.header(IF_MATCH, etag),
679 None => request.header(IF_NONE_MATCH, "*"),
680 };
681 }
682 let response = request.send().await?;
683 let status = response.status();
684 if status.is_success() {
685 if dropped_condition {
686 self.note_conditionals_unsupported();
687 }
688 break ManifestPutOutcome::Stored;
689 }
690 if conditional && status == StatusCode::PRECONDITION_FAILED {
691 break ManifestPutOutcome::PreconditionFailed;
694 }
695 if status == StatusCode::CONFLICT {
696 return Err(conditional_request_conflict(&url));
700 }
701 let failure = FailedRequest::read(response).await;
702 if conditional && failure.is_not_implemented() {
703 if self.may_drop_conditionals() {
704 dropped_condition = true;
705 continue;
706 }
707 return Err(failure.report("update", &url).wrap_err(
708 "conditional writes are required but this store does not implement them",
709 ));
710 }
711 return Err(failure.report("update", &url));
712 };
713 Ok(outcome)
714 })
715 .await
716 }
717
718 pub(crate) async fn put_blob(&self, upload: &BlobUpload) -> Result<()> {
719 upload.digest.validate()?;
720 let url = self.object_url(ObjectKind::Blob, &upload.digest)?;
721 retry_async("PUT", &url, self.retries, || async {
722 match &upload.source {
723 BlobSource::Bytes(bytes) => self.put_create(&url, bytes).await.map(drop),
724 BlobSource::File(file) => self.put_create_file(&url, file.path()).await,
725 BlobSource::Path(path) => self.put_create_file(&url, path).await,
726 }
727 })
728 .await
729 }
730
731 async fn put_create(&self, url: &Url, body: &[u8]) -> Result<bool> {
736 let mut dropped_condition = false;
737 loop {
738 let conditional = self.conditionals_enabled() && !dropped_condition;
739 let mut request = self
740 .signed(reqwest::Method::PUT, url, &PayloadHash::of(body))
741 .await?
742 .header(CONTENT_LENGTH, body.len())
743 .body(body.to_vec());
744 if conditional {
745 request = request.header(IF_NONE_MATCH, "*");
746 }
747 match self
748 .finish_create(url, request.send().await?, conditional)
749 .await?
750 {
751 Some(created) => {
752 if dropped_condition {
753 self.note_conditionals_unsupported();
754 }
755 return Ok(created);
756 }
757 None => dropped_condition = true,
758 }
759 }
760 }
761
762 async fn put_create_file(&self, url: &Url, path: &Path) -> Result<()> {
769 let mut dropped_condition = false;
770 loop {
771 let conditional = self.conditionals_enabled() && !dropped_condition;
772 let file = tokio::fs::File::open(path).await?;
773 let length = file.metadata().await?.len();
774 let mut request = self
775 .signed(reqwest::Method::PUT, url, &PayloadHash::Unsigned)
776 .await?
777 .header(CONTENT_LENGTH, length)
778 .body(reqwest::Body::wrap_stream(
779 tokio_util::io::ReaderStream::new(file),
780 ));
781 if conditional {
782 request = request.header(IF_NONE_MATCH, "*");
783 }
784 match self
785 .finish_create(url, request.send().await?, conditional)
786 .await?
787 {
788 Some(_) => {
789 if dropped_condition {
790 self.note_conditionals_unsupported();
791 }
792 return Ok(());
793 }
794 None => dropped_condition = true,
795 }
796 }
797 }
798
799 async fn finish_create(
804 &self,
805 url: &Url,
806 response: reqwest::Response,
807 conditional: bool,
808 ) -> Result<Option<bool>> {
809 let status = response.status();
810 if status.is_success() {
811 return Ok(Some(true));
812 }
813 if conditional && status == StatusCode::PRECONDITION_FAILED {
817 return Ok(Some(false));
818 }
819 if status == StatusCode::CONFLICT {
820 return Err(conditional_request_conflict(url));
821 }
822 let failure = FailedRequest::read(response).await;
823 if conditional && failure.is_not_implemented() {
824 if self.may_drop_conditionals() {
825 return Ok(None);
826 }
827 return Err(failure.report("store", url).wrap_err(
828 "conditional writes are required but this store does not implement them",
829 ));
830 }
831 Err(failure.report("store", url))
832 }
833
834 pub(crate) async fn get_action_results(
839 &self,
840 actions: &[CacheDigest],
841 ) -> Result<Option<Vec<RemoteActionResult>>> {
842 Ok(actions.is_empty().then(Vec::new))
843 }
844
845 pub(crate) async fn action_batch_limit(&self) -> Result<Option<usize>> {
846 Ok(None)
847 }
848
849 pub(crate) async fn get_blob_pack(
850 &self,
851 _digests: &[CacheDigest],
852 _staging_dir: &Path,
853 ) -> Result<Option<RemoteBlobPack>> {
854 Ok(None)
855 }
856
857 pub(crate) async fn get_blob_pack_with_limit(
858 &self,
859 _digests: &[CacheDigest],
860 _staging_dir: &Path,
861 _max_bytes: u64,
862 ) -> Result<Option<RemoteBlobPack>> {
863 Ok(None)
864 }
865
866 pub(crate) async fn blob_pack_upload_limits(&self) -> Result<Option<crate::BlobPackLimits>> {
867 Ok(None)
868 }
869
870 pub(crate) async fn put_blob_pack(
871 &self,
872 uploads: &[BlobUpload],
873 ) -> Result<Option<BlobPackReceipt>> {
874 Ok(uploads.is_empty().then_some(BlobPackReceipt {
875 created: 0,
876 existing: 0,
877 }))
878 }
879}
880
881fn conditional_request_conflict(url: &Url) -> eyre::Report {
883 eyre::Report::new(TransientRequest("conditional request conflict")).wrap_err(format!(
884 "a concurrent conditional write to {url} conflicted"
885 ))
886}
887
888struct FailedRequest {
893 status: StatusCode,
894 code: Option<String>,
895}
896
897impl FailedRequest {
898 async fn read(mut response: reqwest::Response) -> Self {
899 let status = response.status();
900 let mut body = Vec::new();
904 while body.len() < MAX_ERROR_BODY_BYTES {
905 match response.chunk().await {
906 Ok(Some(chunk)) => body.extend_from_slice(&chunk),
907 Ok(None) | Err(_) => break,
908 }
909 }
910 body.truncate(MAX_ERROR_BODY_BYTES);
911 Self {
912 status,
913 code: error_code(&String::from_utf8_lossy(&body)).map(str::to_string),
914 }
915 }
916
917 fn is_not_implemented(&self) -> bool {
924 self.status == StatusCode::NOT_IMPLEMENTED
925 || (self.status == StatusCode::BAD_REQUEST
926 && self.code.as_deref() == Some("NotImplemented"))
927 }
928
929 fn is_credentials_rejected(&self) -> bool {
936 matches!(
937 (self.status, self.code.as_deref()),
938 (
939 StatusCode::BAD_REQUEST,
940 Some("ExpiredToken" | "TokenRefreshRequired" | "InvalidToken")
941 ) | (
942 StatusCode::FORBIDDEN,
943 Some(
944 "SignatureDoesNotMatch"
945 | "InvalidAccessKeyId"
946 | "InvalidSecurity"
947 | "ExpiredToken"
948 | "TokenRefreshRequired"
949 | "RequestTimeTooSkewed"
950 )
951 )
952 )
953 }
954
955 fn is_retryable(&self) -> bool {
962 matches!(self.status.as_u16(), 408 | 429 | 500 | 502 | 503 | 504)
963 }
964
965 fn report(&self, verb: &str, url: &Url) -> eyre::Report {
966 let detail = match &self.code {
967 Some(code) => format!("failed to {verb} {url}: {} ({code})", self.status),
968 None => format!("failed to {verb} {url}: {}", self.status),
969 };
970 if self.is_retryable() {
971 return eyre::Report::new(TransientRequest("the store asked to be retried"))
976 .wrap_err(detail);
977 }
978 eyre!(detail)
979 }
980}
981
982fn error_code(body: &str) -> Option<&str> {
988 let start = body.find("<Code>")? + "<Code>".len();
989 let end = body[start..].find("</Code>")? + start;
990 Some(body[start..end].trim()).filter(|code| !code.is_empty())
991}
992
993fn validate_bucket(bucket: &str) -> Result<()> {
995 let bucket = bucket.trim();
996 if bucket.is_empty() {
997 bail!("an S3 remote cache needs a bucket");
998 }
999 if bucket.contains('/') || bucket.starts_with('.') || bucket.ends_with('.') {
1000 bail!("invalid S3 bucket name {bucket:?}");
1001 }
1002 Ok(())
1003}
1004
1005fn normalize_prefix(prefix: &str) -> Result<String> {
1007 let prefix = prefix.trim().trim_matches('/');
1008 if prefix.is_empty() {
1009 return Ok(String::new());
1010 }
1011 validate_key_path(prefix, "remote cache prefix")?;
1012 Ok(format!("{prefix}/"))
1013}
1014
1015fn validate_key_path(value: &str, what: &str) -> Result<()> {
1021 let value = value.trim();
1022 if value.is_empty() {
1023 bail!("{what} must not be empty");
1024 }
1025 if value.starts_with('/') || value.ends_with('/') {
1026 bail!("{what} {value:?} must not start or end with a slash");
1027 }
1028 for segment in value.split('/') {
1029 if segment.is_empty() {
1030 bail!("{what} {value:?} must not contain an empty path segment");
1031 }
1032 if segment == "." || segment == ".." {
1033 bail!("{what} {value:?} must not contain a relative path segment");
1034 }
1035 if !segment
1036 .bytes()
1037 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
1038 {
1039 bail!(
1040 "{what} {value:?} must use only letters, digits, '.', '_', '-', and '/' \
1041 when the remote cache is an object store"
1042 );
1043 }
1044 }
1045 Ok(())
1046}
1047
1048fn base_url(config: &S3RemoteCacheConfig) -> Result<Url> {
1056 let bucket = config.bucket.trim();
1057 let (mut url, path_style) = match &config.endpoint {
1058 Some(endpoint) => (endpoint.clone(), config.force_path_style.unwrap_or(true)),
1059 None => (
1060 format!("https://s3.{}.amazonaws.com", config.region.trim()).parse()?,
1061 config
1062 .force_path_style
1063 .unwrap_or_else(|| bucket.contains('.')),
1064 ),
1065 };
1066 if path_style {
1067 let path = url.path().trim_end_matches('/').to_string();
1068 url.set_path(&format!("{path}/{bucket}/"));
1069 } else {
1070 let host = url
1071 .host_str()
1072 .ok_or_else(|| eyre!("an S3 endpoint must have a host"))?;
1073 url.set_host(Some(&format!("{bucket}.{host}")))?;
1074 let path = url.path().trim_end_matches('/').to_string();
1077 url.set_path(&format!("{path}/"));
1078 }
1079 Ok(url)
1080}
1081
1082#[cfg(test)]
1083#[path = "remote_s3_tests.rs"]
1084mod tests;