use std::fmt;
use std::fmt::Write as _;
use std::path::Path;
use std::time::Duration;
use hmac::{Hmac, Mac};
use sha2::{Digest, Sha256};
type HmacSha256 = Hmac<Sha256>;
const ALGORITHM: &str = "AWS4-HMAC-SHA256";
const SERVICE: &str = "s3";
const AWS4_REQUEST: &str = "aws4_request";
const EMPTY_PAYLOAD_SHA256: &str =
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
const MAX_LIST_PAGES: usize = 1000;
const MULTIPART_THRESHOLD: u64 = 64 * 1024 * 1024; const MULTIPART_PART_SIZE: u64 = 64 * 1024 * 1024; const S3_MIN_PART_SIZE: u64 = 5 * 1024 * 1024; const S3_MAX_PARTS: u64 = 10_000;
const TRANSFER_CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
#[cfg(target_os = "linux")]
const TRANSFER_STALL_TIMEOUT: Duration = Duration::from_secs(300);
pub struct S3Credentials {
pub access_key_id: String,
pub secret_access_key: String,
}
impl fmt::Debug for S3Credentials {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("S3Credentials")
.field("access_key_id", &"<redacted>")
.field("secret_access_key", &"<redacted>")
.finish()
}
}
#[derive(Debug, Clone)]
pub struct S3Config {
pub bucket: String,
pub region: String,
pub endpoint: Option<String>,
pub force_path_style: bool,
}
#[derive(Debug)]
pub enum S3Error {
Transport { op: &'static str, detail: String },
Status {
op: &'static str,
status: u16,
code: Option<String>,
},
BadResponse { op: &'static str, detail: String },
VerifyFailed { detail: String },
}
impl fmt::Display for S3Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Transport { op, detail } => {
write!(f, "S3 {op} request failed: {detail}")
}
Self::Status { op, status, code } => match code {
Some(code) => write!(f, "S3 {op} returned HTTP {status} ({code})"),
None => write!(f, "S3 {op} returned HTTP {status}"),
},
Self::BadResponse { op, detail } => {
write!(f, "S3 {op} returned an unexpected response: {detail}")
}
Self::VerifyFailed { detail } => {
write!(f, "remote object does not match local artifact: {detail}")
}
}
}
}
impl std::error::Error for S3Error {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct S3Object {
pub key: String,
pub size: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemoteObjectInfo {
pub content_length: u64,
pub checksum_sha256: Option<String>,
}
fn sha256_hex(data: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(data);
hex::encode(hasher.finalize())
}
#[cfg(test)]
fn sha256_raw(data: &[u8]) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update(data);
hasher.finalize().into()
}
fn hmac_sha256(key: &[u8], data: &[u8]) -> [u8; 32] {
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length");
mac.update(data);
mac.finalize().into_bytes().into()
}
fn aws_uri_encode(s: &str, encode_slash: bool) -> String {
let mut out = String::with_capacity(s.len());
for &b in s.as_bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
out.push(b as char);
}
b'/' if !encode_slash => out.push('/'),
_ => {
let _ = write!(out, "%{b:02X}");
}
}
}
out
}
type Header = (String, String);
fn head_extra_headers() -> Vec<Header> {
vec![("x-amz-checksum-mode".to_owned(), "ENABLED".to_owned())]
}
fn signed_headers_list(headers: &[Header]) -> String {
headers
.iter()
.map(|(name, _)| name.as_str())
.collect::<Vec<_>>()
.join(";")
}
fn canonical_request(
method: &str,
canonical_uri: &str,
canonical_query: &str,
headers: &[Header],
payload_hash: &str,
) -> String {
let canonical_headers: String = headers
.iter()
.fold(String::new(), |mut acc, (name, value)| {
let _ = writeln!(acc, "{name}:{}", value.trim());
acc
});
let signed = signed_headers_list(headers);
format!(
"{method}\n{canonical_uri}\n{canonical_query}\n{canonical_headers}\n{signed}\n{payload_hash}"
)
}
fn string_to_sign(amz_date: &str, scope: &str, canonical_request_hash: &str) -> String {
format!("{ALGORITHM}\n{amz_date}\n{scope}\n{canonical_request_hash}")
}
fn signing_key(secret: &str, date: &str, region: &str) -> [u8; 32] {
let k_date = hmac_sha256(format!("AWS4{secret}").as_bytes(), date.as_bytes());
let k_region = hmac_sha256(&k_date, region.as_bytes());
let k_service = hmac_sha256(&k_region, SERVICE.as_bytes());
hmac_sha256(&k_service, AWS4_REQUEST.as_bytes())
}
fn compute_signature(
secret: &str,
date: &str,
region: &str,
amz_date: &str,
scope: &str,
canonical_req: &str,
) -> String {
let key = signing_key(secret, date, region);
let sts = string_to_sign(amz_date, scope, &sha256_hex(canonical_req.as_bytes()));
hex::encode(hmac_sha256(&key, sts.as_bytes()))
}
fn verify_head(
expected_len: u64,
expected_b64: &str,
info: &RemoteObjectInfo,
) -> Result<bool, S3Error> {
if info.content_length != expected_len {
return Err(S3Error::VerifyFailed {
detail: format!(
"remote length {} != local length {expected_len}",
info.content_length
),
});
}
match &info.checksum_sha256 {
Some(remote) if is_composite_checksum(remote) => Ok(false),
Some(remote) if remote == expected_b64 => Ok(true),
Some(remote) => Err(S3Error::VerifyFailed {
detail: format!("remote sha256 checksum {remote} != local {expected_b64}"),
}),
None => Ok(false),
}
}
fn is_composite_checksum(value: &str) -> bool {
value.rsplit_once('-').is_some_and(|(head, n)| {
!head.is_empty() && !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())
})
}
fn xml_first(xml: &str, tag: &str) -> Option<String> {
let open = format!("<{tag}>");
let close = format!("</{tag}>");
let start = xml.find(&open)? + open.len();
let end = xml[start..].find(&close)? + start;
Some(xml[start..end].to_owned())
}
fn parse_list_objects(xml: &str) -> Result<(Vec<S3Object>, Option<String>), S3Error> {
let mut objects = Vec::new();
let mut rest = xml;
while let Some(open) = rest.find("<Contents>") {
let after = &rest[open + "<Contents>".len()..];
let Some(close) = after.find("</Contents>") else {
break;
};
let block = &after[..close];
if let (Some(key), Some(size_str)) = (xml_first(block, "Key"), xml_first(block, "Size")) {
let size = size_str
.trim()
.parse::<u64>()
.map_err(|_| S3Error::BadResponse {
op: "list",
detail: format!("non-numeric <Size> {size_str:?}"),
})?;
objects.push(S3Object {
key: xml_unescape(&key),
size,
});
}
rest = &after[close + "</Contents>".len()..];
}
let truncated =
xml_first(xml, "IsTruncated").is_some_and(|v| v.trim().eq_ignore_ascii_case("true"));
let token = if truncated {
xml_first(xml, "NextContinuationToken").map(|t| xml_unescape(t.trim()))
} else {
None
};
Ok((objects, token))
}
fn xml_unescape(s: &str) -> String {
s.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace(""", "\"")
.replace("'", "'")
.replace("'", "'")
}
fn parse_error_code(xml: &str) -> Option<String> {
xml_first(xml, "Code")
}
fn canonical_query(params: &[(&str, &str)]) -> String {
let mut encoded: Vec<(String, String)> = params
.iter()
.map(|(k, v)| (aws_uri_encode(k, true), aws_uri_encode(v, true)))
.collect();
encoded.sort_by(|a, b| a.0.cmp(&b.0));
encoded
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>()
.join("&")
}
const fn multipart_part_count(len: u64, part_size: u64) -> u64 {
if part_size == 0 {
return 0;
}
len.div_ceil(part_size)
}
fn effective_part_size(len: u64, configured: u64) -> u64 {
let min_for_parts = len.div_ceil(S3_MAX_PARTS).max(1);
configured.max(min_for_parts).max(S3_MIN_PART_SIZE)
}
fn build_complete_multipart_xml(parts: &[(u32, String)]) -> String {
let mut xml = String::from("<CompleteMultipartUpload>");
for (number, etag) in parts {
let _ = write!(
xml,
"<Part><PartNumber>{number}</PartNumber><ETag>{}</ETag></Part>",
xml_escape(etag)
);
}
xml.push_str("</CompleteMultipartUpload>");
xml
}
fn classify_complete_response(body: &str) -> Result<(), String> {
if body.contains("<CompleteMultipartUploadResult") && !body.contains("<Error") {
return Ok(());
}
Err(parse_error_code(body).unwrap_or_else(|| "UnrecognizedCompleteResponse".to_owned()))
}
fn xml_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
}
fn read_up_to(reader: &mut impl std::io::Read, buf: &mut [u8]) -> std::io::Result<usize> {
let mut filled = 0;
while filled < buf.len() {
let n = reader.read(&mut buf[filled..])?;
if n == 0 {
break;
}
filled += n;
}
Ok(filled)
}
pub struct S3Client {
config: S3Config,
credentials: S3Credentials,
http: reqwest::blocking::Client,
multipart_threshold: u64,
multipart_part_size: u64,
now: fn() -> chrono::DateTime<chrono::Utc>,
}
enum RequestBody<'a> {
Empty,
File { path: &'a Path, sha256_hex: &'a str },
Owned(Vec<u8>),
}
fn validate_endpoint(endpoint: &str) -> Result<(), S3Error> {
let url = url::Url::parse(endpoint).map_err(|e| S3Error::Transport {
op: "init",
detail: format!("invalid endpoint {endpoint:?}: {e}"),
})?;
let path = url.path();
if !path.is_empty() && path != "/" {
return Err(S3Error::Transport {
op: "init",
detail: format!(
"endpoint {endpoint:?} must be scheme + host[:port] only (no path); \
a path prefix like {path:?} is not supported"
),
});
}
Ok(())
}
impl S3Client {
pub fn new(config: S3Config, credentials: S3Credentials) -> Result<Self, S3Error> {
if let Some(endpoint) = &config.endpoint {
validate_endpoint(endpoint)?;
}
let builder = reqwest::blocking::Client::builder()
.timeout(None)
.connect_timeout(TRANSFER_CONNECT_TIMEOUT);
#[cfg(target_os = "linux")]
let builder = builder.tcp_user_timeout(TRANSFER_STALL_TIMEOUT);
let http = builder.build().map_err(|e| S3Error::Transport {
op: "init",
detail: e.to_string(),
})?;
Ok(Self {
config,
credentials,
http,
multipart_threshold: MULTIPART_THRESHOLD,
multipart_part_size: MULTIPART_PART_SIZE,
now: chrono::Utc::now,
})
}
#[must_use]
pub const fn with_part_size(mut self, part_size: u64) -> Self {
self.multipart_part_size = part_size;
self
}
fn host(&self) -> Result<String, S3Error> {
let base = self.endpoint_base();
let url = url::Url::parse(&base).map_err(|e| S3Error::Transport {
op: "init",
detail: format!("invalid endpoint: {e}"),
})?;
let host = url.host_str().ok_or_else(|| S3Error::Transport {
op: "init",
detail: "endpoint has no host".to_owned(),
})?;
Ok(url
.port()
.map_or_else(|| host.to_owned(), |port| format!("{host}:{port}")))
}
#[allow(clippy::option_if_let_else)] fn endpoint_base(&self) -> String {
match &self.config.endpoint {
Some(ep) => {
let ep = ep.trim_end_matches('/');
if self.config.force_path_style {
ep.to_owned()
} else {
if let Some(rest) = ep.strip_prefix("https://") {
format!("https://{}.{rest}", self.config.bucket)
} else if let Some(rest) = ep.strip_prefix("http://") {
format!("http://{}.{rest}", self.config.bucket)
} else {
ep.to_owned()
}
}
}
None if self.config.force_path_style => {
format!("https://s3.{}.amazonaws.com", self.config.region)
}
None => format!(
"https://{}.s3.{}.amazonaws.com",
self.config.bucket, self.config.region
),
}
}
fn canonical_path(&self, key: &str) -> String {
let key = key.trim_start_matches('/');
if self.config.force_path_style {
format!("/{}/{}", self.config.bucket, aws_uri_encode(key, false))
} else {
format!("/{}", aws_uri_encode(key, false))
}
}
fn request_url(&self, key: &str, canonical_query: &str) -> String {
let base = self.endpoint_base();
let path = self.canonical_path(key);
if canonical_query.is_empty() {
format!("{base}{path}")
} else {
format!("{base}{path}?{canonical_query}")
}
}
fn send(
&self,
op: &'static str,
method: reqwest::Method,
key: &str,
canonical_query: &str,
extra_headers: &[Header],
body: RequestBody<'_>,
) -> Result<reqwest::blocking::Response, S3Error> {
let now = (self.now)();
let amz_date = now.format("%Y%m%dT%H%M%SZ").to_string();
let date = now.format("%Y%m%d").to_string();
let payload_hash = match &body {
RequestBody::Empty => EMPTY_PAYLOAD_SHA256.to_owned(),
RequestBody::File { sha256_hex, .. } => (*sha256_hex).to_owned(),
RequestBody::Owned(bytes) => sha256_hex(bytes),
};
let host = self.host()?;
let mut headers: Vec<Header> = vec![
("host".to_owned(), host),
("x-amz-content-sha256".to_owned(), payload_hash.clone()),
("x-amz-date".to_owned(), amz_date.clone()),
];
for (name, value) in extra_headers {
headers.push((name.to_ascii_lowercase(), value.clone()));
}
headers.sort_by(|a, b| a.0.cmp(&b.0));
let canonical = canonical_request(
method.as_str(),
&self.canonical_path(key),
canonical_query,
&headers,
&payload_hash,
);
let scope = format!("{date}/{}/{SERVICE}/{AWS4_REQUEST}", self.config.region);
let signature = compute_signature(
&self.credentials.secret_access_key,
&date,
&self.config.region,
&amz_date,
&scope,
&canonical,
);
let authorization = format!(
"{ALGORITHM} Credential={}/{scope}, SignedHeaders={}, Signature={signature}",
self.credentials.access_key_id,
signed_headers_list(&headers),
);
let url = self.request_url(key, canonical_query);
let mut req = self
.http
.request(method, &url)
.header("x-amz-date", &amz_date)
.header("x-amz-content-sha256", &payload_hash)
.header(reqwest::header::AUTHORIZATION, authorization);
for (name, value) in extra_headers {
req = req.header(name.as_str(), value.as_str());
}
match body {
RequestBody::Empty => {}
RequestBody::File { path, .. } => {
let file = std::fs::File::open(path).map_err(|e| S3Error::Transport {
op,
detail: format!("opening {} for upload: {e}", path.display()),
})?;
req = req.body(file);
}
RequestBody::Owned(bytes) => {
req = req.body(bytes);
}
}
let resp = req.send().map_err(|e| S3Error::Transport {
op,
detail: e.to_string(),
})?;
let status = resp.status();
if status.is_success() {
Ok(resp)
} else {
let code = resp.text().ok().as_deref().and_then(parse_error_code);
Err(S3Error::Status {
op,
status: status.as_u16(),
code,
})
}
}
fn put_file(
&self,
key: &str,
path: &Path,
checksum_b64: &str,
content_sha256_hex: &str,
) -> Result<(), S3Error> {
let extra = vec![("x-amz-checksum-sha256".to_owned(), checksum_b64.to_owned())];
self.send(
"put",
reqwest::Method::PUT,
key,
"",
&extra,
RequestBody::File {
path,
sha256_hex: content_sha256_hex,
},
)?;
Ok(())
}
pub fn head_object(&self, key: &str) -> Result<RemoteObjectInfo, S3Error> {
let resp = self.send(
"head",
reqwest::Method::HEAD,
key,
"",
&head_extra_headers(),
RequestBody::Empty,
)?;
let content_length = resp
.headers()
.get(reqwest::header::CONTENT_LENGTH)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok())
.ok_or_else(|| S3Error::BadResponse {
op: "head",
detail: "missing or invalid Content-Length".to_owned(),
})?;
let checksum_sha256 = resp
.headers()
.get("x-amz-checksum-sha256")
.and_then(|v| v.to_str().ok())
.map(str::to_owned);
Ok(RemoteObjectInfo {
content_length,
checksum_sha256,
})
}
pub fn download_object(
&self,
key: &str,
mut writer: impl std::io::Write,
) -> Result<(), S3Error> {
let mut resp = self.send(
"get",
reqwest::Method::GET,
key,
"",
&[],
RequestBody::Empty,
)?;
std::io::copy(&mut resp, &mut writer).map_err(|e| S3Error::Transport {
op: "get",
detail: e.to_string(),
})?;
Ok(())
}
pub fn delete_object(&self, key: &str) -> Result<(), S3Error> {
self.send(
"delete",
reqwest::Method::DELETE,
key,
"",
&[],
RequestBody::Empty,
)?;
Ok(())
}
pub fn list_objects_v2(&self, prefix: &str) -> Result<Vec<S3Object>, S3Error> {
let mut out = Vec::new();
let mut continuation: Option<String> = None;
for _page in 0..MAX_LIST_PAGES {
let mut params: Vec<(String, String)> = vec![
("list-type".to_owned(), "2".to_owned()),
("prefix".to_owned(), prefix.to_owned()),
];
if let Some(token) = &continuation {
params.push(("continuation-token".to_owned(), token.clone()));
}
params.sort_by(|a, b| a.0.cmp(&b.0));
let canonical_query = params
.iter()
.map(|(k, v)| format!("{}={}", aws_uri_encode(k, true), aws_uri_encode(v, true)))
.collect::<Vec<_>>()
.join("&");
let resp = self.send(
"list",
reqwest::Method::GET,
"",
&canonical_query,
&[],
RequestBody::Empty,
)?;
let body = resp.text().map_err(|e| S3Error::Transport {
op: "list",
detail: e.to_string(),
})?;
let (objects, next) = parse_list_objects(&body)?;
out.extend(objects);
match next {
Some(token) => continuation = Some(token),
None => return Ok(out),
}
}
Err(S3Error::BadResponse {
op: "list",
detail: format!(
"exceeded {MAX_LIST_PAGES} list pages (endpoint returned an unending \
continuation token?)"
),
})
}
fn verify_or_delete(&self, key: &str, len: u64, checksum_b64: &str) -> Result<(), S3Error> {
if let Err(e) = self.verify_uploaded(key, len, checksum_b64) {
if let Err(del_err) = self.delete_object(key) {
eprintln!(
" \u{26A0} failed to delete {key} after post-upload verification \
failed: {del_err}"
);
}
return Err(e);
}
Ok(())
}
pub fn put_file_and_verify(&self, key: &str, path: &Path) -> Result<(), S3Error> {
let len = std::fs::metadata(path)
.map_err(|e| S3Error::Transport {
op: "put",
detail: format!("stat {} for upload: {e}", path.display()),
})?
.len();
if len > self.multipart_threshold {
return self.put_file_multipart(key, path, len);
}
let (digest, hashed_len) = sha256_file(path).map_err(|e| S3Error::Transport {
op: "put",
detail: format!("hashing {} for upload: {e}", path.display()),
})?;
let checksum_b64 = base64_standard(&digest);
let content_hex = hex::encode(digest);
self.put_file(key, path, &checksum_b64, &content_hex)?;
self.verify_or_delete(key, hashed_len, &checksum_b64)
}
fn put_file_multipart(&self, key: &str, path: &Path, len: u64) -> Result<(), S3Error> {
let part_size = effective_part_size(len, self.multipart_part_size);
let part_count = multipart_part_count(len, part_size);
if part_count > S3_MAX_PARTS {
return Err(S3Error::BadResponse {
op: "create-multipart",
detail: format!(
"artifact needs {part_count} parts at {part_size} bytes/part, \
over S3's {S3_MAX_PARTS}-part limit"
),
});
}
let upload_id = self.create_multipart_upload(key)?;
match self.upload_parts_and_complete(key, path, len, part_size, &upload_id) {
Ok(whole_b64) => {
self.verify_or_delete(key, len, &whole_b64)
}
Err(err) => {
let _ = self.abort_multipart_upload(key, &upload_id);
Err(err)
}
}
}
fn upload_parts_and_complete(
&self,
key: &str,
path: &Path,
len: u64,
part_size: u64,
upload_id: &str,
) -> Result<String, S3Error> {
let mut file = std::fs::File::open(path).map_err(|e| S3Error::Transport {
op: "upload-part",
detail: format!("opening {} for upload: {e}", path.display()),
})?;
let cap = usize::try_from(part_size).unwrap_or(usize::MAX);
let mut whole = Sha256Writer::new();
let mut parts: Vec<(u32, String)> = Vec::new();
let mut part_number: u32 = 1;
let mut uploaded: u64 = 0;
loop {
let mut chunk = vec![0u8; cap];
let n = read_up_to(&mut file, &mut chunk).map_err(|e| S3Error::Transport {
op: "upload-part",
detail: format!("reading {} for upload: {e}", path.display()),
})?;
if n == 0 {
break;
}
chunk.truncate(n);
{
use std::io::Write as _;
whole
.write_all(&chunk)
.expect("Sha256Writer::write is infallible");
}
let etag = self.upload_part(key, upload_id, part_number, chunk)?;
parts.push((part_number, etag));
uploaded += u64::try_from(n).unwrap_or(0);
part_number = part_number
.checked_add(1)
.ok_or_else(|| S3Error::BadResponse {
op: "upload-part",
detail: "part count overflow".to_owned(),
})?;
}
if uploaded != len {
return Err(S3Error::Transport {
op: "upload-part",
detail: format!("read {uploaded} bytes but file length is {len}"),
});
}
self.complete_multipart_upload(key, upload_id, &parts)?;
Ok(base64_standard(&whole.finalize()))
}
fn create_multipart_upload(&self, key: &str) -> Result<String, S3Error> {
let query = canonical_query(&[("uploads", "")]);
let resp = self.send(
"create-multipart",
reqwest::Method::POST,
key,
&query,
&[],
RequestBody::Empty,
)?;
let body = resp.text().map_err(|e| S3Error::Transport {
op: "create-multipart",
detail: e.to_string(),
})?;
xml_first(&body, "UploadId").ok_or_else(|| S3Error::BadResponse {
op: "create-multipart",
detail: "CreateMultipartUpload response had no <UploadId>".to_owned(),
})
}
fn upload_part(
&self,
key: &str,
upload_id: &str,
part_number: u32,
chunk: Vec<u8>,
) -> Result<String, S3Error> {
let pn = part_number.to_string();
let query = canonical_query(&[("partNumber", &pn), ("uploadId", upload_id)]);
let resp = self.send(
"upload-part",
reqwest::Method::PUT,
key,
&query,
&[],
RequestBody::Owned(chunk),
)?;
resp.headers()
.get(reqwest::header::ETAG)
.and_then(|v| v.to_str().ok())
.map(str::to_owned)
.ok_or_else(|| S3Error::BadResponse {
op: "upload-part",
detail: format!("UploadPart {part_number} response had no ETag"),
})
}
fn complete_multipart_upload(
&self,
key: &str,
upload_id: &str,
parts: &[(u32, String)],
) -> Result<(), S3Error> {
let query = canonical_query(&[("uploadId", upload_id)]);
let xml = build_complete_multipart_xml(parts);
let resp = self.send(
"complete-multipart",
reqwest::Method::POST,
key,
&query,
&[],
RequestBody::Owned(xml.into_bytes()),
)?;
let body = resp.text().map_err(|e| S3Error::Transport {
op: "complete-multipart",
detail: e.to_string(),
})?;
classify_complete_response(&body).map_err(|code| S3Error::Status {
op: "complete-multipart",
status: 200,
code: Some(code),
})
}
fn abort_multipart_upload(&self, key: &str, upload_id: &str) -> Result<(), S3Error> {
let query = canonical_query(&[("uploadId", upload_id)]);
self.send(
"abort-multipart",
reqwest::Method::DELETE,
key,
&query,
&[],
RequestBody::Empty,
)?;
Ok(())
}
pub fn verify_uploaded(
&self,
key: &str,
expected_len: u64,
expected_b64: &str,
) -> Result<(), S3Error> {
let info = self.head_object(key)?;
if verify_head(expected_len, expected_b64, &info)? {
return Ok(());
}
let mut hasher = Sha256Writer::new();
self.download_object(key, &mut hasher)?;
let actual_b64 = base64_standard(&hasher.finalize());
if actual_b64 == expected_b64 {
Ok(())
} else {
Err(S3Error::VerifyFailed {
detail: "GET-and-rehash of the uploaded object did not match the local sha256"
.to_owned(),
})
}
}
}
struct Sha256Writer {
hasher: Sha256,
}
impl Sha256Writer {
fn new() -> Self {
Self {
hasher: Sha256::new(),
}
}
fn finalize(self) -> [u8; 32] {
self.hasher.finalize().into()
}
}
impl std::io::Write for Sha256Writer {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.hasher.update(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
fn sha256_file(path: &Path) -> std::io::Result<([u8; 32], u64)> {
use std::io::Read as _;
let mut file = std::fs::File::open(path)?;
let mut hasher = Sha256::new();
let mut buf = vec![0u8; 64 * 1024];
let mut total: u64 = 0;
loop {
let n = file.read(&mut buf)?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
total += u64::try_from(n).unwrap_or(u64::MAX);
}
Ok((hasher.finalize().into(), total))
}
fn base64_standard(data: &[u8]) -> String {
use base64::Engine as _;
base64::engine::general_purpose::STANDARD.encode(data)
}
#[cfg(test)]
fn sha256_base64(data: &[u8]) -> String {
base64_standard(&sha256_raw(data))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sigv4_matches_aws_get_object_example() {
let secret = "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY";
let access = "AKIDEXAMPLE";
let region = "us-east-1";
let date = "20130524";
let amz_date = "20130524T000000Z";
let headers: Vec<Header> = vec![
(
"host".to_owned(),
"examplebucket.s3.amazonaws.com".to_owned(),
),
("range".to_owned(), "bytes=0-9".to_owned()),
(
"x-amz-content-sha256".to_owned(),
EMPTY_PAYLOAD_SHA256.to_owned(),
),
("x-amz-date".to_owned(), amz_date.to_owned()),
];
let canonical = canonical_request("GET", "/test.txt", "", &headers, EMPTY_PAYLOAD_SHA256);
assert_eq!(
sha256_hex(canonical.as_bytes()),
"7344ae5b7ee6c3e7e6b0fe0640412a37625d1fbfff95c48bbb2dc43964946972"
);
let scope = format!("{date}/{region}/{SERVICE}/{AWS4_REQUEST}");
assert_eq!(scope, "20130524/us-east-1/s3/aws4_request");
let signature = compute_signature(secret, date, region, amz_date, &scope, &canonical);
assert_eq!(
signature,
"67fe34c8530db585abddc51067328adfedb6e42487d2566dc7d927d6e2722900"
);
assert_eq!(
signed_headers_list(&headers),
"host;range;x-amz-content-sha256;x-amz-date"
);
assert!(format!("Credential={access}/{scope}").contains("AKIDEXAMPLE/20130524"));
}
#[test]
fn string_to_sign_shape() {
let sts = string_to_sign(
"20130524T000000Z",
"20130524/us-east-1/s3/aws4_request",
"abc",
);
assert_eq!(
sts,
"AWS4-HMAC-SHA256\n20130524T000000Z\n20130524/us-east-1/s3/aws4_request\nabc"
);
}
#[test]
fn aws_uri_encode_preserves_key_slashes_but_encodes_specials() {
assert_eq!(
aws_uri_encode("db/prod/20260710T040506Z/control.dump", false),
"db/prod/20260710T040506Z/control.dump"
);
assert_eq!(aws_uri_encode("a b+c/d", false), "a%20b%2Bc/d");
assert_eq!(aws_uri_encode("a/b", true), "a%2Fb");
}
#[test]
fn verify_head_matches_on_checksum() {
let expected = sha256_base64(b"hello");
let info = RemoteObjectInfo {
content_length: 5,
checksum_sha256: Some(expected.clone()),
};
assert!(verify_head(5, &expected, &info).unwrap());
}
#[test]
fn verify_head_length_mismatch_errors() {
let expected = sha256_base64(b"hello");
let info = RemoteObjectInfo {
content_length: 4,
checksum_sha256: Some(expected.clone()),
};
assert!(matches!(
verify_head(5, &expected, &info),
Err(S3Error::VerifyFailed { .. })
));
}
#[test]
fn verify_head_checksum_mismatch_errors() {
let expected = sha256_base64(b"hello");
let wrong = sha256_base64(b"world");
let info = RemoteObjectInfo {
content_length: 5,
checksum_sha256: Some(wrong),
};
assert!(matches!(
verify_head(5, &expected, &info),
Err(S3Error::VerifyFailed { .. })
));
}
#[test]
fn verify_head_absent_checksum_requests_rehash() {
let expected = sha256_base64(b"hello");
let info = RemoteObjectInfo {
content_length: 5,
checksum_sha256: None,
};
assert!(!verify_head(5, &expected, &info).unwrap());
}
#[test]
fn parse_list_objects_extracts_keys_and_sizes() {
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<ListBucketResult>
<Name>bucket</Name>
<Contents>
<Key>db/prod/20260710T040506Z/manifest.json</Key>
<Size>128</Size>
</Contents>
<Contents>
<Key>db/prod/20260710T040506Z/control.dump</Key>
<Size>4096</Size>
</Contents>
<IsTruncated>false</IsTruncated>
</ListBucketResult>"#;
let (objects, next) = parse_list_objects(xml).unwrap();
assert_eq!(objects.len(), 2);
assert_eq!(objects[0].key, "db/prod/20260710T040506Z/manifest.json");
assert_eq!(objects[0].size, 128);
assert_eq!(objects[1].size, 4096);
assert!(next.is_none());
}
#[test]
fn parse_list_objects_follows_truncation_token() {
let xml = r"<ListBucketResult>
<Contents><Key>a</Key><Size>1</Size></Contents>
<IsTruncated>true</IsTruncated>
<NextContinuationToken>tok123</NextContinuationToken>
</ListBucketResult>";
let (objects, next) = parse_list_objects(xml).unwrap();
assert_eq!(objects.len(), 1);
assert_eq!(next.as_deref(), Some("tok123"));
}
#[test]
fn parse_error_code_reads_s3_code() {
let xml = "<Error><Code>NoSuchKey</Code><Message>nope</Message></Error>";
assert_eq!(parse_error_code(xml).as_deref(), Some("NoSuchKey"));
}
#[test]
fn credentials_debug_is_redacted() {
let creds = S3Credentials {
access_key_id: "AKIA_PUBLIC".to_owned(),
secret_access_key: "supersecret".to_owned(),
};
let rendered = format!("{creds:?}");
assert!(!rendered.contains("supersecret"));
assert!(!rendered.contains("AKIA_PUBLIC"));
assert!(rendered.contains("redacted"));
}
#[test]
fn sha256_base64_is_standard_padded() {
assert_eq!(
sha256_base64(b""),
"47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU="
);
}
#[test]
fn head_request_signs_checksum_mode_header() {
let extra = head_extra_headers();
assert_eq!(
extra,
vec![("x-amz-checksum-mode".to_owned(), "ENABLED".to_owned())]
);
let mut headers: Vec<Header> = vec![
("host".to_owned(), "s3.example.test".to_owned()),
(
"x-amz-content-sha256".to_owned(),
EMPTY_PAYLOAD_SHA256.to_owned(),
),
("x-amz-date".to_owned(), "20260710T000000Z".to_owned()),
];
headers.extend(extra);
headers.sort_by(|a, b| a.0.cmp(&b.0));
assert_eq!(
signed_headers_list(&headers),
"host;x-amz-checksum-mode;x-amz-content-sha256;x-amz-date"
);
}
#[test]
fn validate_endpoint_accepts_scheme_authority_only() {
assert!(validate_endpoint("https://minio.example:9000").is_ok());
assert!(validate_endpoint("https://s3.example.test/").is_ok());
assert!(validate_endpoint("http://127.0.0.1:9000").is_ok());
}
#[test]
fn validate_endpoint_rejects_a_path_prefix() {
let err = validate_endpoint("https://gw.example/s3").unwrap_err();
assert!(matches!(err, S3Error::Transport { .. }));
assert!(err.to_string().contains("no path"));
}
#[test]
fn new_rejects_endpoint_with_path() {
let config = S3Config {
bucket: "b".to_owned(),
region: "us-east-1".to_owned(),
endpoint: Some("https://gw.example/s3".to_owned()),
force_path_style: true,
};
let creds = S3Credentials {
access_key_id: "k".to_owned(),
secret_access_key: "s".to_owned(),
};
assert!(S3Client::new(config, creds).is_err());
}
#[test]
fn client_builds_with_uncapped_transfer_timeouts() {
let config = S3Config {
bucket: "b".to_owned(),
region: "us-east-1".to_owned(),
endpoint: Some("https://minio.example:9000".to_owned()),
force_path_style: true,
};
let creds = S3Credentials {
access_key_id: "k".to_owned(),
secret_access_key: "s".to_owned(),
};
assert!(S3Client::new(config, creds).is_ok());
#[cfg(target_os = "linux")]
assert!(TRANSFER_CONNECT_TIMEOUT < TRANSFER_STALL_TIMEOUT);
}
#[test]
fn sha256_writer_matches_one_shot_hash() {
use std::io::Write as _;
let mut w = Sha256Writer::new();
w.write_all(b"hello ").unwrap();
w.write_all(b"streamed ").unwrap();
w.write_all(b"world").unwrap();
assert_eq!(w.finalize(), sha256_raw(b"hello streamed world"));
}
#[test]
fn sha256_file_streams_length_and_digest() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("blob.bin");
let data: Vec<u8> = (0..100_000u32)
.map(|i| u8::try_from(i % 256).unwrap())
.collect();
std::fs::write(&path, &data).unwrap();
let (digest, len) = sha256_file(&path).unwrap();
assert_eq!(len, u64::try_from(data.len()).unwrap());
assert_eq!(digest, sha256_raw(&data));
}
#[test]
fn download_object_streams_response_body_into_writer() {
use std::io::{Read as _, Write as _};
let body = b"streamed-object-body-contents".to_vec();
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let body_for_server = body.clone();
let server = std::thread::spawn(move || {
let (mut sock, _) = listener.accept().unwrap();
let mut acc = Vec::new();
let mut buf = [0u8; 512];
loop {
let n = sock.read(&mut buf).unwrap();
if n == 0 {
break;
}
acc.extend_from_slice(&buf[..n]);
if acc.windows(4).any(|w| w == b"\r\n\r\n") {
break;
}
}
let head = format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n",
body_for_server.len()
);
sock.write_all(head.as_bytes()).unwrap();
sock.write_all(&body_for_server).unwrap();
sock.flush().unwrap();
});
let client = S3Client::new(
S3Config {
bucket: "b".to_owned(),
region: "us-east-1".to_owned(),
endpoint: Some(format!("http://127.0.0.1:{port}")),
force_path_style: true,
},
S3Credentials {
access_key_id: "k".to_owned(),
secret_access_key: "s".to_owned(),
},
)
.unwrap();
let mut sink: Vec<u8> = Vec::new();
client
.download_object("prefix/obj.dump", &mut sink)
.unwrap();
server.join().unwrap();
assert_eq!(sink, body);
}
#[test]
#[allow(clippy::too_many_lines)]
fn put_file_and_verify_deletes_object_on_verify_failure() {
use std::io::{Read as _, Write as _};
use std::time::Duration;
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("artifact.dump");
let payload = b"verify-failure-cleanup-body";
std::fs::write(&path, payload).unwrap();
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let payload_len = payload.len();
let server = std::thread::spawn(move || {
let mut methods: Vec<String> = Vec::new();
'accept: for _conn in 0..8 {
let Ok((mut sock, _)) = listener.accept() else {
break;
};
sock.set_read_timeout(Some(Duration::from_secs(5))).ok();
loop {
let mut acc: Vec<u8> = Vec::new();
let mut buf = [0u8; 1024];
let header_end = loop {
if let Some(p) = acc.windows(4).position(|w| w == b"\r\n\r\n") {
break Some(p + 4);
}
match sock.read(&mut buf) {
Ok(0) | Err(_) => break None,
Ok(n) => acc.extend_from_slice(&buf[..n]),
}
};
let Some(header_end) = header_end else {
break; };
let head = String::from_utf8_lossy(&acc[..header_end]).into_owned();
let method = head
.lines()
.next()
.and_then(|l| l.split_whitespace().next())
.unwrap_or_default()
.to_owned();
let content_length: usize = head
.lines()
.find_map(|l| {
let (k, v) = l.split_once(':')?;
k.trim()
.eq_ignore_ascii_case("content-length")
.then(|| v.trim().parse().ok())
.flatten()
})
.unwrap_or(0);
let mut remaining = content_length.saturating_sub(acc.len() - header_end);
while remaining > 0 {
match sock.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => remaining = remaining.saturating_sub(n),
}
}
methods.push(method.clone());
match method.as_str() {
"HEAD" => {
let resp = format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n",
payload_len + 999
);
sock.write_all(resp.as_bytes()).ok();
}
"DELETE" => {
sock.write_all(b"HTTP/1.1 204 No Content\r\n\r\n").ok();
sock.flush().ok();
break 'accept;
}
_ => {
sock.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n")
.ok();
}
}
sock.flush().ok();
}
}
methods
});
let client = S3Client::new(
S3Config {
bucket: "b".to_owned(),
region: "us-east-1".to_owned(),
endpoint: Some(format!("http://127.0.0.1:{port}")),
force_path_style: true,
},
S3Credentials {
access_key_id: "k".to_owned(),
secret_access_key: "s".to_owned(),
},
)
.unwrap();
let err = client
.put_file_and_verify("prefix/artifact.dump", &path)
.expect_err("verify must fail on the mismatched HEAD length");
assert!(
matches!(err, S3Error::VerifyFailed { .. }),
"expected VerifyFailed, got {err:?}"
);
let methods = server.join().unwrap();
assert_eq!(
methods,
vec!["PUT".to_owned(), "HEAD".to_owned(), "DELETE".to_owned()],
"on a verify failure the object must be PUT, verified via HEAD, then DELETEd",
);
}
#[test]
fn multipart_part_count_divides_ceiling() {
assert_eq!(multipart_part_count(0, 5), 0);
assert_eq!(multipart_part_count(5, 5), 1);
assert_eq!(multipart_part_count(6, 5), 2);
let twelve_mib = 12 * 1024 * 1024;
let five_mib = 5 * 1024 * 1024;
assert_eq!(multipart_part_count(twelve_mib, five_mib), 3);
}
#[test]
fn effective_part_size_floors_at_s3_minimum() {
assert_eq!(
effective_part_size(100 * 1024 * 1024, 1024),
S3_MIN_PART_SIZE
);
}
#[test]
fn effective_part_size_keeps_a_valid_configured_size() {
let part = 8 * 1024 * 1024;
assert_eq!(effective_part_size(64 * 1024 * 1024, part), part);
}
#[test]
fn effective_part_size_grows_to_fit_part_cap() {
let configured = S3_MIN_PART_SIZE;
let len = (S3_MAX_PARTS + 1) * S3_MIN_PART_SIZE;
let eff = effective_part_size(len, configured);
assert!(
eff > configured,
"part size should grow past the configured floor"
);
assert!(
multipart_part_count(len, eff) <= S3_MAX_PARTS,
"grown part size must keep the plan within S3's 10 000-part cap"
);
}
#[test]
fn build_complete_multipart_xml_lists_parts_in_order() {
let parts = vec![
(1u32, "\"etag-one\"".to_owned()),
(2u32, "\"etag-two\"".to_owned()),
];
let xml = build_complete_multipart_xml(&parts);
assert_eq!(
xml,
"<CompleteMultipartUpload>\
<Part><PartNumber>1</PartNumber><ETag>\"etag-one\"</ETag></Part>\
<Part><PartNumber>2</PartNumber><ETag>\"etag-two\"</ETag></Part>\
</CompleteMultipartUpload>"
);
}
#[test]
fn build_complete_multipart_xml_escapes_etag() {
let parts = vec![(1u32, "a&b<c".to_owned())];
let xml = build_complete_multipart_xml(&parts);
assert!(xml.contains("<ETag>a&b<c</ETag>"));
}
#[test]
fn canonical_query_encodes_and_sorts() {
assert_eq!(canonical_query(&[("uploads", "")]), "uploads=");
assert_eq!(
canonical_query(&[("partNumber", "2"), ("uploadId", "abc/def")]),
"partNumber=2&uploadId=abc%2Fdef"
);
}
#[test]
fn classify_complete_response_accepts_result_body() {
let ok = r#"<?xml version="1.0"?><CompleteMultipartUploadResult>\
<Location>http://x</Location><ETag>"abc-3"</ETag>\
</CompleteMultipartUploadResult>"#;
assert!(classify_complete_response(ok).is_ok());
}
#[test]
fn classify_complete_response_detects_200_with_error_body() {
let err = "<Error><Code>InternalError</Code><Message>oops</Message></Error>";
assert_eq!(
classify_complete_response(err).unwrap_err(),
"InternalError"
);
}
#[test]
fn classify_complete_response_rejects_unrecognized_body() {
assert_eq!(
classify_complete_response("garbage").unwrap_err(),
"UnrecognizedCompleteResponse"
);
}
#[test]
fn classify_complete_response_rejects_error_even_with_result_substring() {
let tricky = "<Error><Code>SlowDown</Code></Error><CompleteMultipartUploadResult/>";
assert_eq!(classify_complete_response(tricky).unwrap_err(), "SlowDown");
}
#[test]
fn is_composite_checksum_detects_partcount_suffix() {
assert!(is_composite_checksum("AbCdEf0123456789=-3"));
assert!(is_composite_checksum("x-10000"));
assert!(!is_composite_checksum(
"47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU="
));
assert!(!is_composite_checksum("abc-"));
assert!(!is_composite_checksum("-3"));
}
#[test]
fn verify_head_ignores_composite_checksum_and_requests_rehash() {
let expected = sha256_base64(b"hello");
let info = RemoteObjectInfo {
content_length: 5,
checksum_sha256: Some(format!("{expected}-3")),
};
assert!(!verify_head(5, &expected, &info).unwrap());
}
#[test]
fn parse_upload_id_from_create_response() {
let body = r#"<?xml version="1.0" encoding="UTF-8"?>
<InitiateMultipartUploadResult>
<Bucket>b</Bucket>
<Key>db/prod/x.dump</Key>
<UploadId>VXBsb2FkSUQtMTIz</UploadId>
</InitiateMultipartUploadResult>"#;
assert_eq!(
xml_first(body, "UploadId").as_deref(),
Some("VXBsb2FkSUQtMTIz")
);
}
#[test]
fn read_up_to_fills_across_short_reads() {
struct DribbleReader<'a> {
data: &'a [u8],
pos: usize,
}
impl std::io::Read for DribbleReader<'_> {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
if self.pos >= self.data.len() || buf.is_empty() {
return Ok(0);
}
buf[0] = self.data[self.pos];
self.pos += 1;
Ok(1)
}
}
let mut r = DribbleReader {
data: b"abcdef",
pos: 0,
};
let mut buf = [0u8; 4];
assert_eq!(read_up_to(&mut r, &mut buf).unwrap(), 4);
assert_eq!(&buf, b"abcd");
let mut rest = [0u8; 4];
assert_eq!(read_up_to(&mut r, &mut rest).unwrap(), 2);
assert_eq!(&rest[..2], b"ef");
}
#[test]
fn with_part_size_overrides_only_part_size_not_threshold() {
let client = S3Client::new(
S3Config {
bucket: "b".to_owned(),
region: "us-east-1".to_owned(),
endpoint: Some("https://minio.example:9000".to_owned()),
force_path_style: true,
},
S3Credentials {
access_key_id: "k".to_owned(),
secret_access_key: "s".to_owned(),
},
)
.unwrap()
.with_part_size(5 * 1024 * 1024);
assert_eq!(client.multipart_part_size, 5 * 1024 * 1024);
assert_eq!(client.multipart_threshold, MULTIPART_THRESHOLD);
}
fn client_for(bucket: &str, endpoint: Option<&str>, force_path_style: bool) -> S3Client {
S3Client::new(
S3Config {
bucket: bucket.to_owned(),
region: "us-west-2".to_owned(),
endpoint: endpoint.map(str::to_owned),
force_path_style,
},
S3Credentials {
access_key_id: "k".to_owned(),
secret_access_key: "s".to_owned(),
},
)
.unwrap()
}
#[test]
fn aws_virtual_hosted_addressing_is_the_default() {
let c = client_for("mybucket", None, false);
assert_eq!(
c.endpoint_base(),
"https://mybucket.s3.us-west-2.amazonaws.com"
);
assert_eq!(c.host().unwrap(), "mybucket.s3.us-west-2.amazonaws.com");
assert_eq!(c.canonical_path("db/x.dump"), "/db/x.dump");
}
#[test]
fn aws_force_path_style_uses_regional_host_and_bucket_in_path() {
let c = client_for("mybucket", None, true);
assert_eq!(c.endpoint_base(), "https://s3.us-west-2.amazonaws.com");
assert_eq!(c.host().unwrap(), "s3.us-west-2.amazonaws.com");
assert_eq!(c.canonical_path("db/x.dump"), "/mybucket/db/x.dump");
assert_eq!(
c.request_url("db/x.dump", ""),
"https://s3.us-west-2.amazonaws.com/mybucket/db/x.dump"
);
}
#[test]
fn aws_dotted_bucket_with_force_path_style_is_path_style() {
let c = client_for("my.dotted.bucket", None, true);
assert_eq!(c.host().unwrap(), "s3.us-west-2.amazonaws.com");
assert!(!c.host().unwrap().contains("my.dotted.bucket"));
assert_eq!(
c.canonical_path("control.dump"),
"/my.dotted.bucket/control.dump"
);
}
#[test]
fn custom_endpoint_addressing_is_unchanged_by_the_aws_fix() {
let path = client_for("b", Some("https://minio.example:9000"), true);
assert_eq!(path.host().unwrap(), "minio.example:9000");
assert_eq!(path.canonical_path("k"), "/b/k");
let virt = client_for("b", Some("https://minio.example:9000"), false);
assert_eq!(virt.host().unwrap(), "b.minio.example:9000");
assert_eq!(virt.canonical_path("k"), "/k");
}
}