use crate::keyspace::parse_endpoint_url;
use crate::object_store::Result;
use crate::presign::PresignedUrl;
use crate::presign::{S3CompatiblePresigner, S3PresignerConfig};
use crate::secret::SecretString;
use crate::store_io_runtime::StoreIoRuntime;
use crate::{
ByteRange, ByteStream, MultipartCompletion, MultipartPart, ObjectBody, ObjectMetadata,
ObjectStore, ObjectStoreError, ProviderObjectStore, ProviderObjectStoreConfig, PutMode,
StoredObjectChecksum,
};
use async_trait::async_trait;
use base64::Engine as _;
use bytes::Bytes;
use futures::stream::BoxStream;
use loonfs_api::wire::hex::hex_encode_bytes;
use loonfs_api::{ChecksumAlgorithm, StorageChecksum};
use object_store::aws::{AmazonS3Builder, Checksum};
use object_store::client::{HttpClient, HttpConnector, HttpRequestBody};
use std::fmt;
use std::sync::Arc;
use std::time::{Duration, SystemTime};
const CHECKSUM_HEAD_TTL: Duration = Duration::from_secs(60);
const MULTIPART_CONTROL_TTL: Duration = Duration::from_secs(60);
const S3_CHECKSUM_HEADERS: &[(&str, ChecksumAlgorithm)] = &[
("x-amz-checksum-sha256", ChecksumAlgorithm::Sha256),
("x-amz-checksum-crc64nvme", ChecksumAlgorithm::Crc64nvme),
("x-amz-checksum-crc32c", ChecksumAlgorithm::Crc32c),
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AwsS3StoreConfig {
pub bucket: String,
pub region: String,
pub endpoint_url: Option<String>,
pub access_key_id: SecretString,
pub secret_access_key: SecretString,
pub session_token: Option<SecretString>,
pub key_prefix: Option<String>,
pub force_path_style: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CloudflareR2StoreConfig {
pub bucket: String,
pub account_id: String,
pub endpoint_url: String,
pub access_key_id: SecretString,
pub secret_access_key: SecretString,
pub key_prefix: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct S3CompatibleConfig {
provider_name: &'static str,
bucket: String,
region: String,
endpoint_url: Option<String>,
access_key_id: SecretString,
secret_access_key: SecretString,
session_token: Option<SecretString>,
key_prefix: Option<String>,
force_path_style: bool,
sha256_upload_checksum: bool,
}
#[derive(Clone)]
pub struct S3CompatibleStore {
provider_name: &'static str,
inner: ProviderObjectStore,
request_signer: S3CompatiblePresigner,
http: HttpClient,
_io_runtime: StoreIoRuntime,
}
impl fmt::Debug for S3CompatibleStore {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("S3CompatibleStore")
.field("provider_name", &self.provider_name)
.finish_non_exhaustive()
}
}
impl S3CompatibleStore {
pub fn aws_s3(config: AwsS3StoreConfig) -> Result<Self> {
Self::new(S3CompatibleConfig {
provider_name: "aws-s3",
bucket: config.bucket,
region: config.region,
endpoint_url: config.endpoint_url,
access_key_id: config.access_key_id,
secret_access_key: config.secret_access_key,
session_token: config.session_token,
key_prefix: config.key_prefix,
force_path_style: config.force_path_style,
sha256_upload_checksum: true,
})
}
pub fn cloudflare_r2(config: CloudflareR2StoreConfig) -> Result<Self> {
if config.account_id.trim().is_empty() {
return Err(ObjectStoreError::Configuration(
"account id must not be empty".to_owned(),
));
}
Self::new(S3CompatibleConfig {
provider_name: "cloudflare-r2",
bucket: config.bucket,
region: "auto".to_owned(),
endpoint_url: Some(config.endpoint_url),
access_key_id: config.access_key_id,
secret_access_key: config.secret_access_key,
session_token: None,
key_prefix: config.key_prefix,
force_path_style: true,
sha256_upload_checksum: false,
})
}
fn new(config: S3CompatibleConfig) -> Result<Self> {
validate_config(&config)?;
let endpoint_url = config
.endpoint_url
.as_deref()
.map(|endpoint| {
object_store_endpoint_url(&config.bucket, endpoint, config.force_path_style)
})
.transpose()?;
let request_signer = S3CompatiblePresigner::new(S3PresignerConfig {
bucket: config.bucket.clone(),
region: config.region.clone(),
endpoint_url: config.endpoint_url.clone(),
access_key_id: config.access_key_id.clone(),
secret_access_key: config.secret_access_key.clone(),
session_token: config.session_token.clone(),
key_prefix: config.key_prefix.clone(),
force_path_style: config.force_path_style,
})?;
let io_runtime = StoreIoRuntime::new()?;
let http = io_runtime
.connector()
.connect(&crate::provider_object_store::provider_client_options())
.map_err(|err| ObjectStoreError::Configuration(err.to_string()))?;
let mut builder = AmazonS3Builder::new()
.with_http_connector(io_runtime.connector())
.with_client_options(crate::provider_object_store::provider_client_options())
.with_retry(crate::provider_object_store::provider_retry_config())
.with_bucket_name(config.bucket)
.with_region(config.region)
.with_access_key_id(config.access_key_id.expose())
.with_secret_access_key(config.secret_access_key.expose())
.with_virtual_hosted_style_request(!config.force_path_style);
if let Some(endpoint_url) = endpoint_url {
let allow_http = endpoint_url.starts_with("http://");
builder = builder
.with_endpoint(endpoint_url)
.with_allow_http(allow_http);
}
if let Some(session_token) = &config.session_token {
builder = builder.with_token(session_token.expose());
}
if config.sha256_upload_checksum {
builder = builder.with_checksum_algorithm(Checksum::SHA256);
}
let provider = Arc::new(
builder
.build()
.map_err(|err| ObjectStoreError::Configuration(err.to_string()))?,
);
let inner = ProviderObjectStore::new(
Arc::clone(&provider) as Arc<dyn object_store::ObjectStore>,
Some(provider),
ProviderObjectStoreConfig {
key_prefix: config.key_prefix,
},
)?;
Ok(Self {
provider_name: config.provider_name,
inner,
request_signer,
http,
_io_runtime: io_runtime,
})
}
#[allow(clippy::disallowed_methods)]
fn signing_time() -> SystemTime {
SystemTime::now()
}
async fn execute_signed(
&self,
key: &str,
signed: PresignedUrl,
body: HttpRequestBody,
) -> Result<SignedResponse> {
let mut builder = http::Request::builder()
.method(signed.method.as_str())
.uri(&signed.url);
for (name, value) in &signed.headers {
builder = builder.header(name, value);
}
let request = builder
.body(body)
.map_err(|err| ObjectStoreError::transport(key, err.to_string()))?;
let response = self
.http
.execute(request)
.await
.map_err(|err| ObjectStoreError::transport(key, err.to_string()))?;
let status = response.status();
let headers = response.headers().clone();
let body = response
.into_body()
.bytes()
.await
.map_err(|err| ObjectStoreError::transport(key, err.to_string()))?;
Ok(SignedResponse {
status,
headers,
body,
})
}
}
struct SignedResponse {
status: http::StatusCode,
headers: http::HeaderMap,
body: bytes::Bytes,
}
impl SignedResponse {
fn text(&self) -> std::borrow::Cow<'_, str> {
String::from_utf8_lossy(&self.body)
}
fn provider_error_code(&self) -> Option<String> {
let text = self.text();
if self.status.is_success() && !text.contains("<Error") {
return None;
}
Some(xml_element(&text, "Code").unwrap_or_else(|| self.status.to_string()))
}
}
#[async_trait]
impl ObjectStore for S3CompatibleStore {
async fn head(&self, key: &str) -> Result<Option<ObjectMetadata>> {
self.inner.head(key).await
}
async fn head_stored_checksum(&self, key: &str) -> Result<Option<StoredObjectChecksum>> {
let signed = self.request_signer.presign_head_stored_checksum(
key,
CHECKSUM_HEAD_TTL,
Self::signing_time(),
)?;
let response = self
.execute_signed(key, signed, HttpRequestBody::empty())
.await?;
let status = response.status;
if status == http::StatusCode::NOT_FOUND {
return Ok(None);
}
if status == http::StatusCode::FORBIDDEN || status == http::StatusCode::UNAUTHORIZED {
return Err(ObjectStoreError::PermissionDenied {
object_key: key.to_owned(),
message: format!("provider refused the checksum head with {status}"),
});
}
if !status.is_success() {
return Err(ObjectStoreError::transport(
key,
format!("checksum head failed with {status}"),
));
}
let headers = &response.headers;
let Some(storage_checksum) = s3_stored_checksum(headers) else {
return Err(ObjectStoreError::transport(
key,
"provider reported no full-object checksum for this object".to_owned(),
));
};
let size_bytes = headers
.get(http::header::CONTENT_LENGTH)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse::<u64>().ok())
.ok_or_else(|| {
ObjectStoreError::transport(key, "checksum head reported no content length")
})?;
Ok(Some(StoredObjectChecksum {
size_bytes,
storage_checksum,
}))
}
async fn create_multipart_upload(&self, key: &str) -> Result<String> {
let signed = self.request_signer.presign_create_multipart(
key,
MULTIPART_CONTROL_TTL,
Self::signing_time(),
)?;
let response = self
.execute_signed(key, signed, HttpRequestBody::empty())
.await?;
if let Some(code) = response.provider_error_code() {
return Err(multipart_error(key, "create", &code));
}
xml_element(&response.text(), "UploadId").ok_or_else(|| {
ObjectStoreError::transport(key, "multipart create returned no upload id")
})
}
async fn complete_multipart_upload(
&self,
key: &str,
provider_upload_id: &str,
parts: &[MultipartPart],
full_object_checksum: &StorageChecksum,
) -> Result<MultipartCompletion> {
let signed = self.request_signer.presign_complete_multipart(
key,
provider_upload_id,
full_object_checksum,
MULTIPART_CONTROL_TTL,
Self::signing_time(),
)?;
let response = self
.execute_signed(key, signed, complete_multipart_body(parts)?.into())
.await?;
match response.provider_error_code().as_deref() {
None => Ok(MultipartCompletion::Assembled),
Some("NoSuchUpload") => Ok(MultipartCompletion::UnknownUpload),
Some(code) => Err(multipart_error(key, "complete", code)),
}
}
async fn abort_multipart_upload(&self, key: &str, provider_upload_id: &str) -> Result<()> {
let signed = self.request_signer.presign_abort_multipart(
key,
provider_upload_id,
MULTIPART_CONTROL_TTL,
Self::signing_time(),
)?;
let response = self
.execute_signed(key, signed, HttpRequestBody::empty())
.await?;
match response.provider_error_code().as_deref() {
None | Some("NoSuchUpload") => Ok(()),
Some(code) => Err(multipart_error(key, "abort", code)),
}
}
async fn get_with_metadata(&self, key: &str) -> Result<Option<ObjectBody>> {
self.inner.get_with_metadata(key).await
}
async fn get(&self, key: &str, range: Option<ByteRange>) -> Result<Option<Bytes>> {
self.inner.get(key, range).await
}
async fn put(&self, key: &str, bytes: Bytes, mode: PutMode) -> Result<ObjectMetadata> {
self.inner.put(key, bytes, mode).await
}
async fn put_streamed(&self, key: &str, body: ByteStream, mode: PutMode) -> Result<u64> {
self.inner.put_streamed(key, body, mode).await
}
async fn delete(&self, key: &str) -> Result<()> {
self.inner.delete(key).await
}
fn list_prefix_stream(&self, prefix: &str) -> BoxStream<'static, Result<String>> {
self.inner.list_prefix_stream(prefix)
}
}
fn s3_stored_checksum(headers: &http::HeaderMap) -> Option<StorageChecksum> {
for (header, algorithm) in S3_CHECKSUM_HEADERS {
let Some(value) = headers.get(*header).and_then(|value| value.to_str().ok()) else {
continue;
};
let Ok(raw) = base64::engine::general_purpose::STANDARD.decode(value) else {
continue;
};
if raw.len() != algorithm.value_bytes() {
continue;
}
return Some(StorageChecksum {
algorithm: *algorithm,
value: hex_encode_bytes(&raw),
});
}
None
}
fn xml_element(document: &str, name: &str) -> Option<String> {
let opening = format!("<{name}>");
let closing = format!("</{name}>");
let start = document.find(&opening)? + opening.len();
let end = document[start..].find(&closing)? + start;
Some(document[start..end].trim().to_owned())
}
fn xml_escape(value: &str) -> String {
value
.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
}
fn complete_multipart_body(parts: &[MultipartPart]) -> Result<String> {
if parts.is_empty() {
return Err(ObjectStoreError::InvalidContentRef(
"a multipart upload completes with at least one part".to_owned(),
));
}
let mut body = String::from("<CompleteMultipartUpload>");
let mut previous = 0;
for part in parts {
if part.part_number <= previous {
return Err(ObjectStoreError::InvalidContentRef(
"multipart parts must be listed once each, in ascending part order".to_owned(),
));
}
previous = part.part_number;
body.push_str("<Part><PartNumber>");
body.push_str(&part.part_number.to_string());
body.push_str("</PartNumber><ETag>");
body.push_str(&xml_escape(&part.etag));
body.push_str("</ETag><ChecksumCRC64NVME>");
body.push_str(&xml_escape(&base64_checksum(&part.checksum)?));
body.push_str("</ChecksumCRC64NVME></Part>");
}
body.push_str("</CompleteMultipartUpload>");
Ok(body)
}
fn base64_checksum(checksum: &StorageChecksum) -> Result<String> {
let raw = loonfs_api::wire::hex::hex_decode_bytes(&checksum.value).map_err(|_| {
ObjectStoreError::InvalidContentRef(format!(
"{} checksum must be lowercase hex",
checksum.algorithm
))
})?;
if raw.len() != checksum.algorithm.value_bytes() {
return Err(ObjectStoreError::InvalidContentRef(format!(
"{} checksum must be {} hex characters",
checksum.algorithm,
checksum.algorithm.value_bytes() * 2
)));
}
Ok(base64::engine::general_purpose::STANDARD.encode(raw))
}
fn multipart_error(key: &str, operation: &str, code: &str) -> ObjectStoreError {
match code {
"AccessDenied" | "InvalidAccessKeyId" | "SignatureDoesNotMatch" => {
ObjectStoreError::PermissionDenied {
object_key: key.to_owned(),
message: format!("provider refused multipart {operation}: {code}"),
}
}
code => ObjectStoreError::transport(key, format!("multipart {operation} failed: {code}")),
}
}
fn validate_config(config: &S3CompatibleConfig) -> Result<()> {
if config.bucket.trim().is_empty() {
return Err(ObjectStoreError::Configuration(
"bucket must not be empty".to_owned(),
));
}
if config.region.trim().is_empty() {
return Err(ObjectStoreError::Configuration(
"region must not be empty".to_owned(),
));
}
if config.access_key_id.expose().trim().is_empty() {
return Err(ObjectStoreError::Configuration(
"access key id must not be empty".to_owned(),
));
}
if config.secret_access_key.expose().trim().is_empty() {
return Err(ObjectStoreError::Configuration(
"secret access key must not be empty".to_owned(),
));
}
Ok(())
}
fn object_store_endpoint_url(
bucket: &str,
endpoint_url: &str,
force_path_style: bool,
) -> Result<String> {
if force_path_style {
return Ok(endpoint_url.to_owned());
}
let parsed = parse_endpoint_url(endpoint_url)?;
let bucket_prefix = format!("{}.", bucket.trim());
if parsed.authority.starts_with(&bucket_prefix) {
return Ok(endpoint_url.to_owned());
}
Ok(format!(
"{}://{}.{}/{}",
parsed.scheme, bucket, parsed.authority, parsed.path
)
.trim_end_matches('/')
.to_owned())
}
#[cfg(test)]
mod tests {
use super::{object_store_endpoint_url, S3CompatibleConfig, S3CompatibleStore};
fn test_config() -> S3CompatibleConfig {
S3CompatibleConfig {
provider_name: "test-s3",
bucket: "bucket".to_owned(),
region: "us-east-1".to_owned(),
endpoint_url: Some("http://127.0.0.1:9000".to_owned()),
access_key_id: "access".into(),
secret_access_key: "secret".into(),
session_token: None,
key_prefix: Some("tenant-a".to_owned()),
force_path_style: true,
sha256_upload_checksum: true,
}
}
#[test]
fn s3_compatible_store_builds_without_hidden_runtime() {
let store = S3CompatibleStore::new(test_config()).expect("construct store");
let debug = format!("{store:?}");
assert!(debug.contains("test-s3"));
}
#[test]
fn s3_compatible_store_rejects_blank_credentials() {
let mut config = test_config();
config.access_key_id = "".into();
assert!(S3CompatibleStore::new(config).is_err());
}
#[test]
fn virtual_hosted_endpoint_inserts_bucket_when_endpoint_is_bucketless() {
let endpoint =
object_store_endpoint_url("bucket", "https://s3.us-east-2.amazonaws.com", false)
.expect("endpoint");
assert_eq!(endpoint, "https://bucket.s3.us-east-2.amazonaws.com");
}
#[test]
fn virtual_hosted_endpoint_preserves_bucket_specific_endpoint() {
let endpoint =
object_store_endpoint_url("bucket", "https://bucket.s3.us-east-2.amazonaws.com", false)
.expect("endpoint");
assert_eq!(endpoint, "https://bucket.s3.us-east-2.amazonaws.com");
}
#[test]
fn path_style_endpoint_stays_bucketless() {
let endpoint =
object_store_endpoint_url("bucket", "https://s3.us-east-2.amazonaws.com", true)
.expect("endpoint");
assert_eq!(endpoint, "https://s3.us-east-2.amazonaws.com");
}
}