use std::fs::File;
use std::io::Read;
use std::path::{Path, PathBuf};
use bytes::Bytes;
use futures::StreamExt;
use minio::s3::MinioClient;
use minio::s3::builders::ObjectContent;
use minio::s3::creds::StaticProvider;
use minio::s3::error::{Error as S3Error, S3ServerError};
use minio::s3::http::BaseUrl;
use minio::s3::minio_error_response::MinioErrorCode;
use minio::s3::response_traits::HasEtagFromHeaders;
use minio::s3::segmented_bytes::SegmentedBytes;
use minio::s3::types::{S3Api, ToStream};
use crate::commands::keyring::bucket::store::BucketConfig;
pub(crate) const UPLOAD_PART_SIZE: u64 = 64 * 1024 * 1024;
const HASH_CHUNK_BYTES: usize = 8 * 1024 * 1024;
pub struct ObjectEntry {
pub key: String,
pub size: u64,
pub is_prefix: bool,
}
pub(crate) enum UploadOutcome {
Uploaded,
Unchanged,
}
#[derive(Clone)]
pub(crate) enum UploadBody {
Path(PathBuf),
Bytes(Bytes),
}
fn hash_file_part(file: &mut File, part_size: u64) -> Result<md5::Digest, String> {
let mut context = md5::Context::new();
let mut buf = vec![0u8; HASH_CHUNK_BYTES];
let mut remaining = part_size;
while remaining > 0 {
let to_read = remaining.min(HASH_CHUNK_BYTES as u64) as usize;
file.read_exact(&mut buf[..to_read])
.map_err(|err| format!("failed to read file while hashing: {err}"))?;
context.consume(&buf[..to_read]);
remaining -= to_read as u64;
}
Ok(context.finalize())
}
fn expected_etag_for_file(path: &Path) -> Result<(String, u64), String> {
expected_etag_with_part_size(path, UPLOAD_PART_SIZE)
}
fn needs_hash(existing_etag: &Option<String>, body: &UploadBody) -> bool {
matches!(body, UploadBody::Bytes(_)) || existing_etag.is_some()
}
fn file_size(path: &Path) -> Result<u64, String> {
std::fs::metadata(path)
.map(|meta| meta.len())
.map_err(|err| format!("failed to stat {}: {err}", path.display()))
}
fn expected_etag_with_part_size(path: &Path, part_size: u64) -> Result<(String, u64), String> {
let mut file =
File::open(path).map_err(|err| format!("failed to open {}: {err}", path.display()))?;
let size = file
.metadata()
.map_err(|err| format!("failed to stat {}: {err}", path.display()))?
.len();
if size <= part_size {
let digest = hash_file_part(&mut file, size)?;
return Ok((format!("{digest:x}"), size));
}
let part_count = size.div_ceil(part_size);
let mut concatenated = Vec::with_capacity(part_count as usize * 16);
let mut remaining = size;
while remaining > 0 {
let this_part = remaining.min(part_size);
let digest = hash_file_part(&mut file, this_part)?;
concatenated.extend_from_slice(&digest.0);
remaining -= this_part;
}
let final_digest = md5::compute(&concatenated);
Ok((format!("{final_digest:x}-{part_count}"), size))
}
fn build_client(bucket_config: &BucketConfig, secret_key: &str) -> Result<MinioClient, String> {
let base_url: BaseUrl = bucket_config
.endpoint
.parse()
.map_err(|err| format!("invalid endpoint '{}': {err}", bucket_config.endpoint))?;
let provider = StaticProvider::new(&bucket_config.access_key_id, secret_key, None);
MinioClient::new(base_url, Some(provider), None, None).map_err(|err| {
format!(
"failed to create S3 client for '{}': {err}",
bucket_config.alias
)
})
}
fn format_error(err: &S3Error) -> String {
if let S3Error::S3Server(S3ServerError::S3Error(response)) = err {
let code = response.code();
return match response.message() {
Some(message) => format!("{code:?}: {message}"),
None => format!("{code:?}"),
};
}
err.to_string()
}
pub async fn list_buckets(
bucket_config: &BucketConfig,
secret_key: &str,
) -> Result<Vec<String>, String> {
let client = build_client(bucket_config, secret_key)?;
let resp = client
.list_buckets()
.build()
.send()
.await
.map_err(|err| format!("failed to list buckets: {}", format_error(&err)))?;
let buckets = resp
.buckets()
.map_err(|err| format!("failed to parse bucket list: {err}"))?;
Ok(buckets
.into_iter()
.map(|bucket| bucket.name.to_string())
.collect())
}
pub(crate) async fn bucket_exists(
bucket_config: &BucketConfig,
secret_key: &str,
) -> Result<bool, String> {
let client = build_client(bucket_config, secret_key)?;
let resp = client
.bucket_exists(bucket_config.bucket.as_str())
.map_err(|err| format!("invalid bucket name '{}': {err}", bucket_config.bucket))?
.build()
.send()
.await
.map_err(|err| format!("failed to check bucket: {}", format_error(&err)))?;
Ok(resp.exists())
}
pub async fn list_objects(
bucket_config: &BucketConfig,
secret_key: &str,
prefix: &str,
recursive: bool,
) -> Result<Vec<ObjectEntry>, String> {
let client = build_client(bucket_config, secret_key)?;
let prefix = Some(prefix.to_string());
let list = if recursive {
client
.list_objects(bucket_config.bucket.as_str())
.map_err(|err| format!("invalid bucket name '{}': {err}", bucket_config.bucket))?
.prefix(prefix)
.recursive(true)
.build()
} else {
client
.list_objects(bucket_config.bucket.as_str())
.map_err(|err| format!("invalid bucket name '{}': {err}", bucket_config.bucket))?
.prefix(prefix)
.delimiter(Some("/".to_string()))
.build()
};
let mut stream = list.to_stream().await;
let mut entries = Vec::new();
while let Some(page) = stream.next().await {
let page = page.map_err(|err| format!("failed to list objects: {}", format_error(&err)))?;
for item in page.contents {
entries.push(ObjectEntry {
key: item.name,
size: item.size.unwrap_or(0),
is_prefix: item.is_prefix,
});
}
}
Ok(entries)
}
pub async fn download_object_to_file(
bucket_config: &BucketConfig,
secret_key: &str,
key: &str,
dest_path: &Path,
) -> Result<u64, String> {
let client = build_client(bucket_config, secret_key)?;
let resp = client
.get_object(bucket_config.bucket.as_str(), key)
.map_err(|err| format!("invalid object key '{key}': {err}"))?
.build()
.send()
.await
.map_err(|err| format!("failed to download '{key}': {}", format_error(&err)))?;
resp.content()
.map_err(|err| format!("failed to read '{key}': {err}"))?
.to_file(dest_path)
.await
.map_err(|err| {
format!(
"failed to download '{key}' to {}: {err}",
dest_path.display()
)
})
}
pub(crate) async fn upload_if_changed(
bucket_config: &BucketConfig,
secret_key: &str,
key: &str,
body: UploadBody,
) -> Result<UploadOutcome, String> {
let client = build_client(bucket_config, secret_key)?;
let existing_etag = match client
.stat_object(bucket_config.bucket.as_str(), key)
.map_err(|err| format!("invalid object key '{key}': {err}"))?
.build()
.send()
.await
{
Ok(resp) => Some(
resp.etag()
.map_err(|err| format!("failed to read ETag for '{key}': {err}"))?
.to_string(),
),
Err(S3Error::S3Server(S3ServerError::S3Error(ref response)))
if response.code() == MinioErrorCode::NoSuchKey =>
{
None
}
Err(err) => {
return Err(format!("failed to check '{key}': {}", format_error(&err)));
}
};
let compute_hash = needs_hash(&existing_etag, &body);
let (local_hash, size): (Option<String>, u64) = match &body {
UploadBody::Path(path) => {
let path = path.clone();
if compute_hash {
let (hash, size) =
tokio::task::spawn_blocking(move || expected_etag_for_file(&path))
.await
.map_err(|err| format!("hash task panicked: {err}"))??;
(Some(hash), size)
} else {
let size = tokio::task::spawn_blocking(move || file_size(&path))
.await
.map_err(|err| format!("size task panicked: {err}"))??;
(None, size)
}
}
UploadBody::Bytes(bytes) => (
Some(format!("{:x}", md5::compute(bytes.as_ref()))),
bytes.len() as u64,
),
};
if let Some(existing) = existing_etag {
let local_hash =
local_hash.expect("existing_etag.is_some() implies needs_hash(...) was true");
if existing == local_hash {
return Ok(UploadOutcome::Unchanged);
}
println!("note: '{key}' changed since last upload, new version created");
}
match body {
UploadBody::Path(path) => {
let content = ObjectContent::from(path.as_path());
let builder = client
.put_object_content(bucket_config.bucket.as_str(), key, content)
.map_err(|err| format!("invalid object key '{key}': {err}"))?;
let send_result = if size > UPLOAD_PART_SIZE {
builder.part_size(UPLOAD_PART_SIZE).build().send().await
} else {
builder.build().send().await
};
send_result
.map_err(|err| format!("failed to upload '{key}': {}", format_error(&err)))?;
}
UploadBody::Bytes(bytes) => {
let segmented = SegmentedBytes::from(bytes);
client
.put_object(bucket_config.bucket.as_str(), key, segmented)
.map_err(|err| format!("invalid object key '{key}': {err}"))?
.build()
.send()
.await
.map_err(|err| format!("failed to upload '{key}': {}", format_error(&err)))?;
}
}
Ok(UploadOutcome::Uploaded)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn write_fixture(bytes: &[u8]) -> (tempfile::TempDir, PathBuf) {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("fixture.bin");
let mut file = File::create(&path).unwrap();
file.write_all(bytes).unwrap();
(dir, path)
}
#[test]
fn needs_hash_skips_path_bodies_when_nothing_to_compare_against() {
let path_body = UploadBody::Path(PathBuf::from("/does/not/matter"));
assert!(!needs_hash(&None, &path_body));
assert!(needs_hash(&Some("etag".to_string()), &path_body));
}
#[test]
fn needs_hash_always_true_for_bytes_bodies() {
let bytes_body = UploadBody::Bytes(Bytes::from_static(b"x"));
assert!(needs_hash(&None, &bytes_body));
assert!(needs_hash(&Some("etag".to_string()), &bytes_body));
}
#[test]
#[cfg(unix)]
fn file_size_does_not_require_read_permission() {
use std::os::unix::fs::PermissionsExt;
let (_dir, path) = write_fixture(b"some content");
let mut perms = std::fs::metadata(&path).unwrap().permissions();
perms.set_mode(0o000);
std::fs::set_permissions(&path, perms).unwrap();
if File::open(&path).is_ok() {
return;
}
assert_eq!(file_size(&path).unwrap(), 12);
}
#[test]
fn streamed_md5_equals_md5_compute_on_a_small_fixture() {
let data = b"the quick brown fox jumps over the lazy dog";
let (_dir, path) = write_fixture(data);
let (etag, size) = expected_etag_with_part_size(&path, UPLOAD_PART_SIZE).unwrap();
assert_eq!(size, data.len() as u64);
assert_eq!(etag, format!("{:x}", md5::compute(data)));
}
#[test]
fn streamed_md5_handles_an_empty_file() {
let (_dir, path) = write_fixture(b"");
let (etag, size) = expected_etag_with_part_size(&path, UPLOAD_PART_SIZE).unwrap();
assert_eq!(size, 0);
assert_eq!(etag, format!("{:x}", md5::compute(b"")));
}
#[test]
fn multipart_etag_matches_the_standard_s3_recipe() {
let data: Vec<u8> = (0u8..25).collect();
let (_dir, path) = write_fixture(&data);
let (etag, size) = expected_etag_with_part_size(&path, 10).unwrap();
assert_eq!(size, 25);
assert_eq!(etag, "704bbbf0caffa731e3361851ec17ae6c-3");
}
#[test]
fn a_file_at_exactly_the_part_size_stays_single_part() {
let data = vec![7u8; 10];
let (_dir, path) = write_fixture(&data);
let (etag, _size) = expected_etag_with_part_size(&path, 10).unwrap();
assert_eq!(etag, format!("{:x}", md5::compute(&data)));
assert!(!etag.contains('-'));
}
}