1use crate::keyspace::parse_endpoint_url;
8use crate::object_store::Result;
9use crate::presign::PresignedUrl;
10use crate::presign::{S3CompatiblePresigner, S3PresignerConfig};
11use crate::secret::SecretString;
12use crate::store_io_runtime::StoreIoRuntime;
13use crate::{
14 ByteRange, ByteStream, MultipartCompletion, MultipartPart, ObjectBody, ObjectMetadata,
15 ObjectStore, ObjectStoreError, ProviderObjectStore, ProviderObjectStoreConfig, PutMode,
16 StoredObjectChecksum,
17};
18use async_trait::async_trait;
19use base64::Engine as _;
20use bytes::Bytes;
21use futures::stream::BoxStream;
22use loonfs_api::wire::hex::hex_encode_bytes;
23use loonfs_api::{ChecksumAlgorithm, StorageChecksum};
24use object_store::aws::{AmazonS3Builder, Checksum};
25use object_store::client::{HttpClient, HttpConnector, HttpRequestBody};
26use std::fmt;
27use std::sync::Arc;
28use std::time::{Duration, SystemTime};
29
30const CHECKSUM_HEAD_TTL: Duration = Duration::from_secs(60);
34
35const MULTIPART_CONTROL_TTL: Duration = Duration::from_secs(60);
38
39const S3_CHECKSUM_HEADERS: &[(&str, ChecksumAlgorithm)] = &[
42 ("x-amz-checksum-sha256", ChecksumAlgorithm::Sha256),
43 ("x-amz-checksum-crc64nvme", ChecksumAlgorithm::Crc64nvme),
44 ("x-amz-checksum-crc32c", ChecksumAlgorithm::Crc32c),
45];
46
47#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct AwsS3StoreConfig {
50 pub bucket: String,
52 pub region: String,
54 pub endpoint_url: Option<String>,
56 pub access_key_id: SecretString,
58 pub secret_access_key: SecretString,
60 pub session_token: Option<SecretString>,
62 pub key_prefix: Option<String>,
64 pub force_path_style: bool,
66}
67
68#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct CloudflareR2StoreConfig {
71 pub bucket: String,
73 pub account_id: String,
75 pub endpoint_url: String,
77 pub access_key_id: SecretString,
79 pub secret_access_key: SecretString,
81 pub key_prefix: Option<String>,
83}
84
85#[derive(Debug, Clone, PartialEq, Eq)]
86struct S3CompatibleConfig {
87 provider_name: &'static str,
88 bucket: String,
89 region: String,
90 endpoint_url: Option<String>,
91 access_key_id: SecretString,
92 secret_access_key: SecretString,
93 session_token: Option<SecretString>,
94 key_prefix: Option<String>,
95 force_path_style: bool,
96 sha256_upload_checksum: bool,
101}
102
103#[derive(Clone)]
105pub struct S3CompatibleStore {
106 provider_name: &'static str,
107 inner: ProviderObjectStore,
108 request_signer: S3CompatiblePresigner,
113 http: HttpClient,
116 _io_runtime: StoreIoRuntime,
119}
120
121impl fmt::Debug for S3CompatibleStore {
122 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
123 f.debug_struct("S3CompatibleStore")
124 .field("provider_name", &self.provider_name)
125 .finish_non_exhaustive()
126 }
127}
128
129impl S3CompatibleStore {
130 pub fn aws_s3(config: AwsS3StoreConfig) -> Result<Self> {
135 Self::new(S3CompatibleConfig {
136 provider_name: "aws-s3",
137 bucket: config.bucket,
138 region: config.region,
139 endpoint_url: config.endpoint_url,
140 access_key_id: config.access_key_id,
141 secret_access_key: config.secret_access_key,
142 session_token: config.session_token,
143 key_prefix: config.key_prefix,
144 force_path_style: config.force_path_style,
145 sha256_upload_checksum: true,
146 })
147 }
148
149 pub fn cloudflare_r2(config: CloudflareR2StoreConfig) -> Result<Self> {
154 if config.account_id.trim().is_empty() {
155 return Err(ObjectStoreError::Configuration(
156 "account id must not be empty".to_owned(),
157 ));
158 }
159 Self::new(S3CompatibleConfig {
160 provider_name: "cloudflare-r2",
161 bucket: config.bucket,
162 region: "auto".to_owned(),
163 endpoint_url: Some(config.endpoint_url),
164 access_key_id: config.access_key_id,
165 secret_access_key: config.secret_access_key,
166 session_token: None,
167 key_prefix: config.key_prefix,
168 force_path_style: true,
172 sha256_upload_checksum: false,
184 })
185 }
186
187 fn new(config: S3CompatibleConfig) -> Result<Self> {
188 validate_config(&config)?;
189 let endpoint_url = config
190 .endpoint_url
191 .as_deref()
192 .map(|endpoint| {
193 object_store_endpoint_url(&config.bucket, endpoint, config.force_path_style)
194 })
195 .transpose()?;
196 let request_signer = S3CompatiblePresigner::new(S3PresignerConfig {
197 bucket: config.bucket.clone(),
198 region: config.region.clone(),
199 endpoint_url: config.endpoint_url.clone(),
200 access_key_id: config.access_key_id.clone(),
201 secret_access_key: config.secret_access_key.clone(),
202 session_token: config.session_token.clone(),
203 key_prefix: config.key_prefix.clone(),
204 force_path_style: config.force_path_style,
205 })?;
206
207 let io_runtime = StoreIoRuntime::new()?;
208 let http = io_runtime
209 .connector()
210 .connect(&crate::provider_object_store::provider_client_options())
211 .map_err(|err| ObjectStoreError::Configuration(err.to_string()))?;
212 let mut builder = AmazonS3Builder::new()
213 .with_http_connector(io_runtime.connector())
214 .with_client_options(crate::provider_object_store::provider_client_options())
215 .with_retry(crate::provider_object_store::provider_retry_config())
216 .with_bucket_name(config.bucket)
217 .with_region(config.region)
218 .with_access_key_id(config.access_key_id.expose())
219 .with_secret_access_key(config.secret_access_key.expose())
220 .with_virtual_hosted_style_request(!config.force_path_style);
221
222 if let Some(endpoint_url) = endpoint_url {
223 let allow_http = endpoint_url.starts_with("http://");
224 builder = builder
225 .with_endpoint(endpoint_url)
226 .with_allow_http(allow_http);
227 }
228 if let Some(session_token) = &config.session_token {
229 builder = builder.with_token(session_token.expose());
230 }
231 if config.sha256_upload_checksum {
232 builder = builder.with_checksum_algorithm(Checksum::SHA256);
233 }
234
235 let provider = Arc::new(
236 builder
237 .build()
238 .map_err(|err| ObjectStoreError::Configuration(err.to_string()))?,
239 );
240 let inner = ProviderObjectStore::new(
241 Arc::clone(&provider) as Arc<dyn object_store::ObjectStore>,
242 Some(provider),
243 ProviderObjectStoreConfig {
244 key_prefix: config.key_prefix,
245 },
246 )?;
247
248 Ok(Self {
249 provider_name: config.provider_name,
250 inner,
251 request_signer,
252 http,
253 _io_runtime: io_runtime,
254 })
255 }
256
257 #[allow(clippy::disallowed_methods)]
258 fn signing_time() -> SystemTime {
259 SystemTime::now()
262 }
263
264 async fn execute_signed(
271 &self,
272 key: &str,
273 signed: PresignedUrl,
274 body: HttpRequestBody,
275 ) -> Result<SignedResponse> {
276 let mut builder = http::Request::builder()
277 .method(signed.method.as_str())
278 .uri(&signed.url);
279 for (name, value) in &signed.headers {
280 builder = builder.header(name, value);
281 }
282 let request = builder
283 .body(body)
284 .map_err(|err| ObjectStoreError::transport(key, err.to_string()))?;
285 let response = self
286 .http
287 .execute(request)
288 .await
289 .map_err(|err| ObjectStoreError::transport(key, err.to_string()))?;
290
291 let status = response.status();
292 let headers = response.headers().clone();
293 let body = response
294 .into_body()
295 .bytes()
296 .await
297 .map_err(|err| ObjectStoreError::transport(key, err.to_string()))?;
298 Ok(SignedResponse {
299 status,
300 headers,
301 body,
302 })
303 }
304}
305
306struct SignedResponse {
312 status: http::StatusCode,
313 headers: http::HeaderMap,
314 body: bytes::Bytes,
315}
316
317impl SignedResponse {
318 fn text(&self) -> std::borrow::Cow<'_, str> {
319 String::from_utf8_lossy(&self.body)
320 }
321
322 fn provider_error_code(&self) -> Option<String> {
330 let text = self.text();
331 if self.status.is_success() && !text.contains("<Error") {
332 return None;
333 }
334 Some(xml_element(&text, "Code").unwrap_or_else(|| self.status.to_string()))
335 }
336}
337
338#[async_trait]
339impl ObjectStore for S3CompatibleStore {
340 async fn head(&self, key: &str) -> Result<Option<ObjectMetadata>> {
341 self.inner.head(key).await
342 }
343
344 async fn head_stored_checksum(&self, key: &str) -> Result<Option<StoredObjectChecksum>> {
345 let signed = self.request_signer.presign_head_stored_checksum(
346 key,
347 CHECKSUM_HEAD_TTL,
348 Self::signing_time(),
349 )?;
350 let response = self
351 .execute_signed(key, signed, HttpRequestBody::empty())
352 .await?;
353
354 let status = response.status;
355 if status == http::StatusCode::NOT_FOUND {
356 return Ok(None);
357 }
358 if status == http::StatusCode::FORBIDDEN || status == http::StatusCode::UNAUTHORIZED {
359 return Err(ObjectStoreError::PermissionDenied {
360 object_key: key.to_owned(),
361 message: format!("provider refused the checksum head with {status}"),
362 });
363 }
364 if !status.is_success() {
365 return Err(ObjectStoreError::transport(
368 key,
369 format!("checksum head failed with {status}"),
370 ));
371 }
372
373 let headers = &response.headers;
374 let Some(storage_checksum) = s3_stored_checksum(headers) else {
375 return Err(ObjectStoreError::transport(
376 key,
377 "provider reported no full-object checksum for this object".to_owned(),
378 ));
379 };
380 let size_bytes = headers
381 .get(http::header::CONTENT_LENGTH)
382 .and_then(|value| value.to_str().ok())
383 .and_then(|value| value.parse::<u64>().ok())
384 .ok_or_else(|| {
385 ObjectStoreError::transport(key, "checksum head reported no content length")
386 })?;
387
388 Ok(Some(StoredObjectChecksum {
389 size_bytes,
390 storage_checksum,
391 }))
392 }
393
394 async fn create_multipart_upload(&self, key: &str) -> Result<String> {
395 let signed = self.request_signer.presign_create_multipart(
396 key,
397 MULTIPART_CONTROL_TTL,
398 Self::signing_time(),
399 )?;
400 let response = self
401 .execute_signed(key, signed, HttpRequestBody::empty())
402 .await?;
403 if let Some(code) = response.provider_error_code() {
404 return Err(multipart_error(key, "create", &code));
405 }
406 xml_element(&response.text(), "UploadId").ok_or_else(|| {
407 ObjectStoreError::transport(key, "multipart create returned no upload id")
408 })
409 }
410
411 async fn complete_multipart_upload(
412 &self,
413 key: &str,
414 provider_upload_id: &str,
415 parts: &[MultipartPart],
416 full_object_checksum: &StorageChecksum,
417 ) -> Result<MultipartCompletion> {
418 let signed = self.request_signer.presign_complete_multipart(
419 key,
420 provider_upload_id,
421 full_object_checksum,
422 MULTIPART_CONTROL_TTL,
423 Self::signing_time(),
424 )?;
425 let response = self
426 .execute_signed(key, signed, complete_multipart_body(parts)?.into())
427 .await?;
428 match response.provider_error_code().as_deref() {
429 None => Ok(MultipartCompletion::Assembled),
430 Some("NoSuchUpload") => Ok(MultipartCompletion::UnknownUpload),
434 Some(code) => Err(multipart_error(key, "complete", code)),
435 }
436 }
437
438 async fn abort_multipart_upload(&self, key: &str, provider_upload_id: &str) -> Result<()> {
439 let signed = self.request_signer.presign_abort_multipart(
440 key,
441 provider_upload_id,
442 MULTIPART_CONTROL_TTL,
443 Self::signing_time(),
444 )?;
445 let response = self
446 .execute_signed(key, signed, HttpRequestBody::empty())
447 .await?;
448 match response.provider_error_code().as_deref() {
449 None | Some("NoSuchUpload") => Ok(()),
451 Some(code) => Err(multipart_error(key, "abort", code)),
452 }
453 }
454
455 async fn get_with_metadata(&self, key: &str) -> Result<Option<ObjectBody>> {
456 self.inner.get_with_metadata(key).await
457 }
458
459 async fn get(&self, key: &str, range: Option<ByteRange>) -> Result<Option<Bytes>> {
460 self.inner.get(key, range).await
461 }
462
463 async fn put(&self, key: &str, bytes: Bytes, mode: PutMode) -> Result<ObjectMetadata> {
464 self.inner.put(key, bytes, mode).await
465 }
466
467 async fn put_streamed(&self, key: &str, body: ByteStream, mode: PutMode) -> Result<u64> {
468 self.inner.put_streamed(key, body, mode).await
469 }
470
471 async fn delete(&self, key: &str) -> Result<()> {
472 self.inner.delete(key).await
473 }
474
475 fn list_prefix_stream(&self, prefix: &str) -> BoxStream<'static, Result<String>> {
476 self.inner.list_prefix_stream(prefix)
477 }
478}
479
480fn s3_stored_checksum(headers: &http::HeaderMap) -> Option<StorageChecksum> {
486 for (header, algorithm) in S3_CHECKSUM_HEADERS {
487 let Some(value) = headers.get(*header).and_then(|value| value.to_str().ok()) else {
488 continue;
489 };
490 let Ok(raw) = base64::engine::general_purpose::STANDARD.decode(value) else {
491 continue;
492 };
493 if raw.len() != algorithm.value_bytes() {
494 continue;
495 }
496 return Some(StorageChecksum {
497 algorithm: *algorithm,
498 value: hex_encode_bytes(&raw),
499 });
500 }
501 None
502}
503
504fn xml_element(document: &str, name: &str) -> Option<String> {
511 let opening = format!("<{name}>");
512 let closing = format!("</{name}>");
513 let start = document.find(&opening)? + opening.len();
514 let end = document[start..].find(&closing)? + start;
515 Some(document[start..end].trim().to_owned())
516}
517
518fn xml_escape(value: &str) -> String {
519 value
520 .replace('&', "&")
521 .replace('<', "<")
522 .replace('>', ">")
523}
524
525fn complete_multipart_body(parts: &[MultipartPart]) -> Result<String> {
531 if parts.is_empty() {
532 return Err(ObjectStoreError::InvalidContentRef(
533 "a multipart upload completes with at least one part".to_owned(),
534 ));
535 }
536 let mut body = String::from("<CompleteMultipartUpload>");
537 let mut previous = 0;
538 for part in parts {
539 if part.part_number <= previous {
540 return Err(ObjectStoreError::InvalidContentRef(
541 "multipart parts must be listed once each, in ascending part order".to_owned(),
542 ));
543 }
544 previous = part.part_number;
545 body.push_str("<Part><PartNumber>");
546 body.push_str(&part.part_number.to_string());
547 body.push_str("</PartNumber><ETag>");
548 body.push_str(&xml_escape(&part.etag));
549 body.push_str("</ETag><ChecksumCRC64NVME>");
550 body.push_str(&xml_escape(&base64_checksum(&part.checksum)?));
551 body.push_str("</ChecksumCRC64NVME></Part>");
552 }
553 body.push_str("</CompleteMultipartUpload>");
554 Ok(body)
555}
556
557fn base64_checksum(checksum: &StorageChecksum) -> Result<String> {
558 let raw = loonfs_api::wire::hex::hex_decode_bytes(&checksum.value).map_err(|_| {
559 ObjectStoreError::InvalidContentRef(format!(
560 "{} checksum must be lowercase hex",
561 checksum.algorithm
562 ))
563 })?;
564 if raw.len() != checksum.algorithm.value_bytes() {
565 return Err(ObjectStoreError::InvalidContentRef(format!(
566 "{} checksum must be {} hex characters",
567 checksum.algorithm,
568 checksum.algorithm.value_bytes() * 2
569 )));
570 }
571 Ok(base64::engine::general_purpose::STANDARD.encode(raw))
572}
573
574fn multipart_error(key: &str, operation: &str, code: &str) -> ObjectStoreError {
575 match code {
576 "AccessDenied" | "InvalidAccessKeyId" | "SignatureDoesNotMatch" => {
577 ObjectStoreError::PermissionDenied {
578 object_key: key.to_owned(),
579 message: format!("provider refused multipart {operation}: {code}"),
580 }
581 }
582 code => ObjectStoreError::transport(key, format!("multipart {operation} failed: {code}")),
583 }
584}
585
586fn validate_config(config: &S3CompatibleConfig) -> Result<()> {
587 if config.bucket.trim().is_empty() {
588 return Err(ObjectStoreError::Configuration(
589 "bucket must not be empty".to_owned(),
590 ));
591 }
592 if config.region.trim().is_empty() {
593 return Err(ObjectStoreError::Configuration(
594 "region must not be empty".to_owned(),
595 ));
596 }
597 if config.access_key_id.expose().trim().is_empty() {
598 return Err(ObjectStoreError::Configuration(
599 "access key id must not be empty".to_owned(),
600 ));
601 }
602 if config.secret_access_key.expose().trim().is_empty() {
603 return Err(ObjectStoreError::Configuration(
604 "secret access key must not be empty".to_owned(),
605 ));
606 }
607 Ok(())
608}
609
610fn object_store_endpoint_url(
611 bucket: &str,
612 endpoint_url: &str,
613 force_path_style: bool,
614) -> Result<String> {
615 if force_path_style {
616 return Ok(endpoint_url.to_owned());
617 }
618
619 let parsed = parse_endpoint_url(endpoint_url)?;
620 let bucket_prefix = format!("{}.", bucket.trim());
621 if parsed.authority.starts_with(&bucket_prefix) {
622 return Ok(endpoint_url.to_owned());
623 }
624
625 Ok(format!(
626 "{}://{}.{}/{}",
627 parsed.scheme, bucket, parsed.authority, parsed.path
628 )
629 .trim_end_matches('/')
630 .to_owned())
631}
632
633#[cfg(test)]
634mod tests {
635 use super::{object_store_endpoint_url, S3CompatibleConfig, S3CompatibleStore};
636
637 fn test_config() -> S3CompatibleConfig {
638 S3CompatibleConfig {
639 provider_name: "test-s3",
640 bucket: "bucket".to_owned(),
641 region: "us-east-1".to_owned(),
642 endpoint_url: Some("http://127.0.0.1:9000".to_owned()),
643 access_key_id: "access".into(),
644 secret_access_key: "secret".into(),
645 session_token: None,
646 key_prefix: Some("tenant-a".to_owned()),
647 force_path_style: true,
648 sha256_upload_checksum: true,
649 }
650 }
651
652 #[test]
653 fn s3_compatible_store_builds_without_hidden_runtime() {
654 let store = S3CompatibleStore::new(test_config()).expect("construct store");
655 let debug = format!("{store:?}");
656 assert!(debug.contains("test-s3"));
657 }
658
659 #[test]
660 fn s3_compatible_store_rejects_blank_credentials() {
661 let mut config = test_config();
662 config.access_key_id = "".into();
663 assert!(S3CompatibleStore::new(config).is_err());
664 }
665
666 #[test]
667 fn virtual_hosted_endpoint_inserts_bucket_when_endpoint_is_bucketless() {
668 let endpoint =
669 object_store_endpoint_url("bucket", "https://s3.us-east-2.amazonaws.com", false)
670 .expect("endpoint");
671
672 assert_eq!(endpoint, "https://bucket.s3.us-east-2.amazonaws.com");
673 }
674
675 #[test]
676 fn virtual_hosted_endpoint_preserves_bucket_specific_endpoint() {
677 let endpoint =
678 object_store_endpoint_url("bucket", "https://bucket.s3.us-east-2.amazonaws.com", false)
679 .expect("endpoint");
680
681 assert_eq!(endpoint, "https://bucket.s3.us-east-2.amazonaws.com");
682 }
683
684 #[test]
685 fn path_style_endpoint_stays_bucketless() {
686 let endpoint =
687 object_store_endpoint_url("bucket", "https://s3.us-east-2.amazonaws.com", true)
688 .expect("endpoint");
689
690 assert_eq!(endpoint, "https://s3.us-east-2.amazonaws.com");
691 }
692}