use crate::error::StorageError;
use crate::signing::{
derive_signing_key, hex_hmac_sha256, hex_sha256, utc_now_components,
};
use crate::storage::Storage;
use crate::StorageBackend;
use async_trait::async_trait;
use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS};
const AWS_ENCODE_SET: &AsciiSet = &CONTROLS
.add(b' ')
.add(b'!')
.add(b'#')
.add(b'$')
.add(b'%')
.add(b'&')
.add(b'\'')
.add(b'(')
.add(b')')
.add(b'*')
.add(b'+')
.add(b',')
.add(b'/')
.add(b':')
.add(b';')
.add(b'<')
.add(b'=')
.add(b'>')
.add(b'?')
.add(b'@')
.add(b'[')
.add(b']')
.add(b'{')
.add(b'}');
pub struct S3CompatBackend {
pub bucket: String,
pub region: String,
pub endpoint: String,
pub access_key: String,
pub secret_key: String,
pub path_style: bool,
client: reqwest::Client,
}
impl S3CompatBackend {
pub fn new(
bucket: impl Into<String>,
region: impl Into<String>,
endpoint: impl Into<String>,
access_key: impl Into<String>,
secret_key: impl Into<String>,
path_style: bool,
) -> Result<Self, StorageError> {
let endpoint = {
let e = endpoint.into().trim_end_matches('/').to_string();
if e.starts_with("http://") || e.starts_with("https://") {
e
} else {
format!("https://{}", e)
}
};
let client = reqwest::Client::builder()
.build()
.map_err(|e| StorageError::InvalidConfig(format!("build reqwest client: {}", e)))?;
Ok(Self {
bucket: bucket.into(),
region: region.into(),
endpoint,
access_key: access_key.into(),
secret_key: secret_key.into(),
path_style,
client,
})
}
fn object_url(&self, key: &str) -> String {
let trimmed_key = key.trim_start_matches('/');
format!("{}/{}/{}", self.endpoint, self.bucket, trimmed_key)
}
pub fn url_for(&self, key: &str) -> String {
self.object_url(key)
}
fn sign_request(
&self,
method: &str,
url: &reqwest::Url,
headers: &reqwest::header::HeaderMap,
payload_hash: &str,
amz_date: &str,
date_stamp: &str,
) -> Result<String, StorageError> {
let host = url
.host_str()
.ok_or_else(|| StorageError::InvalidConfig(format!("url missing host: {}", url)))?;
let host_header = match url.port() {
Some(p) => format!("{}:{}", host, p),
None => host.to_string(),
};
let canonical_uri = canonical_resource_path(url.path());
let pairs: Vec<(String, String)> = url
.query_pairs()
.map(|(k, v)| (k.into_owned(), v.into_owned()))
.collect();
let canonical_query = canonical_query_string(pairs);
let mut header_keys: Vec<String> = headers
.keys()
.map(|k| k.as_str().to_lowercase())
.collect();
for h in ["host", "x-amz-content-sha256", "x-amz-date"] {
if !header_keys.contains(&h.to_string()) {
header_keys.push(h.to_string());
}
}
header_keys.sort();
let mut canonical_headers = String::new();
for k in &header_keys {
let v = if k == "host" {
host_header.clone()
} else {
headers
.get(k.as_str())
.map(|hv| hv.to_str().unwrap_or_default().trim().to_string())
.unwrap_or_default()
};
canonical_headers.push_str(k);
canonical_headers.push(':');
canonical_headers.push_str(&v);
canonical_headers.push('\n');
}
let signed_headers = header_keys.join(";");
let canonical_request = format!(
"{}\n{}\n{}\n{}\n{}\n{}",
method.to_uppercase(),
canonical_uri,
canonical_query,
canonical_headers,
signed_headers,
payload_hash
);
let canonical_request_hash = hex_sha256(canonical_request.as_bytes());
let service = "s3";
let credential_scope = format!("{}/{}/{}/aws4_request", date_stamp, self.region, service);
let string_to_sign = format!(
"AWS4-HMAC-SHA256\n{}\n{}\n{}",
amz_date, credential_scope, canonical_request_hash
);
let signing_key = derive_signing_key(&self.secret_key, &date_stamp, &self.region, service);
let signature = hex_hmac_sha256(&signing_key, string_to_sign.as_bytes());
Ok(format!(
"AWS4-HMAC-SHA256 Credential={}/{}, SignedHeaders={}, Signature={}",
self.access_key, credential_scope, signed_headers, signature
))
}
async fn send_signed(
&self,
method: reqwest::Method,
key: &str,
body: Option<Vec<u8>>,
extra_headers: Vec<(String, String)>,
) -> Result<reqwest::Response, StorageError> {
let url_str = self.object_url(key);
let url: reqwest::Url = url_str
.parse()
.map_err(|e| StorageError::InvalidConfig(format!("parse url {}: {}", url_str, e)))?;
let empty_hash = hex_sha256(b"");
let payload_hash = body
.as_ref()
.map(|b| hex_sha256(b))
.unwrap_or_else(|| empty_hash.clone());
let (date_stamp, amz_date) = utc_now_components();
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(
"x-amz-content-sha256",
reqwest::header::HeaderValue::from_str(&payload_hash)
.map_err(|e| StorageError::InvalidConfig(format!("set content-sha256: {}", e)))?,
);
headers.insert(
"x-amz-date",
reqwest::header::HeaderValue::from_str(&amz_date)
.map_err(|e| StorageError::InvalidConfig(format!("set amz-date: {}", e)))?,
);
for (k, v) in extra_headers {
let hname = reqwest::header::HeaderName::from_bytes(k.as_bytes())
.map_err(|e| StorageError::InvalidConfig(format!("header name {}: {}", k, e)))?;
let hval = reqwest::header::HeaderValue::from_str(&v)
.map_err(|e| StorageError::InvalidConfig(format!("header value {}: {}", v, e)))?;
headers.insert(hname, hval);
}
let authorization = self.sign_request(
method.as_str(),
&url,
&headers,
&payload_hash,
&amz_date,
&date_stamp,
)?;
headers.insert(
reqwest::header::AUTHORIZATION,
reqwest::header::HeaderValue::from_str(&authorization)
.map_err(|e| StorageError::InvalidConfig(format!("set authorization: {}", e)))?,
);
let req = self
.client
.request(method, url)
.headers(headers)
.body(body.unwrap_or_default());
req.send()
.await
.map_err(|e| StorageError::Connection(format!("http send: {}", e)))
}
pub async fn put_object(
&self,
key: &str,
data: &[u8],
content_type: &str,
) -> Result<(), StorageError> {
let resp = self
.send_signed(
reqwest::Method::PUT,
key,
Some(data.to_vec()),
vec![("content-type".to_string(), content_type.to_string())],
)
.await?;
let status = resp.status().as_u16();
if (200..300).contains(&status) {
Ok(())
} else {
let body = resp.text().await.unwrap_or_default();
Err(StorageError::Put(format!(
"s3-compat put status {}: {}",
status, body
)))
}
}
pub async fn get_object(&self, key: &str) -> Result<Vec<u8>, StorageError> {
let resp = self
.send_signed(reqwest::Method::GET, key, None, vec![])
.await?;
let status = resp.status().as_u16();
if status == 404 {
return Err(StorageError::NotFound(key.to_string()));
}
if !(200..300).contains(&status) {
let body = resp.text().await.unwrap_or_default();
return Err(StorageError::Get(format!(
"s3-compat get status {}: {}",
status, body
)));
}
let bytes = resp
.bytes()
.await
.map_err(|e| StorageError::Get(format!("read body: {}", e)))?;
Ok(bytes.to_vec())
}
pub async fn delete_object(&self, key: &str) -> Result<(), StorageError> {
let resp = self
.send_signed(reqwest::Method::DELETE, key, None, vec![])
.await?;
let status = resp.status().as_u16();
if (200..300).contains(&status) {
Ok(())
} else {
let body = resp.text().await.unwrap_or_default();
Err(StorageError::Delete(format!(
"s3-compat delete status {}: {}",
status, body
)))
}
}
pub async fn head_object(&self, key: &str) -> Result<bool, StorageError> {
let resp = self
.send_signed(reqwest::Method::HEAD, key, None, vec![])
.await?;
let status = resp.status().as_u16();
if (200..300).contains(&status) {
Ok(true)
} else if status == 404 {
Ok(false)
} else {
let body = resp.text().await.unwrap_or_default();
Err(StorageError::Get(format!(
"s3-compat head status {}: {}",
status, body
)))
}
}
pub async fn list_objects(&self, prefix: Option<&str>) -> Result<Vec<String>, StorageError> {
let url_str = self.object_url("");
let mut url: reqwest::Url = url_str
.parse()
.map_err(|e| StorageError::InvalidConfig(format!("parse url {}: {}", url_str, e)))?;
url.query_pairs_mut().append_pair("list-type", "2");
if let Some(p) = prefix {
url.query_pairs_mut().append_pair("prefix", p);
}
let empty_hash = hex_sha256(b"");
let (date_stamp, amz_date) = utc_now_components();
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(
"x-amz-content-sha256",
reqwest::header::HeaderValue::from_str(&empty_hash)
.map_err(|e| StorageError::InvalidConfig(format!("set content-sha256: {}", e)))?,
);
headers.insert(
"x-amz-date",
reqwest::header::HeaderValue::from_str(&amz_date)
.map_err(|e| StorageError::InvalidConfig(format!("set amz-date: {}", e)))?,
);
let authorization = self.sign_request(
"GET",
&url,
&headers,
&empty_hash,
&amz_date,
&date_stamp,
)?;
headers.insert(
reqwest::header::AUTHORIZATION,
reqwest::header::HeaderValue::from_str(&authorization)
.map_err(|e| StorageError::InvalidConfig(format!("set authorization: {}", e)))?,
);
let resp = self
.client
.request(reqwest::Method::GET, url)
.headers(headers)
.send()
.await
.map_err(|e| StorageError::Connection(format!("http send: {}", e)))?;
let status = resp.status().as_u16();
if !(200..300).contains(&status) {
let body = resp.text().await.unwrap_or_default();
return Err(StorageError::Get(format!(
"s3-compat list status {}: {}",
status, body
)));
}
let xml = resp
.text()
.await
.map_err(|e| StorageError::Get(format!("read list body: {}", e)))?;
let mut keys = Vec::new();
let marker_start = "<Key>";
let marker_end = "</Key>";
let mut cursor = 0;
while let Some(start_idx) = xml[cursor..].find(marker_start) {
let abs_start = cursor + start_idx + marker_start.len();
if let Some(end_idx) = xml[abs_start..].find(marker_end) {
let abs_end = abs_start + end_idx;
keys.push(xml[abs_start..abs_end].to_string());
cursor = abs_end + marker_end.len();
} else {
break;
}
}
Ok(keys)
}
}
#[async_trait]
impl Storage for S3CompatBackend {
async fn put(
&self,
key: &str,
data: &[u8],
content_type: &str,
) -> Result<String, StorageError> {
self.put_object(key, data, content_type).await?;
Ok(self.url_for(key))
}
async fn get(&self, key: &str) -> Result<Vec<u8>, StorageError> {
self.get_object(key).await
}
async fn delete(&self, key: &str) -> Result<(), StorageError> {
self.delete_object(key).await
}
async fn exists(&self, key: &str) -> Result<bool, StorageError> {
self.head_object(key).await
}
}
fn canonical_resource_path(path: &str) -> String {
if path.is_empty() {
return "/".to_string();
}
let trimmed = path.trim_start_matches('/');
let encoded: Vec<String> = trimmed
.split('/')
.map(|seg| utf8_percent_encode(seg, AWS_ENCODE_SET).to_string())
.collect();
let mut result = String::with_capacity(trimmed.len() + 1);
result.push('/');
result.push_str(&encoded.join("/"));
result
}
fn canonical_query_string(pairs: Vec<(String, String)>) -> String {
let mut pairs: Vec<(String, String)> = pairs
.into_iter()
.map(|(k, v)| {
(
utf8_percent_encode(&k, AWS_ENCODE_SET).to_string(),
utf8_percent_encode(&v, AWS_ENCODE_SET).to_string(),
)
})
.collect();
pairs.sort();
pairs
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>()
.join("&")
}
#[async_trait]
impl StorageBackend for S3CompatBackend {
async fn put_object(
&self,
key: &str,
data: &[u8],
content_type: &str,
) -> Result<(), StorageError> {
S3CompatBackend::put_object(self, key, data, content_type).await
}
async fn get_object(&self, key: &str) -> Result<Vec<u8>, StorageError> {
S3CompatBackend::get_object(self, key).await
}
async fn delete_object(&self, key: &str) -> Result<(), StorageError> {
S3CompatBackend::delete_object(self, key).await
}
async fn head_object(&self, key: &str) -> Result<bool, StorageError> {
S3CompatBackend::head_object(self, key).await
}
async fn list_objects(&self, prefix: Option<&str>) -> Result<Vec<String>, StorageError> {
S3CompatBackend::list_objects(self, prefix).await
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_canonical_query_string_sorted() {
let pairs = vec![
("b".to_string(), "2".to_string()),
("a".to_string(), "1".to_string()),
("c".to_string(), "3".to_string()),
];
let canonical = canonical_query_string(pairs);
assert_eq!(canonical, "a=1&b=2&c=3");
}
#[test]
fn test_canonical_resource_path_root() {
assert_eq!(canonical_resource_path(""), "/");
assert_eq!(canonical_resource_path("/"), "/");
}
#[test]
fn test_canonical_resource_path_simple() {
let p = canonical_resource_path("/foo/bar");
assert_eq!(p, "/foo/bar");
}
#[test]
fn test_s3_compat_backend_new() {
let backend = S3CompatBackend::new(
"bucket",
"us-east-1",
"https://s3.example.com",
"ak",
"sk",
true,
);
assert!(backend.is_ok());
let b = backend.unwrap();
assert_eq!(b.bucket, "bucket");
assert_eq!(b.region, "us-east-1");
assert_eq!(b.endpoint, "https://s3.example.com");
assert!(b.path_style);
}
#[test]
fn test_s3_compat_backend_endpoint_trailing_slash_stripped() {
let b = S3CompatBackend::new(
"bucket",
"us-east-1",
"https://s3.example.com/",
"ak",
"sk",
true,
)
.unwrap();
assert_eq!(b.endpoint, "https://s3.example.com");
}
#[test]
fn test_s3_compat_backend_endpoint_adds_scheme() {
let b = S3CompatBackend::new(
"bucket",
"us-east-1",
"oss-cn-hangzhou.aliyuncs.com",
"ak",
"sk",
true,
)
.unwrap();
assert_eq!(b.endpoint, "https://oss-cn-hangzhou.aliyuncs.com");
}
#[test]
fn test_object_url_path_style() {
let b = S3CompatBackend::new(
"my-bucket",
"us-east-1",
"https://s3.example.com",
"ak",
"sk",
true,
)
.unwrap();
let url = b.object_url("path/to/file.txt");
assert_eq!(url, "https://s3.example.com/my-bucket/path/to/file.txt");
}
#[tokio::test]
#[ignore = "requires a real S3-compatible endpoint (e.g. MinIO)"]
async fn test_s3_compat_real_put_get_delete() {
let endpoint = std::env::var("S3_COMPAT_TEST_ENDPOINT")
.unwrap_or_else(|_| "http://localhost:9000".to_string());
let bucket = std::env::var("S3_COMPAT_TEST_BUCKET")
.unwrap_or_else(|_| "test-bucket".to_string());
let region = std::env::var("S3_COMPAT_TEST_REGION")
.unwrap_or_else(|_| "us-east-1".to_string());
let access_key = std::env::var("S3_COMPAT_TEST_AK")
.unwrap_or_else(|_| "minioadmin".to_string());
let secret_key = std::env::var("S3_COMPAT_TEST_SK")
.unwrap_or_else(|_| "minioadmin".to_string());
let backend = S3CompatBackend::new(
&bucket,
®ion,
&endpoint,
&access_key,
&secret_key,
true,
)
.unwrap();
let key = format!("s3_compat_test_{}.txt", uuid_simple());
let data = b"hello s3-compat";
backend.put_object(&key, data, "text/plain").await.unwrap();
let fetched = backend.get_object(&key).await.unwrap();
assert_eq!(fetched, data);
assert!(backend.head_object(&key).await.unwrap());
let listed = backend.list_objects(None).await.unwrap();
assert!(listed.contains(&key));
backend.delete_object(&key).await.unwrap();
assert!(!backend.head_object(&key).await.unwrap());
}
fn uuid_simple() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
format!("{now:x}")
}
}