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::time::{Duration, Instant, SystemTime};
32use tokio::io::AsyncWriteExt;
33use url::Url;
34
35const LAYOUT_VERSION: u8 = 1;
40const CONNECTIVITY_PROBE_KEY: &str = "connectivity-probe";
43const MAX_ERROR_BODY_BYTES: usize = 8 * 1024;
45const RENEWAL_MARGIN: Duration = Duration::from_secs(5 * 60);
49const RENEWAL_RETRY: Duration = Duration::from_secs(30);
52
53#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, strum::EnumString, strum::Display)]
55#[strum(serialize_all = "kebab-case")]
56pub enum S3ConditionalWrites {
57 #[default]
59 Auto,
60 Required,
62 Off,
64}
65
66pub struct S3RemoteCacheConfig {
68 pub bucket: String,
70 pub prefix: String,
72 pub namespace: String,
74 pub region: String,
76 pub endpoint: Option<Url>,
78 pub force_path_style: Option<bool>,
80 pub conditional_writes: S3ConditionalWrites,
82 pub credentials: S3Credentials,
84 pub connect_timeout: Duration,
86 pub read_timeout: Duration,
88 pub download_timeout: Duration,
97 pub retries: i64,
99}
100
101#[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
119enum CredentialSource {
121 Fixed(S3Credentials),
123 InstanceRole(InstanceRole),
125}
126
127struct InstanceRole {
128 provider: InstanceRoleCredentials,
129 state: tokio::sync::Mutex<RenewalState>,
132}
133
134struct RenewalState {
135 credentials: S3Credentials,
136 expires_at: SystemTime,
137 retry_at: Option<Instant>,
140 failing: bool,
143}
144
145impl CredentialSource {
146 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 base_url: Url,
194 root: String,
196 region: String,
197 credentials: CredentialSource,
198 conditional_writes: S3ConditionalWrites,
199 conditionals_disabled: AtomicBool,
202 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 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 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 fn conditionals_enabled(&self) -> bool {
306 self.conditional_writes != S3ConditionalWrites::Off
307 && !self.conditionals_disabled.load(Ordering::Relaxed)
308 }
309
310 fn may_drop_conditionals(&self) -> bool {
315 self.conditional_writes == S3ConditionalWrites::Auto
316 }
317
318 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 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 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 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 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 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 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 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 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 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 break ManifestPutOutcome::PreconditionFailed;
621 }
622 if status == StatusCode::CONFLICT {
623 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 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 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 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 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 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
808fn 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
815struct 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 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 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 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 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 return eyre::Report::new(TransientRequest("the store asked to be retried"))
898 .wrap_err(detail);
899 }
900 eyre!(detail)
901 }
902}
903
904fn 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
915fn 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
927fn 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
937fn 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
970fn 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 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;