Skip to main content

loonfs_objectstore/
s3_compatible.rs

1//! [`S3CompatibleStore`]: the S3-API store, constructed per provider.
2//!
3//! AWS S3 and Cloudflare R2 differ by addressing, credentials, and whether
4//! uploads carry a client-computed checksum -- not by behaviour worth a type
5//! each, so both are constructors here.
6
7use 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
30/// Lifetime of the internally signed `HeadObject` used for checksum
31/// readback. The request is issued immediately and never handed out, so it
32/// only needs to outlive one round trip and its clock skew.
33const CHECKSUM_HEAD_TTL: Duration = Duration::from_secs(60);
34
35/// Lifetime of the internally signed multipart control requests. Like the
36/// checksum head, each is issued immediately and never handed out.
37const MULTIPART_CONTROL_TTL: Duration = Duration::from_secs(60);
38
39/// Provider checksum headers this adapter understands, in the order it
40/// prefers them, paired with the durable algorithm each one names.
41const 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/// Supplies explicit credentials, addressing, and key scoping for AWS S3.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct AwsS3StoreConfig {
50    /// Bucket that acts as the LoonFS object-store root.
51    pub bucket: String,
52    /// Signing region passed to the S3 client and presigner.
53    pub region: String,
54    /// S3-compatible endpoint override, or `None` for the regional AWS endpoint.
55    pub endpoint_url: Option<String>,
56    /// Access-key id used for SigV4 request signing.
57    pub access_key_id: SecretString,
58    /// Secret access key used for SigV4 request signing.
59    pub secret_access_key: SecretString,
60    /// Temporary credential token, or `None` for long-lived credentials.
61    pub session_token: Option<SecretString>,
62    /// Logical prefix prepended to every key, or `None` to use the bucket root.
63    pub key_prefix: Option<String>,
64    /// Selects path-style bucket addressing for compatible endpoints that require it.
65    pub force_path_style: bool,
66}
67
68/// Supplies explicit S3 credentials and account addressing for Cloudflare R2.
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct CloudflareR2StoreConfig {
71    /// R2 bucket that acts as the LoonFS object-store root.
72    pub bucket: String,
73    /// Cloudflare account identity, required even though requests use the explicit endpoint.
74    pub account_id: String,
75    /// Account-level R2 S3 endpoint; this adapter always uses path-style bucket addressing.
76    pub endpoint_url: String,
77    /// S3-compatible access-key id used for request signing.
78    pub access_key_id: SecretString,
79    /// S3-compatible secret used for request signing.
80    pub secret_access_key: SecretString,
81    /// Logical prefix prepended to every key, or `None` to use the bucket root.
82    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    /// Attach a client-computed SHA-256 to every upload so the provider
97    /// verifies the bytes on PUT (`x-amz-checksum-sha256`). Enabling it also
98    /// gives the provider a stored full-object checksum that
99    /// [`ObjectStore::head_stored_checksum`] can read back.
100    sha256_upload_checksum: bool,
101}
102
103/// Implements the LoonFS storage contract on an S3-API endpoint.
104#[derive(Clone)]
105pub struct S3CompatibleStore {
106    provider_name: &'static str,
107    inner: ProviderObjectStore,
108    /// Signs the requests the provider client cannot express: the
109    /// `HeadObject` that reads a stored checksum back, and the multipart
110    /// control calls that have to carry checksum headers. The SigV4 signer
111    /// this crate already owns for direct-put URLs can express all of them.
112    request_signer: S3CompatiblePresigner,
113    /// Sends those signed requests over the store's own IO runtime and
114    /// timeout scheme, exactly like every provider-client request.
115    http: HttpClient,
116    /// Keeps the HTTP IO runtime alive for the provider client's lifetime;
117    /// the connector inside the client holds only a handle onto it.
118    _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    /// Builds an AWS S3 store with bounded retries and SHA-256 upload checksums.
131    ///
132    /// Construction fails for invalid credentials, bucket, region, endpoint,
133    /// key prefix, runtime initialization, or provider-client configuration.
134    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    /// Builds a Cloudflare R2 store with path-style addressing.
150    ///
151    /// Construction fails for a blank account id or any invalid shared
152    /// configuration, including credentials, endpoint, and key prefix.
153    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            // The configured endpoint is the bucket-less account host; path
169            // style makes the client append the bucket. Virtual hosting would
170            // use the endpoint verbatim and address keys as buckets.
171            force_path_style: true,
172            // Measured live 2026-07-31: must stay off. With a checksum
173            // algorithm configured, the provider client signs multipart part
174            // uploads with the aws-chunked streaming-trailer encoding, and R2
175            // answers 501 Not Implemented to every part PUT. Plain PUTs with
176            // the header succeed (the first nine conformance assertions pass;
177            // the multipart-overwrite and streamed-write assertions fail), but
178            // one upstream knob configures both shapes, so it stays off
179            // wholesale. Nothing is lost: R2 stores its self-computed
180            // CRC-64/NVME, which `head_stored_checksum` reads back, and the
181            // presigned direct paths carry their own enforced checksum
182            // headers independent of this flag.
183            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        // A SigV4 signature is dated, so these internally issued requests
260        // enter wall time here. Nothing durable is derived from it.
261        SystemTime::now()
262    }
263
264    /// Issues one internally signed request and returns its status, headers,
265    /// and body.
266    ///
267    /// Every caller here signs a request the provider client cannot build,
268    /// so they all land on the same transport: the store's own HTTP client,
269    /// runtime, and timeouts.
270    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
306/// One internally signed response, read whole.
307///
308/// The bodies here are small provider control documents — an upload id, or
309/// an error — so reading them fully is what makes the S3 family's habit of
310/// reporting failures inside a 200 response detectable at all.
311struct 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    /// Reports the provider's own error code for this response, treating a
323    /// success status carrying an error document as the failure it is.
324    ///
325    /// `CompleteMultipartUpload` may answer 200 and then report a failure in
326    /// the body, because the provider holds the connection open while it
327    /// assembles the object. Reading only the status would take that for
328    /// success.
329    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            // A HEAD carries no body to quote, so the status is the whole
366            // diagnostic the provider gives us.
367            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            // The upload is gone, which says nothing about the object: an
431            // earlier completion may have consumed it and assembled exactly
432            // what was asked for. The caller decides from the object.
433            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            // Nothing to abandon is the state an abort is trying to reach.
450            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
480/// Reads whichever full-object checksum the provider stored.
481///
482/// The checksum *type* header is deliberately not consulted: R2 never sends
483/// one, and full-object coverage is established when LoonFS writes the
484/// object, not discovered when it reads the metadata back.
485fn 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
504/// Reads the text of the first `<name>` element in a provider document.
505///
506/// The S3 control documents this crate reads are a handful of flat elements
507/// — an upload id, an error code — so a scan finds them without an XML
508/// parser, and anything it cannot find is reported as absent rather than
509/// guessed at.
510fn 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('&', "&amp;")
521        .replace('<', "&lt;")
522        .replace('>', "&gt;")
523}
524
525/// Builds the part manifest `CompleteMultipartUpload` assembles from.
526///
527/// The parts are the client's bookkeeping: LoonFS never recorded them, so
528/// this document is the only place they exist on the server side, for
529/// exactly as long as one request.
530fn 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}