use std::collections::HashMap; use std::num::NonZeroUsize; use std::time::Duration; use futures_util::TryStreamExt; use opendal::services::S3; use opendal::{Metadata, Operator, Writer}; use remora_domain::OpaqueId; use remora_domain::ports::StorageAdapter; use crate::body::{OpenDalTargetBody, StoredWrite}; use crate::config::TargetStoreConfig; use crate::digest::{ContentHasher, ContentSha256, check_expected, hash_bytes}; use crate::error::{TargetError, map_opendal, redact_sensitive}; use crate::identity::{ CompletedPart, MultipartIdentity, ObjectIdentity, REMORA_SHA256_METADATA_KEY, attempt_manifest_key, attempt_object_key, validate_attempt_key, }; use crate::multipart::MultipartClient; /// Request for a create-only object write under a unique reserved key. #[derive(Clone, Debug, Eq, PartialEq)] pub struct CreateObjectRequest { key: String, expected_content_sha256: Option, chunk_size: Option, } impl CreateObjectRequest { pub fn parse(key: impl Into) -> Result { let key = key.into(); validate_attempt_key(&key)?; Ok(Self { key, expected_content_sha256: None, chunk_size: None, }) } #[must_use] pub fn for_attempt_object( attempt_id: &OpaqueId, object_id: &OpaqueId, relative_path: Option<&str>, ) -> Self { Self { key: attempt_object_key(attempt_id, object_id, relative_path), expected_content_sha256: None, chunk_size: None, } } #[must_use] pub fn for_attempt_manifest(attempt_id: &OpaqueId, manifest_id: &OpaqueId) -> Self { Self { key: attempt_manifest_key(attempt_id, manifest_id), expected_content_sha256: None, chunk_size: None, } } #[must_use] pub fn with_expected_content_sha256(mut self, expected: ContentSha256) -> Self { self.expected_content_sha256 = Some(expected); self } #[must_use] pub fn with_chunk_size(mut self, chunk_size: NonZeroUsize) -> Self { self.chunk_size = Some(chunk_size); self } #[must_use] pub fn key(&self) -> &str { &self.key } } /// Stored object returned after a create-only write completes. #[derive(Clone, Debug, Eq, PartialEq)] pub struct StoredObject { pub identity: ObjectIdentity, /// Independently computed SHA-256 of the written bytes. pub content_sha256: ContentSha256, pub size: u64, } #[derive(Clone, Debug, Eq, PartialEq)] pub struct ObjectMetadata { pub identity: ObjectIdentity, pub content_sha256: Option, pub size: u64, } /// Time-limited exact-object access URL. #[derive(Clone, Debug, Eq, PartialEq)] pub struct TimeLimitedAccess { pub method: String, pub url: String, pub expires_in: Duration, } /// Recoverable multipart upload bound to one exact target identity. pub struct MultipartSession { identity: MultipartIdentity, expected_sha256: Option, } impl std::fmt::Debug for MultipartSession { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { formatter .debug_struct("MultipartSession") .field("identity", &self.identity) .field("expected_sha256", &self.expected_sha256) .finish_non_exhaustive() } } impl MultipartSession { #[must_use] pub fn identity(&self) -> &MultipartIdentity { &self.identity } } /// OpenDAL-backed target storage adapter for attempt-scoped object writes. #[derive(Clone)] pub struct TargetStore { config: TargetStoreConfig, operator: Operator, multipart: MultipartClient, } impl TargetStore { pub fn connect(config: TargetStoreConfig) -> Result { opendal::install_default(); let mut builder = S3::default() .bucket(&config.bucket) .endpoint(&config.endpoint) .region(&config.region) .access_key_id(&config.access_key_id) .secret_access_key(&config.secret_access_key) .disable_config_load() .disable_ec2_metadata(); if !config.root.is_empty() { builder = builder.root(&config.root); } let operator = Operator::new(builder) .map_err(|error| TargetError::validation(redact_sensitive(error.to_string())))?; let multipart = MultipartClient::new(config.clone())?; Ok(Self { config, operator, multipart, }) } pub fn connect_from_env() -> Result { Self::connect(TargetStoreConfig::from_env()?) } #[must_use] pub fn config(&self) -> &TargetStoreConfig { &self.config } /// Open a create-only streaming writer for one attempt object. pub async fn open_create_only( &self, request: &CreateObjectRequest, ) -> Result { validate_attempt_key(&request.key)?; self.require_absent(&request.key).await?; let writer = self.open_writer(request).await?; Ok(OpenDalTargetBody::new( writer, self.new_object_identity(&request.key, None), request.expected_content_sha256.clone(), )) } /// Create-only put of a complete buffer. /// /// Independently hashes `body`, persists that digest as user metadata, and /// retains the version ID from the write response when present. pub async fn put_create_only( &self, request: &CreateObjectRequest, body: impl Into, ) -> Result { validate_attempt_key(&request.key)?; self.require_absent(&request.key).await?; let body = body.into(); let computed = hash_bytes(&body); check_expected(request.expected_content_sha256.as_ref(), &computed)?; let size = body.len() as u64; let write = self .operator .write_with(&request.key, body) .if_not_exists(true) .user_metadata(sha256_metadata(&computed)); let metadata = write.await.map_err(map_opendal)?; Ok(self.stored_from_write(&request.key, &computed, &metadata, size)) } /// Exact identity read. Version is required when present on the identity. pub async fn read_exact(&self, identity: &ObjectIdentity) -> Result { self.ensure_object_identity(identity)?; let mut read = self.operator.read_with(&identity.key); if let Some(version) = identity.version_id.as_deref() { read = read.version(version); } let buffer = read.await.map_err(map_opendal)?; Ok(buffer.to_bytes()) } /// Exact identity delete. Missing objects are success for cleanup. pub async fn delete_exact(&self, identity: &ObjectIdentity) -> Result<(), TargetError> { self.ensure_object_identity(identity)?; let mut delete = self.operator.delete_with(&identity.key); if let Some(version) = identity.version_id.as_deref() { delete = delete.version(version); } match delete.await { Ok(()) => Ok(()), Err(error) if matches!(error.kind(), opendal::ErrorKind::NotFound) => Ok(()), Err(error) => Err(map_opendal(error)), } } /// Head an exact identity, returning size and retained SHA-256 metadata. pub async fn head_exact( &self, identity: &ObjectIdentity, ) -> Result { self.ensure_object_identity(identity)?; let mut stat = self.operator.stat_with(&identity.key); if let Some(version) = identity.version_id.as_deref() { stat = stat.version(version); } let metadata = stat.await.map_err(map_opendal)?; // Prefer exact version retained on the identity when the head omits it. let version_id = metadata .version() .map(str::to_owned) .or_else(|| identity.version_id.clone()); let content_sha256 = observed_sha256(&metadata)?; Ok(ObjectMetadata { identity: ObjectIdentity { target_store_id: self.config.target_store_id.clone(), bucket: self.config.bucket.clone(), key: identity.key.clone(), version_id, }, content_sha256, size: metadata.content_length(), }) } /// Issue a time-limited URL bound to one exact object identity. pub async fn presign_read( &self, identity: &ObjectIdentity, expires_in: Duration, ) -> Result { self.ensure_object_identity(identity)?; if expires_in.is_zero() { return Err(TargetError::validation( "time-limited access expiry must be positive", )); } let mut read_options = opendal::options::ReadOptions::default(); if let Some(version) = identity.version_id.as_deref() { read_options.version = Some(version.to_owned()); } let signed = self .operator .presign_read_options(&identity.key, expires_in, read_options) .await .map_err(map_opendal)?; Ok(TimeLimitedAccess { method: signed.method().as_str().to_owned(), url: signed.uri().to_string(), expires_in, }) } /// Begin a create-only multipart upload under a reserved key. /// /// The completed exact object is independently stream-hashed after S3 /// accepts the freshly re-listed part set. pub async fn begin_multipart( &self, request: &CreateObjectRequest, ) -> Result { self.require_absent(&request.key).await?; let identity = self.multipart.initiate(&request.key, None).await?; Ok(MultipartSession { identity, expected_sha256: request.expected_content_sha256.clone(), }) } /// Reconstruct a multipart session from its durable identity after loss. pub async fn resume_multipart( &self, identity: MultipartIdentity, expected_content_sha256: Option, ) -> Result<(MultipartSession, Vec), TargetError> { self.ensure_multipart_identity(&identity)?; validate_attempt_key(&identity.key)?; let parts = self.multipart.list_parts(&identity).await?; Ok(( MultipartSession { identity, expected_sha256: expected_content_sha256, }, parts, )) } pub async fn upload_part( &self, session: &MultipartSession, part_number: u32, body: bytes::Bytes, ) -> Result { self.ensure_multipart_identity(&session.identity)?; self.multipart .upload_part(&session.identity, part_number, body) .await } pub async fn list_parts( &self, session: &MultipartSession, ) -> Result, TargetError> { self.ensure_multipart_identity(&session.identity)?; self.multipart.list_parts(&session.identity).await } pub async fn complete_multipart( &self, session: MultipartSession, parts: &[CompletedPart], ) -> Result { self.ensure_multipart_identity(&session.identity)?; self.require_absent(&session.identity.key).await?; let relisted = self.multipart.list_parts(&session.identity).await?; if relisted != parts { return Err(TargetError::integrity( "multipart completion parts do not match exact target re-list", )); } let version_id = self .multipart .complete(&session.identity, parts, true) .await?; let object = self.new_object_identity(&session.identity.key, version_id); let (computed, size) = self.hash_exact(&object).await?; if let Err(error) = check_expected(session.expected_sha256.as_ref(), &computed) { let _ = self.delete_exact(&object).await; return Err(error); } Ok(StoredObject { identity: object, content_sha256: computed, size, }) } pub async fn abort_multipart(&self, session: MultipartSession) -> Result<(), TargetError> { self.ensure_multipart_identity(&session.identity)?; self.multipart.abort(&session.identity).await } async fn open_writer(&self, request: &CreateObjectRequest) -> Result { let chunk = request .chunk_size .map(NonZeroUsize::get) .unwrap_or(5 * 1024 * 1024); self.operator .writer_with(&request.key) .if_not_exists(true) .chunk(chunk) .await .map_err(map_opendal) } async fn require_absent(&self, key: &str) -> Result<(), TargetError> { match self.operator.stat(key).await { Ok(_) => Err(TargetError::AlreadyExists), Err(error) if matches!(error.kind(), opendal::ErrorKind::NotFound) => Ok(()), Err(error) => Err(map_opendal(error)), } } async fn hash_exact( &self, identity: &ObjectIdentity, ) -> Result<(ContentSha256, u64), TargetError> { self.ensure_object_identity(identity)?; let mut reader = self.operator.reader_with(&identity.key).chunk(1024 * 1024); if let Some(version) = identity.version_id.as_deref() { reader = reader.version(version); } let reader = reader.await.map_err(map_opendal)?; let mut stream = reader.into_bytes_stream(..).await.map_err(map_opendal)?; let mut hasher = ContentHasher::new(); let mut size = 0u64; while let Some(chunk) = stream .try_next() .await .map_err(|error| TargetError::transient(redact_sensitive(error.to_string())))? { hasher.update(&chunk); size = size.saturating_add(u64::try_from(chunk.len()).unwrap_or(u64::MAX)); } Ok((hasher.finalize(), size)) } fn stored_from_write( &self, key: &str, content_sha256: &ContentSha256, metadata: &Metadata, written_size: u64, ) -> StoredObject { StoredObject { identity: self.new_object_identity(key, metadata.version().map(str::to_owned)), content_sha256: content_sha256.clone(), size: metadata.content_length().max(written_size), } } fn new_object_identity(&self, key: &str, version_id: Option) -> ObjectIdentity { ObjectIdentity { target_store_id: self.config.target_store_id.clone(), bucket: self.config.bucket.clone(), key: key.to_owned(), version_id: version_id.filter(|value| !value.is_empty()), } } fn ensure_object_identity(&self, identity: &ObjectIdentity) -> Result<(), TargetError> { if identity.target_store_id != self.config.target_store_id { return Err(TargetError::validation( "object identity target store does not match configured store", )); } if identity.bucket != self.config.bucket { return Err(TargetError::validation( "object identity bucket does not match configured target", )); } Ok(()) } fn ensure_multipart_identity(&self, identity: &MultipartIdentity) -> Result<(), TargetError> { if identity.target_store_id != self.config.target_store_id { return Err(TargetError::validation( "multipart identity target store does not match configured store", )); } if identity.bucket != self.config.bucket { return Err(TargetError::validation( "multipart identity bucket does not match configured target", )); } Ok(()) } } #[cfg(feature = "test-utils")] impl TargetStore { /// Overwrite put without create-only guards (fixture version matrix only). pub async fn put_overwrite( &self, request: &CreateObjectRequest, body: impl Into, ) -> Result { validate_attempt_key(&request.key)?; let body = body.into(); let computed = hash_bytes(&body); check_expected(request.expected_content_sha256.as_ref(), &computed)?; let size = body.len() as u64; let write = self .operator .write_with(&request.key, body) .user_metadata(sha256_metadata(&computed)); let metadata = write.await.map_err(map_opendal)?; Ok(self.stored_from_write(&request.key, &computed, &metadata, size)) } /// Enable bucket versioning when supported (fixture only). pub async fn enable_bucket_versioning(&self) -> Result { self.multipart.enable_bucket_versioning().await } /// Delete every version under a prefix (fixture teardown only). pub async fn purge_versions_with_prefix(&self, prefix: &str) -> Result { let versions = self.multipart.list_object_versions(prefix).await?; let count = versions.len(); for version in versions { self.multipart .delete_object_version(&version.key, &version.version_id) .await?; } Ok(count) } } impl StorageAdapter for TargetStore { type Error = TargetError; type Request = CreateObjectRequest; type StoredObject = StoredObject; async fn create(&self, request: &Self::Request) -> Result { self.put_create_only(request, bytes::Bytes::new()).await } } fn sha256_metadata(digest: &ContentSha256) -> HashMap { HashMap::from([( REMORA_SHA256_METADATA_KEY.to_owned(), digest.as_str().to_owned(), )]) } fn observed_sha256(metadata: &Metadata) -> Result, TargetError> { let Some(map) = metadata.user_metadata() else { return Ok(None); }; map.get(REMORA_SHA256_METADATA_KEY) .or_else(|| { map.iter() .find(|(key, _)| key.eq_ignore_ascii_case(REMORA_SHA256_METADATA_KEY)) .map(|(_, value)| value) }) .map(ContentSha256::parse) .transpose() } /// Finish helper re-exported for callers that hold a streaming body. pub async fn finish_create_only(body: OpenDalTargetBody) -> Result { body.finish_object().await }