use std::collections::HashMap;
use async_trait::async_trait;
use jiff::Timestamp;
use serde::{Deserialize, Serialize};
use tokio::io::{AsyncWrite, AsyncWriteExt};
use crate::cors::CorsRule;
use crate::encryption::{BucketEncryption, ObjectEncryptionRequest};
use crate::error::{Error, Result};
use crate::lifecycle::LifecycleRule;
use crate::multipart_copy::{
MultipartCopyCancellation, MultipartCopyOptions, MultipartCopyProgress, MultipartCopyResult,
};
use crate::object_lock::{
BucketObjectLockConfiguration, LegalHoldStatus, ObjectLockOptions, ObjectRetention,
};
use crate::path::RemotePath;
use crate::replication::{
ReplicationCheckResult, ReplicationConfiguration, ReplicationResyncStartOptions,
ReplicationResyncStartResult, ReplicationResyncStatus,
};
use crate::select::SelectOptions;
use crate::transfer_options::{
ObjectTransferMetadata, ObjectWriteOptions, TransferCopyOptions, TransferReadOptions,
};
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct CreateBucketOptions {
pub region: Option<String>,
pub versioning_enabled: bool,
pub object_lock_enabled: bool,
}
impl CreateBucketOptions {
pub fn for_cli(
region: Option<String>,
versioning_enabled: bool,
object_lock_enabled: bool,
) -> Result<Self> {
let options = Self {
region,
versioning_enabled: versioning_enabled || object_lock_enabled,
object_lock_enabled,
};
options.validate()?;
Ok(options)
}
pub fn validate(&self) -> Result<()> {
if self.object_lock_enabled && !self.versioning_enabled {
return Err(Error::InvalidPath(
"Bucket Object Lock requires versioning to be enabled".to_string(),
));
}
if self
.region
.as_deref()
.is_some_and(|region| region.trim().is_empty())
{
return Err(Error::InvalidPath(
"Bucket region cannot be empty".to_string(),
));
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ObjectVersion {
pub key: String,
pub version_id: String,
pub is_latest: bool,
pub is_delete_marker: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_modified: Option<Timestamp>,
#[serde(skip_serializing_if = "Option::is_none")]
pub size_bytes: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub etag: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ObjectVersionListResult {
pub items: Vec<ObjectVersion>,
pub truncated: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub continuation_token: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub version_id_marker: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ObjectReadOptions {
pub version_id: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CopyObjectOptions {
pub source_version_id: Option<String>,
}
impl CopyObjectOptions {
pub fn for_source_version(source_version_id: Option<String>) -> Result<Self> {
if source_version_id.as_deref().is_some_and(str::is_empty) {
return Err(Error::InvalidPath(
"Source version ID cannot be empty".to_string(),
));
}
Ok(Self { source_version_id })
}
}
impl ObjectReadOptions {
pub fn for_version(version_id: Option<String>) -> Result<Self> {
if version_id.as_deref().is_some_and(str::is_empty) {
return Err(Error::InvalidPath("Version ID cannot be empty".to_string()));
}
Ok(Self { version_id })
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ListObjectVersionsOptions {
pub max_keys: Option<i32>,
pub key_marker: Option<String>,
pub version_id_marker: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DeleteRequestOptions {
pub version_id: Option<String>,
pub bypass_governance: bool,
pub force_delete: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ObjectVersionIdentifier {
pub key: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub version_id: Option<String>,
pub is_delete_marker: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DeletedObject {
pub key: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub version_id: Option<String>,
pub is_delete_marker: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DeleteObjectFailure {
pub key: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub version_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub code: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct DeleteObjectsResult {
pub deleted: Vec<DeletedObject>,
pub failures: Vec<DeleteObjectFailure>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ObjectInfo {
pub key: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub size_bytes: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub size_human: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_modified: Option<Timestamp>,
#[serde(skip_serializing_if = "Option::is_none")]
pub etag: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub storage_class: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub content_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<HashMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub version_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub source_version_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub is_delete_marker: Option<bool>,
pub is_dir: bool,
}
impl ObjectInfo {
pub fn file(key: impl Into<String>, size: i64) -> Self {
Self {
key: key.into(),
size_bytes: Some(size),
size_human: Some(humansize::format_size(size as u64, humansize::BINARY)),
last_modified: None,
etag: None,
storage_class: None,
content_type: None,
metadata: None,
version_id: None,
source_version_id: None,
is_delete_marker: None,
is_dir: false,
}
}
pub fn dir(key: impl Into<String>) -> Self {
Self {
key: key.into(),
size_bytes: None,
size_human: None,
last_modified: None,
etag: None,
storage_class: None,
content_type: None,
metadata: None,
version_id: None,
source_version_id: None,
is_delete_marker: None,
is_dir: true,
}
}
pub fn bucket(name: impl Into<String>) -> Self {
Self {
key: name.into(),
size_bytes: None,
size_human: None,
last_modified: None,
etag: None,
storage_class: None,
content_type: None,
metadata: None,
version_id: None,
source_version_id: None,
is_delete_marker: None,
is_dir: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ListResult {
pub items: Vec<ObjectInfo>,
pub truncated: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub continuation_token: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct ListOptions {
pub max_keys: Option<i32>,
pub delimiter: Option<String>,
pub prefix: Option<String>,
pub continuation_token: Option<String>,
pub recursive: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MultipartIdentity {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MultipartUpload {
pub bucket: String,
pub key: String,
pub upload_id: String,
pub initiated: Option<Timestamp>,
pub size_bytes: Option<i64>,
pub storage_class: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub initiator: Option<MultipartIdentity>,
#[serde(skip_serializing_if = "Option::is_none")]
pub owner: Option<MultipartIdentity>,
#[serde(skip_serializing_if = "Option::is_none")]
pub checksum_algorithm: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub checksum_type: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct MultipartUploadListOptions {
pub prefix: Option<String>,
pub delimiter: Option<String>,
pub key_marker: Option<String>,
pub upload_id_marker: Option<String>,
pub max_uploads: Option<i32>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MultipartUploadListResult {
pub uploads: Vec<MultipartUpload>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub common_prefixes: Vec<String>,
pub truncated: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub next_key_marker: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub next_upload_id_marker: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AbortMultipartUploadRequest {
pub bucket: String,
pub key: String,
pub upload_id: String,
}
#[derive(Debug, Clone, Default)]
pub struct Capabilities {
pub versioning: bool,
pub object_lock: bool,
pub tagging: bool,
pub anonymous: bool,
pub select: bool,
pub notifications: bool,
pub lifecycle: bool,
pub replication: bool,
pub cors: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum NotificationTarget {
Queue,
Topic,
Lambda,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BucketNotification {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
pub target: NotificationTarget,
pub arn: String,
pub events: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prefix: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub suffix: Option<String>,
}
#[async_trait]
pub trait ObjectStore: Send + Sync {
async fn list_buckets(&self) -> Result<Vec<ObjectInfo>>;
async fn list_objects(&self, path: &RemotePath, options: ListOptions) -> Result<ListResult>;
async fn list_multipart_uploads(
&self,
bucket: &str,
options: MultipartUploadListOptions,
) -> Result<MultipartUploadListResult> {
let _ = (bucket, options);
Err(Error::UnsupportedFeature(
"Multipart upload listing is not supported by this object store".to_string(),
))
}
async fn abort_multipart_upload(&self, request: &AbortMultipartUploadRequest) -> Result<()> {
let _ = request;
Err(Error::UnsupportedFeature(
"Multipart upload cleanup is not supported by this object store".to_string(),
))
}
async fn head_object(&self, path: &RemotePath) -> Result<ObjectInfo>;
async fn head_object_with_options(
&self,
path: &RemotePath,
options: &ObjectReadOptions,
) -> Result<ObjectInfo> {
if options.version_id.is_some() {
return Err(Error::UnsupportedFeature(
"Exact-version metadata reads are not implemented by this object store".to_string(),
));
}
self.head_object(path).await
}
async fn head_object_with_transfer_options(
&self,
path: &RemotePath,
options: &TransferReadOptions,
) -> Result<ObjectInfo> {
let legacy = options.legacy_read_options()?;
self.head_object_with_options(path, &legacy).await
}
async fn head_object_transfer_metadata(
&self,
_path: &RemotePath,
options: &TransferReadOptions,
) -> Result<ObjectTransferMetadata> {
options.validate()?;
Err(Error::UnsupportedFeature(
"Complete transfer metadata is not implemented by this object store".to_string(),
))
}
async fn bucket_exists(&self, bucket: &str) -> Result<bool>;
async fn create_bucket(&self, bucket: &str) -> Result<()>;
async fn create_bucket_with_options(
&self,
bucket: &str,
options: &CreateBucketOptions,
) -> Result<()> {
options.validate()?;
if options != &CreateBucketOptions::default() {
return Err(Error::UnsupportedFeature(
"Bucket creation options are not implemented by this object store".to_string(),
));
}
self.create_bucket(bucket).await
}
async fn get_bucket_location(&self, _bucket: &str) -> Result<Option<String>> {
Err(Error::UnsupportedFeature(
"Bucket location inspection is not implemented by this object store".to_string(),
))
}
async fn delete_bucket(&self, bucket: &str) -> Result<()>;
async fn capabilities(&self) -> Result<Capabilities>;
async fn get_object(&self, path: &RemotePath) -> Result<Vec<u8>>;
async fn get_object_with_options(
&self,
path: &RemotePath,
options: &ObjectReadOptions,
) -> Result<Vec<u8>> {
if options.version_id.is_some() {
return Err(Error::UnsupportedFeature(
"Exact-version object reads are not implemented by this object store".to_string(),
));
}
self.get_object(path).await
}
async fn get_object_with_transfer_options(
&self,
path: &RemotePath,
options: &TransferReadOptions,
) -> Result<Vec<u8>> {
let legacy = options.legacy_read_options()?;
self.get_object_with_options(path, &legacy).await
}
async fn write_object_to_with_options(
&self,
path: &RemotePath,
options: &ObjectReadOptions,
writer: &mut (dyn AsyncWrite + Send + Unpin),
max_bytes: Option<u64>,
) -> Result<u64> {
let data = self.get_object_with_options(path, options).await?;
let write_len = max_bytes
.and_then(|limit| usize::try_from(limit).ok())
.map(|limit| limit.min(data.len()))
.unwrap_or(data.len());
writer.write_all(&data[..write_len]).await?;
writer.flush().await?;
Ok(write_len as u64)
}
async fn write_object_to_with_transfer_options(
&self,
path: &RemotePath,
options: &TransferReadOptions,
writer: &mut (dyn AsyncWrite + Send + Unpin),
max_bytes: Option<u64>,
) -> Result<u64> {
let legacy = options.legacy_read_options()?;
self.write_object_to_with_options(path, &legacy, writer, max_bytes)
.await
}
async fn put_object(
&self,
path: &RemotePath,
data: Vec<u8>,
content_type: Option<&str>,
encryption: Option<&ObjectEncryptionRequest>,
) -> Result<ObjectInfo>;
async fn put_object_with_options(
&self,
path: &RemotePath,
data: Vec<u8>,
options: &ObjectWriteOptions,
) -> Result<ObjectInfo> {
let (content_type, encryption) = options.legacy_put_arguments()?;
self.put_object(path, data, content_type, encryption).await
}
async fn delete_object(&self, path: &RemotePath) -> Result<()>;
async fn delete_object_with_options(
&self,
path: &RemotePath,
options: DeleteRequestOptions,
) -> Result<DeletedObject> {
if options.version_id.is_some() || options.bypass_governance || options.force_delete {
return Err(Error::UnsupportedFeature(
"Version-aware or policy-bypassing deletion is not implemented by this object store"
.to_string(),
));
}
self.delete_object(path).await?;
Ok(DeletedObject {
key: path.key.clone(),
version_id: None,
is_delete_marker: false,
})
}
async fn delete_objects(&self, bucket: &str, keys: Vec<String>) -> Result<Vec<String>>;
async fn delete_object_versions(
&self,
_bucket: &str,
_objects: Vec<ObjectVersionIdentifier>,
_options: DeleteRequestOptions,
) -> Result<DeleteObjectsResult> {
Err(Error::UnsupportedFeature(
"Multi-object version deletion is not implemented by this object store".to_string(),
))
}
async fn copy_object(
&self,
src: &RemotePath,
dst: &RemotePath,
encryption: Option<&ObjectEncryptionRequest>,
) -> Result<ObjectInfo>;
async fn copy_object_with_options(
&self,
src: &RemotePath,
dst: &RemotePath,
options: &CopyObjectOptions,
encryption: Option<&ObjectEncryptionRequest>,
) -> Result<ObjectInfo> {
if options.source_version_id.is_some() {
return Err(Error::UnsupportedFeature(
"Exact-version server-side copy is not implemented by this object store"
.to_string(),
));
}
self.copy_object(src, dst, encryption).await
}
async fn copy_object_with_transfer_options(
&self,
src: &RemotePath,
dst: &RemotePath,
options: &TransferCopyOptions,
) -> Result<ObjectInfo> {
let (legacy, encryption) = options.legacy_copy_arguments()?;
self.copy_object_with_options(src, dst, &legacy, encryption)
.await
}
async fn multipart_copy(
&self,
_src: &RemotePath,
_dst: &RemotePath,
_options: &MultipartCopyOptions,
_cancellation: &MultipartCopyCancellation,
_encryption: Option<&ObjectEncryptionRequest>,
_on_progress: &MultipartCopyProgress<'_>,
) -> Result<MultipartCopyResult> {
Err(Error::UnsupportedFeature(
"Multipart server-side copy is not implemented by this object store".to_string(),
))
}
async fn multipart_copy_with_transfer_options(
&self,
src: &RemotePath,
dst: &RemotePath,
multipart: &MultipartCopyOptions,
transfer: &TransferCopyOptions,
cancellation: &MultipartCopyCancellation,
on_progress: &MultipartCopyProgress<'_>,
) -> Result<MultipartCopyResult> {
transfer.validate_multipart_source_version(multipart.source_version_id.as_deref())?;
let (_, encryption) = transfer.legacy_copy_arguments()?;
self.multipart_copy(src, dst, multipart, cancellation, encryption, on_progress)
.await
}
async fn presign_get(&self, path: &RemotePath, expires_secs: u64) -> Result<String>;
async fn presign_put(
&self,
path: &RemotePath,
expires_secs: u64,
content_type: Option<&str>,
) -> Result<String>;
async fn get_versioning(&self, bucket: &str) -> Result<Option<bool>>;
async fn set_versioning(&self, bucket: &str, enabled: bool) -> Result<()>;
async fn get_bucket_object_lock_configuration(
&self,
_bucket: &str,
) -> Result<Option<BucketObjectLockConfiguration>> {
Err(Error::UnsupportedFeature(
"Bucket Object Lock configuration is not implemented by this object store".to_string(),
))
}
async fn put_bucket_object_lock_configuration(
&self,
_bucket: &str,
_configuration: BucketObjectLockConfiguration,
) -> Result<()> {
Err(Error::UnsupportedFeature(
"Bucket Object Lock configuration is not implemented by this object store".to_string(),
))
}
async fn get_object_retention(
&self,
_path: &RemotePath,
_options: &ObjectLockOptions,
) -> Result<Option<ObjectRetention>> {
Err(Error::UnsupportedFeature(
"Object retention is not implemented by this object store".to_string(),
))
}
async fn put_object_retention(
&self,
_path: &RemotePath,
_retention: Option<ObjectRetention>,
_options: &ObjectLockOptions,
) -> Result<()> {
Err(Error::UnsupportedFeature(
"Object retention is not implemented by this object store".to_string(),
))
}
async fn get_object_legal_hold(
&self,
_path: &RemotePath,
_options: &ObjectLockOptions,
) -> Result<LegalHoldStatus> {
Err(Error::UnsupportedFeature(
"Object legal hold is not implemented by this object store".to_string(),
))
}
async fn put_object_legal_hold(
&self,
_path: &RemotePath,
_status: LegalHoldStatus,
_options: &ObjectLockOptions,
) -> Result<()> {
Err(Error::UnsupportedFeature(
"Object legal hold is not implemented by this object store".to_string(),
))
}
async fn get_bucket_encryption(&self, bucket: &str) -> Result<Option<BucketEncryption>>;
async fn set_bucket_encryption(&self, bucket: &str, encryption: BucketEncryption)
-> Result<()>;
async fn delete_bucket_encryption(&self, bucket: &str) -> Result<()>;
async fn list_object_versions(
&self,
path: &RemotePath,
max_keys: Option<i32>,
) -> Result<Vec<ObjectVersion>>;
async fn list_object_versions_page_with_options(
&self,
_path: &RemotePath,
_options: &ListObjectVersionsOptions,
) -> Result<ObjectVersionListResult> {
Err(Error::UnsupportedFeature(
"Paginated object version listing is not implemented by this object store".to_string(),
))
}
async fn get_object_tags(
&self,
path: &RemotePath,
) -> Result<std::collections::HashMap<String, String>>;
async fn get_bucket_tags(
&self,
bucket: &str,
) -> Result<std::collections::HashMap<String, String>>;
async fn set_object_tags(
&self,
path: &RemotePath,
tags: std::collections::HashMap<String, String>,
) -> Result<()>;
async fn set_bucket_tags(
&self,
bucket: &str,
tags: std::collections::HashMap<String, String>,
) -> Result<()>;
async fn delete_object_tags(&self, path: &RemotePath) -> Result<()>;
async fn delete_bucket_tags(&self, bucket: &str) -> Result<()>;
async fn get_bucket_policy(&self, bucket: &str) -> Result<Option<String>>;
async fn set_bucket_policy(&self, bucket: &str, policy: &str) -> Result<()>;
async fn delete_bucket_policy(&self, bucket: &str) -> Result<()>;
async fn get_bucket_notifications(&self, bucket: &str) -> Result<Vec<BucketNotification>>;
async fn set_bucket_notifications(
&self,
bucket: &str,
notifications: Vec<BucketNotification>,
) -> Result<()>;
async fn get_bucket_lifecycle(&self, bucket: &str) -> Result<Vec<LifecycleRule>>;
async fn set_bucket_lifecycle(&self, bucket: &str, rules: Vec<LifecycleRule>) -> Result<()>;
async fn delete_bucket_lifecycle(&self, bucket: &str) -> Result<()>;
async fn restore_object(&self, path: &RemotePath, days: i32) -> Result<()>;
async fn get_bucket_replication(
&self,
bucket: &str,
) -> Result<Option<ReplicationConfiguration>>;
async fn set_bucket_replication(
&self,
bucket: &str,
config: ReplicationConfiguration,
) -> Result<()>;
async fn delete_bucket_replication(&self, bucket: &str) -> Result<()>;
async fn check_bucket_replication(&self, bucket: &str) -> Result<()>;
async fn check_bucket_replication_detailed(
&self,
bucket: &str,
) -> Result<ReplicationCheckResult> {
self.check_bucket_replication(bucket).await?;
Ok(ReplicationCheckResult::legacy_success())
}
async fn start_bucket_replication_resync(
&self,
bucket: &str,
options: ReplicationResyncStartOptions,
) -> Result<ReplicationResyncStartResult>;
async fn bucket_replication_resync_status(
&self,
bucket: &str,
target_arn: Option<&str>,
) -> Result<ReplicationResyncStatus>;
async fn get_bucket_cors(&self, bucket: &str) -> Result<Vec<CorsRule>>;
async fn set_bucket_cors(&self, bucket: &str, rules: Vec<CorsRule>) -> Result<()>;
async fn delete_bucket_cors(&self, bucket: &str) -> Result<()>;
async fn select_object_content(
&self,
path: &RemotePath,
options: &SelectOptions,
writer: &mut (dyn AsyncWrite + Send + Unpin),
) -> Result<()>;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn create_bucket_options_reject_lock_without_versioning() {
let options = CreateBucketOptions {
region: Some("us-east-1".to_string()),
versioning_enabled: false,
object_lock_enabled: true,
};
let error = options
.validate()
.expect_err("Object Lock without versioning must be rejected");
assert!(matches!(error, crate::Error::InvalidPath(_)));
}
#[test]
fn create_bucket_options_normalize_cli_lock_to_versioning() {
let options = CreateBucketOptions::for_cli(Some("us-east-1".to_string()), false, true)
.expect("CLI Object Lock options should be valid");
assert!(options.object_lock_enabled);
assert!(options.versioning_enabled);
assert_eq!(options.region.as_deref(), Some("us-east-1"));
}
#[test]
fn test_object_info_file() {
let info = ObjectInfo::file("test.txt", 1024);
assert_eq!(info.key, "test.txt");
assert_eq!(info.size_bytes, Some(1024));
assert!(!info.is_dir);
assert_eq!(info.version_id, None);
assert_eq!(info.source_version_id, None);
assert_eq!(info.is_delete_marker, None);
}
#[test]
fn object_read_options_reject_empty_version_ids() {
let error = ObjectReadOptions::for_version(Some(String::new()))
.expect_err("empty version IDs must be rejected");
assert!(matches!(error, crate::Error::InvalidPath(_)));
}
#[test]
fn versioned_delete_targets_are_serializable_for_structured_output() {
let target = ObjectVersionIdentifier {
key: "reports/a.csv".to_string(),
version_id: Some("v1".to_string()),
is_delete_marker: true,
};
let json = serde_json::to_value(target).expect("serialize versioned delete target");
assert_eq!(json["key"], "reports/a.csv");
assert_eq!(json["version_id"], "v1");
assert_eq!(json["is_delete_marker"], true);
}
#[test]
fn object_info_version_fields_are_optional_and_serializable() {
let current = serde_json::to_value(ObjectInfo::file("current.txt", 1))
.expect("serialize current object info");
assert!(current.get("version_id").is_none());
assert!(current.get("source_version_id").is_none());
assert!(current.get("is_delete_marker").is_none());
let mut copied = ObjectInfo::file("copy.txt", 1);
copied.version_id = Some("destination-v2".to_string());
copied.source_version_id = Some("source-v1".to_string());
let copied = serde_json::to_value(copied).expect("serialize copy object info");
assert_eq!(copied["version_id"], "destination-v2");
assert_eq!(copied["source_version_id"], "source-v1");
}
#[test]
fn test_object_info_dir() {
let info = ObjectInfo::dir("path/to/dir/");
assert_eq!(info.key, "path/to/dir/");
assert!(info.is_dir);
assert!(info.size_bytes.is_none());
}
#[test]
fn test_object_info_bucket() {
let info = ObjectInfo::bucket("my-bucket");
assert_eq!(info.key, "my-bucket");
assert!(info.is_dir);
}
#[test]
fn test_object_info_metadata_default_none() {
let info = ObjectInfo::file("test.txt", 1024);
assert!(info.metadata.is_none());
}
#[test]
fn test_object_info_metadata_set() {
let mut info = ObjectInfo::file("test.txt", 1024);
let mut meta = HashMap::new();
meta.insert("content-disposition".to_string(), "attachment".to_string());
meta.insert("custom-key".to_string(), "custom-value".to_string());
info.metadata = Some(meta);
let metadata = info.metadata.as_ref().expect("metadata should be Some");
assert_eq!(metadata.len(), 2);
assert_eq!(metadata.get("content-disposition").unwrap(), "attachment");
assert_eq!(metadata.get("custom-key").unwrap(), "custom-value");
}
#[test]
fn multipart_upload_serializes_stable_identity_and_server_metadata() {
let upload = MultipartUpload {
bucket: "archive".to_string(),
key: "backups/data.tar".to_string(),
upload_id: "upload-123".to_string(),
initiated: Some(
"2026-07-21T04:00:00Z"
.parse()
.expect("test timestamp should be valid"),
),
size_bytes: None,
storage_class: Some("STANDARD".to_string()),
initiator: Some(MultipartIdentity {
id: Some("user-1".to_string()),
display_name: Some("backup-agent".to_string()),
}),
owner: None,
checksum_algorithm: Some("CRC64NVME".to_string()),
checksum_type: Some("FULL_OBJECT".to_string()),
};
let value = serde_json::to_value(upload).expect("multipart upload should serialize");
assert_eq!(value["bucket"], "archive");
assert_eq!(value["key"], "backups/data.tar");
assert_eq!(value["upload_id"], "upload-123");
assert_eq!(value["initiated"], "2026-07-21T04:00:00Z");
assert!(value["size_bytes"].is_null());
assert_eq!(value["storage_class"], "STANDARD");
assert_eq!(value["initiator"]["id"], "user-1");
}
#[test]
fn multipart_list_options_default_to_the_first_unfiltered_page() {
let options = MultipartUploadListOptions::default();
assert!(options.prefix.is_none());
assert!(options.delimiter.is_none());
assert!(options.key_marker.is_none());
assert!(options.upload_id_marker.is_none());
assert!(options.max_uploads.is_none());
}
}