Add exact-identity target storage adapter

This commit is contained in:
ms2k 2026-07-17 02:54:18 +09:00
commit 854e6e1c17
13 changed files with 3263 additions and 41 deletions

938
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -8,6 +8,7 @@ members = [
"crates/remora-durable", "crates/remora-durable",
"crates/remora-identity", "crates/remora-identity",
"crates/remora-stream", "crates/remora-stream",
"crates/remora-target",
"crates/remora-worker", "crates/remora-worker",
] ]
resolver = "3" resolver = "3"

View file

@ -0,0 +1,40 @@
[package]
name = "remora-target"
edition.workspace = true
publish = false
rust-version.workspace = true
version.workspace = true
[features]
default = []
# Fixture-only helpers (overwrite, version purge). Not for production callers.
test-utils = []
[dependencies]
aws-credential-types = "1.2.11"
aws-sigv4 = "1.3.7"
bytes = "1.11.1"
futures-util = "0.3.32"
http = "1.4.0"
opendal = { version = "0.58.0", default-features = false, features = [
"executors-tokio",
"http-transport-reqwest",
"http-transport-reqwest-rustls",
"services-s3",
] }
percent-encoding = "2.3.2"
quick-xml = { version = "0.38.4", features = ["serialize"] }
remora-domain.workspace = true
remora-stream = { path = "../remora-stream" }
reqwest = { version = "0.12.28", default-features = false, features = [
"rustls-tls-webpki-roots",
] }
serde = { workspace = true, features = ["derive"] }
sha2.workspace = true
thiserror.workspace = true
tokio = { workspace = true, features = ["macros", "sync", "time"] }
[dev-dependencies]
remora-target = { path = ".", features = ["test-utils"] }
sha2.workspace = true
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "sync", "time"] }

View file

@ -0,0 +1,92 @@
use opendal::Writer;
use remora_stream::{StreamError, TargetBody, TargetFinish};
use crate::digest::{ContentHasher, ContentSha256, check_expected};
use crate::error::{TargetError, map_opendal};
use crate::identity::ObjectIdentity;
/// Streaming create-only target body backed by an OpenDAL writer.
///
/// SHA-256 is accumulated over every written byte. The open writer cannot carry
/// final digest metadata (unknown until finish); the independently computed
/// digest is returned on finish for durable persistence.
pub struct OpenDalTargetBody {
writer: Writer,
hasher: ContentHasher,
bytes_written: u64,
identity: ObjectIdentity,
expected_sha256: Option<ContentSha256>,
}
impl OpenDalTargetBody {
pub(crate) fn new(
writer: Writer,
identity: ObjectIdentity,
expected_sha256: Option<ContentSha256>,
) -> Self {
Self {
writer,
hasher: ContentHasher::new(),
bytes_written: 0,
identity,
expected_sha256,
}
}
#[must_use]
pub fn identity(&self) -> &ObjectIdentity {
&self.identity
}
/// Finish the write and return identity plus independently computed digest.
pub async fn finish_object(mut self) -> Result<StoredWrite, TargetError> {
let metadata = self.writer.close().await.map_err(map_opendal)?;
let version_id = metadata.version().map(str::to_owned);
let content_sha256 = self.hasher.finalize();
check_expected(self.expected_sha256.as_ref(), &content_sha256)?;
let content_length = metadata.content_length();
let bytes_written = if content_length > 0 {
content_length
} else {
self.bytes_written
};
Ok(StoredWrite {
identity: self.identity.with_version(version_id),
bytes_written,
content_sha256,
})
}
}
impl TargetBody for OpenDalTargetBody {
async fn write_all(&mut self, data: &[u8]) -> Result<(), StreamError> {
if data.is_empty() {
return Ok(());
}
self.writer
.write(data.to_vec())
.await
.map_err(|error| map_opendal(error).into_stream_error())?;
self.hasher.update(data);
self.bytes_written = self
.bytes_written
.saturating_add(u64::try_from(data.len()).unwrap_or(u64::MAX));
Ok(())
}
async fn finish(self) -> Result<TargetFinish, StreamError> {
let stored = self.finish_object().await?;
Ok(TargetFinish {
bytes_written: stored.bytes_written,
})
}
}
/// Result of completing a create-only stream write.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct StoredWrite {
pub identity: ObjectIdentity,
pub bytes_written: u64,
/// Independently computed SHA-256 of written bytes.
pub content_sha256: ContentSha256,
}

View file

@ -0,0 +1,142 @@
use std::fmt;
use crate::error::TargetError;
/// Connection settings for one S3-compatible target store.
#[derive(Clone, Eq, PartialEq)]
pub struct TargetStoreConfig {
/// Stable target-store identity used in authorization and object identities.
pub target_store_id: String,
pub endpoint: String,
pub bucket: String,
pub region: String,
pub access_key_id: String,
pub secret_access_key: String,
/// Optional operator root under the bucket. Empty means bucket root.
pub root: String,
}
impl fmt::Debug for TargetStoreConfig {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("TargetStoreConfig")
.field("target_store_id", &self.target_store_id)
.field("endpoint", &self.endpoint)
.field("bucket", &self.bucket)
.field("region", &self.region)
.field("access_key_id", &self.access_key_id)
.field("secret_access_key", &"REDACTED")
.field("root", &self.root)
.finish()
}
}
impl TargetStoreConfig {
/// Load fixture or deployment settings from environment variables.
///
/// Recognized variables: `REMORA_TARGET_STORE_ID` (or `REMORA_TARGET_PROVIDER`),
/// `REMORA_TARGET_ENDPOINT`, `REMORA_TARGET_BUCKET`, `REMORA_TARGET_REGION`,
/// `REMORA_TARGET_ACCESS_KEY`, `REMORA_TARGET_SECRET_KEY`,
/// `REMORA_TARGET_EXCLUSIVE_WRITER`, and optional `REMORA_TARGET_ROOT`.
pub fn from_env() -> Result<Self, TargetError> {
let target_store_id = std::env::var("REMORA_TARGET_STORE_ID")
.ok()
.filter(|value| !value.is_empty())
.or_else(|| {
std::env::var("REMORA_TARGET_PROVIDER")
.ok()
.filter(|value| !value.is_empty())
})
.ok_or_else(|| {
TargetError::validation(
"REMORA_TARGET_STORE_ID or REMORA_TARGET_PROVIDER is required",
)
})?;
let endpoint = required_env("REMORA_TARGET_ENDPOINT")?;
let bucket = required_env("REMORA_TARGET_BUCKET")?;
let region = std::env::var("REMORA_TARGET_REGION")
.ok()
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "us-east-1".to_owned());
let access_key_id = required_env("REMORA_TARGET_ACCESS_KEY")?;
let secret_access_key = required_env("REMORA_TARGET_SECRET_KEY")?;
let root = std::env::var("REMORA_TARGET_ROOT")
.ok()
.filter(|value| !value.is_empty())
.unwrap_or_default();
let exclusive_writer = required_env("REMORA_TARGET_EXCLUSIVE_WRITER")?
.parse::<bool>()
.map_err(|_| {
TargetError::validation("REMORA_TARGET_EXCLUSIVE_WRITER must be true or false")
})?;
if !exclusive_writer {
return Err(TargetError::validation(
"target reserved namespace must have Remora as its exclusive writer",
));
}
Self::new_exclusive(
target_store_id,
endpoint,
bucket,
region,
access_key_id,
secret_access_key,
root,
)
}
pub fn new_exclusive(
target_store_id: impl Into<String>,
endpoint: impl Into<String>,
bucket: impl Into<String>,
region: impl Into<String>,
access_key_id: impl Into<String>,
secret_access_key: impl Into<String>,
root: impl Into<String>,
) -> Result<Self, TargetError> {
let target_store_id = target_store_id.into();
let endpoint = endpoint.into().trim_end_matches('/').to_owned();
let bucket = bucket.into();
let region = region.into();
let access_key_id = access_key_id.into();
let secret_access_key = secret_access_key.into();
let root = normalize_root(root.into());
if target_store_id.is_empty() {
return Err(TargetError::validation("target store id is required"));
}
if endpoint.is_empty() {
return Err(TargetError::validation("target endpoint is required"));
}
if bucket.is_empty() {
return Err(TargetError::validation("target bucket is required"));
}
if access_key_id.is_empty() || secret_access_key.is_empty() {
return Err(TargetError::validation("target credentials are required"));
}
Ok(Self {
target_store_id,
endpoint,
bucket,
region,
access_key_id,
secret_access_key,
root,
})
}
}
fn required_env(name: &str) -> Result<String, TargetError> {
std::env::var(name)
.ok()
.filter(|value| !value.is_empty())
.ok_or_else(|| TargetError::validation(format!("{name} is required")))
}
fn normalize_root(root: String) -> String {
let trimmed = root.trim_matches('/');
if trimmed.is_empty() {
String::new()
} else {
format!("/{trimmed}/")
}
}

View file

@ -0,0 +1,117 @@
use sha2::{Digest, Sha256};
use crate::error::TargetError;
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub struct ContentSha256(String);
impl ContentSha256 {
pub fn parse(value: impl AsRef<str>) -> Result<Self, TargetError> {
let value = value.as_ref().trim();
let hex = value
.strip_prefix("sha256:")
.or_else(|| value.strip_prefix("SHA256:"))
.unwrap_or(value);
if hex.len() != 64 || !hex.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return Err(TargetError::validation("invalid sha256 digest"));
}
Ok(Self(format!("sha256:{}", hex.to_ascii_lowercase())))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for ContentSha256 {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(formatter)
}
}
/// Format a raw SHA-256 digest as `sha256:` + lowercase hex.
#[must_use]
fn format_sha256(bytes: &[u8]) -> ContentSha256 {
let mut out = String::with_capacity(7 + bytes.len() * 2);
out.push_str("sha256:");
for byte in bytes {
use std::fmt::Write as _;
let _ = write!(out, "{byte:02x}");
}
ContentSha256(out)
}
/// Independently hash `data` and return the Remora digest string.
#[must_use]
pub fn hash_bytes(data: &[u8]) -> ContentSha256 {
format_sha256(Sha256::digest(data).as_ref())
}
/// Running SHA-256 over written bytes (never trusts a caller claim).
#[derive(Clone, Default)]
pub struct ContentHasher {
hasher: Sha256,
}
impl ContentHasher {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn update(&mut self, data: &[u8]) {
if data.is_empty() {
return;
}
self.hasher.update(data);
}
#[must_use]
pub fn finalize(self) -> ContentSha256 {
format_sha256(self.hasher.finalize().as_ref())
}
}
/// If the caller supplied an expected digest, require an exact match to the
/// independently computed value. Omitted expectation is allowed.
pub fn check_expected(
expected: Option<&ContentSha256>,
computed: &ContentSha256,
) -> Result<(), TargetError> {
let Some(expected) = expected else {
return Ok(());
};
if expected != computed {
return Err(TargetError::integrity(
"independently computed sha256 does not match caller expectation",
));
}
Ok(())
}
#[must_use]
pub fn sha256_equal(left: &str, right: &str) -> bool {
match (ContentSha256::parse(left), ContentSha256::parse(right)) {
(Ok(left), Ok(right)) => left == right,
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn digest_parsing_canonicalizes_and_rejects_invalid_values() {
let upper = ContentSha256::parse(
"SHA256:BA7816BF8F01CFEA414140DE5DAE2223B00361A396177A9CB410FF61F20015AD",
)
.expect("valid digest");
assert_eq!(
upper.as_str(),
"sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
assert!(ContentSha256::parse("sha256:not-a-digest").is_err());
}
}

View file

@ -0,0 +1,186 @@
use remora_stream::{StreamError, StreamSide};
/// Target storage adapter errors.
///
/// Diagnostic text is for operator logs only after secret scrubbing. Public
/// projections go through [`remora_stream::classify_stream_error`].
#[derive(Debug, thiserror::Error)]
pub enum TargetError {
#[error("target authorization failure")]
Authorization,
#[error("object already exists under create-only identity")]
AlreadyExists,
#[error("object not found")]
NotFound,
#[error("integrity failure: {0}")]
Integrity(String),
#[error("capacity exhausted on target")]
Capacity,
#[error("transient target failure: {0}")]
Transient(String),
#[error("validation failure: {0}")]
Validation(String),
#[error("internal target error: {0}")]
Internal(String),
}
impl TargetError {
pub fn validation(detail: impl Into<String>) -> Self {
Self::Validation(detail.into())
}
pub fn integrity(detail: impl Into<String>) -> Self {
Self::Integrity(detail.into())
}
pub fn transient(detail: impl Into<String>) -> Self {
Self::Transient(detail.into())
}
pub fn internal(detail: impl Into<String>) -> Self {
Self::Internal(detail.into())
}
/// Map to a stream error for copy-loop and adapter I/O boundaries.
#[must_use]
pub fn into_stream_error(self) -> StreamError {
match self {
Self::Authorization => StreamError::Authorization {
side: StreamSide::Target,
},
Self::AlreadyExists => StreamError::integrity("create-only identity already exists"),
Self::NotFound => StreamError::internal("target object not found"),
Self::Integrity(detail) => StreamError::Integrity { detail },
Self::Capacity => StreamError::Capacity {
side: StreamSide::Target,
},
Self::Transient(detail) => StreamError::Transient {
side: StreamSide::Target,
detail,
},
Self::Validation(detail) => StreamError::internal(detail),
Self::Internal(detail) => StreamError::Internal { detail },
}
}
}
impl From<TargetError> for StreamError {
fn from(value: TargetError) -> Self {
value.into_stream_error()
}
}
/// Map OpenDAL errors without embedding raw exception bodies in public paths.
pub(crate) fn map_opendal(error: opendal::Error) -> TargetError {
use opendal::ErrorKind;
let detail = redact_sensitive(error.to_string());
match error.kind() {
ErrorKind::NotFound => TargetError::NotFound,
ErrorKind::PermissionDenied => TargetError::Authorization,
ErrorKind::AlreadyExists | ErrorKind::ConditionNotMatch => TargetError::AlreadyExists,
ErrorKind::RateLimited => TargetError::Capacity,
ErrorKind::Unsupported | ErrorKind::ConfigInvalid => TargetError::Validation(detail),
ErrorKind::Unexpected
| ErrorKind::IsADirectory
| ErrorKind::NotADirectory
| ErrorKind::IsSameFile
| ErrorKind::RangeNotSatisfied => {
if is_likely_transient(&detail) {
TargetError::Transient(detail)
} else {
TargetError::Internal(detail)
}
}
_ => TargetError::Internal(detail),
}
}
pub(crate) fn map_http_status(status: u16, body: &str) -> TargetError {
let detail = redact_sensitive(body);
match status {
401 | 403 => TargetError::Authorization,
404 => TargetError::NotFound,
409 | 412 => TargetError::AlreadyExists,
429 | 503 => TargetError::Capacity,
500 | 502 | 504 => TargetError::Transient(format!("http {status}: {detail}")),
_ if status >= 500 => TargetError::Transient(format!("http {status}: {detail}")),
_ => TargetError::Internal(format!("http {status}: {detail}")),
}
}
/// Scrub credential-bearing fragments from operator-facing diagnostic text.
pub(crate) fn redact_sensitive(input: impl AsRef<str>) -> String {
let mut out = input.as_ref().to_owned();
const NEEDLES: &[&str] = &[
"Authorization:",
"authorization:",
"AWSAccessKeyId=",
"X-Amz-Credential=",
"X-Amz-Signature=",
"X-Amz-Security-Token=",
"x-amz-security-token=",
"Credential=",
"Signature=",
"SecretAccessKey",
"secret_access_key",
"secret-access-key",
"Bearer ",
];
while let Some((idx, needle)) = NEEDLES
.iter()
.filter_map(|needle| out.find(needle).map(|idx| (idx, *needle)))
.min_by_key(|(idx, _)| *idx)
{
let value_start = idx + needle.len();
// Authorization headers are multi-token; scrub through end of line.
let value_end = if needle.eq_ignore_ascii_case("Authorization:") {
out[value_start..]
.find(['\r', '\n'])
.map(|offset| value_start + offset)
.unwrap_or(out.len())
} else {
out[value_start..]
.find(|character: char| {
character.is_whitespace()
|| matches!(character, '"' | '\'' | ',' | ';' | '&' | '<' | '>')
})
.map(|offset| value_start + offset)
.unwrap_or(out.len())
};
out.replace_range(idx..value_end, "[redacted]");
}
if out.len() > 512 {
out.truncate(512);
out.push('…');
}
out
}
fn is_likely_transient(detail: &str) -> bool {
let lower = detail.to_ascii_lowercase();
lower.contains("timeout")
|| lower.contains("temporar")
|| lower.contains("connection")
|| lower.contains("reset")
|| lower.contains("unavailable")
|| lower.contains("slow down")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn redact_sensitive_scrubs_long_and_repeated_secrets() {
let long_sig = "a".repeat(80);
let input = format!(
"X-Amz-Signature={long_sig}&X-Amz-Credential=AKIAEXAMPLE/path Authorization: AWS4-HMAC-SHA256 Credential=AKIAEXAMPLE, SignedHeaders=host, Signature={long_sig} Bearer super-secret-token-value-here"
);
let redacted = redact_sensitive(input);
assert!(!redacted.contains("AKIAEXAMPLE"));
assert!(!redacted.contains(&long_sig));
assert!(!redacted.contains("super-secret-token-value-here"));
assert!(redacted.contains("[redacted]"));
}
}

View file

@ -0,0 +1,137 @@
use remora_domain::OpaqueId;
use crate::error::TargetError;
/// Deployment-reserved target namespace segment.
///
/// User target prefixes must not contain this segment. Attempt objects and
/// manifests are written only under this service namespace.
pub const RESERVED_NAMESPACE_SEGMENT: &str = "_remora";
/// User-metadata key for Remora-computed SHA-256 digests.
pub const REMORA_SHA256_METADATA_KEY: &str = "remora-sha256";
/// Exact object identity retained for reads, deletes, and time-limited access.
///
/// Bound to a target-store identity so identities cannot be applied across stores.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ObjectIdentity {
pub target_store_id: String,
pub bucket: String,
pub key: String,
pub version_id: Option<String>,
}
impl ObjectIdentity {
#[must_use]
pub fn with_version(mut self, version_id: Option<String>) -> Self {
self.version_id = version_id.filter(|value| !value.is_empty());
self
}
}
/// Exact multipart identity retained for destination resume and cleanup.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MultipartIdentity {
pub target_store_id: String,
pub bucket: String,
pub key: String,
pub upload_id: String,
}
/// One completed multipart part as re-listed from the target store.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CompletedPart {
pub part_number: u32,
pub etag: String,
pub size: Option<u64>,
}
/// Construct a create-only attempt object key under the reserved namespace.
///
/// Layout: `_remora/a/{attempt_id}/o/{object_id}[/{relative_path}]`
#[must_use]
pub fn attempt_object_key(
attempt_id: &OpaqueId,
object_id: &OpaqueId,
relative_path: Option<&str>,
) -> String {
let mut key = format!(
"{RESERVED_NAMESPACE_SEGMENT}/a/{}/o/{}",
attempt_id.as_str(),
object_id.as_str()
);
if let Some(path) = relative_path.filter(|value| !value.is_empty()) {
let trimmed = path.trim_matches('/');
if !trimmed.is_empty() {
key.push('/');
key.push_str(trimmed);
}
}
key
}
/// Construct a reserved-namespace key for a manifest descriptor object.
#[must_use]
pub fn attempt_manifest_key(attempt_id: &OpaqueId, manifest_id: &OpaqueId) -> String {
format!(
"{RESERVED_NAMESPACE_SEGMENT}/a/{}/m/{}",
attempt_id.as_str(),
manifest_id.as_str()
)
}
/// Return true when `key` is under the reserved service namespace.
#[must_use]
pub fn is_reserved_key(key: &str) -> bool {
let trimmed = key.trim_start_matches('/');
trimmed == RESERVED_NAMESPACE_SEGMENT
|| trimmed.starts_with(&format!("{RESERVED_NAMESPACE_SEGMENT}/"))
}
/// Validate a reserved attempt object or manifest identity layout.
pub fn validate_attempt_key(key: &str) -> Result<(), TargetError> {
if key.is_empty() || key.starts_with('/') || key.contains("//") {
return Err(TargetError::validation("invalid object key"));
}
if !is_reserved_key(key) {
return Err(TargetError::validation(
"object key must be under the reserved service namespace",
));
}
let segments = key.split('/').collect::<Vec<_>>();
if segments.len() < 5
|| segments[0] != RESERVED_NAMESPACE_SEGMENT
|| segments[1] != "a"
|| OpaqueId::parse(segments[2]).is_err()
|| OpaqueId::parse(segments[4]).is_err()
|| !matches!(segments[3], "o" | "m")
|| (segments[3] == "m" && segments.len() != 5)
{
return Err(TargetError::validation(
"object key must contain attempt and object identities",
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn attempt_keys_are_reserved_and_unique() {
let attempt = OpaqueId::parse("attempt01aaaaaaaa").unwrap();
let object = OpaqueId::parse("object01aaaaaaaaa").unwrap();
let key = attempt_object_key(&attempt, &object, Some("한글/é/member"));
assert!(is_reserved_key(&key));
assert!(validate_attempt_key(&key).is_ok());
assert!(key.contains("한글"));
assert_eq!(
attempt_manifest_key(&attempt, &object),
"_remora/a/attempt01aaaaaaaa/m/object01aaaaaaaaa"
);
assert!(validate_attempt_key("public/data").is_err());
assert!(validate_attempt_key("_remora/arbitrary").is_err());
}
}

View file

@ -0,0 +1,27 @@
//! Target storage adapter (WP-08).
//!
//! OpenDAL-backed create-only attempt objects, multipart recovery, exact
//! identity reads and deletes, independent SHA-256, version retention, and
//! exact time-limited access against S3-compatible target stores.
mod body;
mod config;
mod digest;
mod error;
mod identity;
mod multipart;
mod store;
pub use body::{OpenDalTargetBody, StoredWrite};
pub use config::TargetStoreConfig;
pub use digest::{ContentSha256, hash_bytes, sha256_equal};
pub use error::TargetError;
pub use identity::{
CompletedPart, MultipartIdentity, ObjectIdentity, REMORA_SHA256_METADATA_KEY,
RESERVED_NAMESPACE_SEGMENT, attempt_manifest_key, attempt_object_key, is_reserved_key,
validate_attempt_key,
};
pub use store::{
CreateObjectRequest, MultipartSession, ObjectMetadata, StoredObject, TargetStore,
TimeLimitedAccess, finish_create_only,
};

View file

@ -0,0 +1,579 @@
use std::fmt;
use std::time::{Duration, SystemTime};
use aws_credential_types::Credentials;
use aws_sigv4::http_request::{
PayloadChecksumKind, SignableBody, SignableRequest, SigningSettings, UriPathNormalizationMode,
sign,
};
use aws_sigv4::sign::v4;
use bytes::Bytes;
use http::header::{CONTENT_LENGTH, CONTENT_TYPE, HeaderName, HeaderValue};
use http::{Method, Request};
use percent_encoding::{AsciiSet, CONTROLS, utf8_percent_encode};
use quick_xml::de::from_str;
use quick_xml::se::to_string;
use serde::{Deserialize, Serialize};
use crate::config::TargetStoreConfig;
use crate::error::{TargetError, map_http_status, redact_sensitive};
use crate::identity::{CompletedPart, MultipartIdentity, REMORA_SHA256_METADATA_KEY};
/// Encode path segments for path-style S3 URLs.
const PATH_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'}');
/// One version or delete marker listed under a key/prefix.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ObjectVersion {
pub key: String,
pub version_id: String,
pub is_delete_marker: bool,
}
/// Explicit multipart control plane for destination recovery.
///
/// OpenDAL streams multipart writes but does not expose list-parts recovery.
/// This client issues path-style S3 multipart APIs with the same credentials
/// as the OpenDAL operator so exact upload IDs and re-listed parts are durable.
#[derive(Clone)]
pub struct MultipartClient {
config: TargetStoreConfig,
http: reqwest::Client,
}
impl fmt::Debug for MultipartClient {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("MultipartClient")
.field("config", &self.config)
.finish_non_exhaustive()
}
}
impl MultipartClient {
pub fn new(config: TargetStoreConfig) -> Result<Self, TargetError> {
let http = reqwest::Client::builder()
.timeout(Duration::from_secs(120))
.build()
.map_err(|error| TargetError::internal(redact_sensitive(error.to_string())))?;
Ok(Self { config, http })
}
/// Enable bucket versioning when the peer supports it.
///
/// Peers that return NotImplemented are reported as `false` without error.
pub async fn enable_bucket_versioning(&self) -> Result<bool, TargetError> {
let body = r#"<?xml version="1.0" encoding="UTF-8"?>
<VersioningConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Status>Enabled</Status>
</VersioningConfiguration>"#;
let uri = format!(
"{}/{}/?versioning",
self.config.endpoint, self.config.bucket
);
let request = Request::builder()
.method(Method::PUT)
.uri(uri)
.header(CONTENT_TYPE, "application/xml")
.header(CONTENT_LENGTH, body.len().to_string())
.body(Bytes::from_static(body.as_bytes()))
.map_err(|error| TargetError::internal(error.to_string()))?;
let response = self.send(request).await?;
let status = response.status().as_u16();
if status / 100 == 2 {
return Ok(true);
}
let text = response.text().await.unwrap_or_default();
if status == 501
|| text.contains("NotImplemented")
|| text.contains("XNotImplemented")
|| text.contains("NotSupported")
{
return Ok(false);
}
Err(map_http_status(status, &text))
}
/// Start a multipart upload. `content_sha256` is optional S3 user metadata
/// for an independently computed digest only.
pub async fn initiate(
&self,
key: &str,
content_sha256: Option<&str>,
) -> Result<MultipartIdentity, TargetError> {
let path = object_path(&self.config, key);
let mut builder = Request::builder()
.method(Method::POST)
.uri(format!("{}{}?uploads", self.config.endpoint, path));
if let Some(digest) = content_sha256 {
let header = format!("x-amz-meta-{REMORA_SHA256_METADATA_KEY}");
builder = builder.header(
HeaderName::from_bytes(header.as_bytes())
.map_err(|_| TargetError::internal("invalid metadata header name"))?,
HeaderValue::from_str(digest)
.map_err(|_| TargetError::validation("invalid sha256 metadata"))?,
);
}
let request = builder
.header(CONTENT_LENGTH, "0")
.body(Bytes::new())
.map_err(|error| TargetError::internal(error.to_string()))?;
let response = self.send(request).await?;
let status = response.status().as_u16();
let body = response
.text()
.await
.map_err(|error| TargetError::transient(redact_sensitive(error.to_string())))?;
if status / 100 != 2 {
return Err(map_http_status(status, &body));
}
let parsed: InitiateMultipartUploadResult = from_str(&body)
.map_err(|error| TargetError::internal(redact_sensitive(error.to_string())))?;
if parsed.upload_id.is_empty() {
return Err(TargetError::internal(
"multipart initiate missing upload id",
));
}
Ok(MultipartIdentity {
target_store_id: self.config.target_store_id.clone(),
bucket: self.config.bucket.clone(),
key: key.to_owned(),
upload_id: parsed.upload_id,
})
}
pub async fn upload_part(
&self,
identity: &MultipartIdentity,
part_number: u32,
body: bytes::Bytes,
) -> Result<CompletedPart, TargetError> {
if part_number == 0 {
return Err(TargetError::validation("part number must be >= 1"));
}
let path = object_path(&self.config, &identity.key);
let uri = format!(
"{}{}?partNumber={part_number}&uploadId={}",
self.config.endpoint,
path,
encode_query(&identity.upload_id)
);
let len = body.len();
let request = Request::builder()
.method(Method::PUT)
.uri(uri)
.header(CONTENT_LENGTH, len.to_string())
.body(body)
.map_err(|error| TargetError::internal(error.to_string()))?;
let response = self.send(request).await?;
let status = response.status().as_u16();
if status / 100 != 2 {
let body = response.text().await.unwrap_or_default();
return Err(map_http_status(status, &body));
}
let etag = response
.headers()
.get("etag")
.and_then(|value| value.to_str().ok())
.map(|value| value.trim_matches('"').to_owned())
.ok_or_else(|| TargetError::internal("multipart part missing etag"))?;
Ok(CompletedPart {
part_number,
etag,
size: Some(len as u64),
})
}
pub async fn list_parts(
&self,
identity: &MultipartIdentity,
) -> Result<Vec<CompletedPart>, TargetError> {
let mut parts = Vec::new();
let mut part_number_marker: Option<u32> = None;
loop {
let path = object_path(&self.config, &identity.key);
let mut uri = format!(
"{}{}?uploadId={}",
self.config.endpoint,
path,
encode_query(&identity.upload_id)
);
if let Some(marker) = part_number_marker {
uri.push_str(&format!("&part-number-marker={marker}"));
}
let request = Request::builder()
.method(Method::GET)
.uri(uri)
.body(Bytes::new())
.map_err(|error| TargetError::internal(error.to_string()))?;
let response = self.send(request).await?;
let status = response.status().as_u16();
let body = response
.text()
.await
.map_err(|error| TargetError::transient(redact_sensitive(error.to_string())))?;
if status / 100 != 2 {
return Err(map_http_status(status, &body));
}
let parsed: ListPartsResult = from_str(&body)
.map_err(|error| TargetError::internal(redact_sensitive(error.to_string())))?;
for part in parsed.parts {
parts.push(CompletedPart {
part_number: part.part_number,
etag: part.etag.trim_matches('"').to_owned(),
size: Some(part.size),
});
}
if parsed.is_truncated {
part_number_marker = parsed
.next_part_number_marker
.or_else(|| parts.last().map(|part| part.part_number));
if part_number_marker.is_none() {
break;
}
} else {
break;
}
}
parts.sort_by_key(|part| part.part_number);
Ok(parts)
}
pub async fn complete(
&self,
identity: &MultipartIdentity,
parts: &[CompletedPart],
if_not_exists: bool,
) -> Result<Option<String>, TargetError> {
if parts.is_empty() {
return Err(TargetError::validation(
"multipart complete requires at least one part",
));
}
let payload = CompleteMultipartUpload {
parts: parts
.iter()
.map(|part| CompletePart {
part_number: part.part_number,
etag: format!("\"{}\"", part.etag.trim_matches('"')),
})
.collect(),
};
let xml = to_string(&payload)
.map_err(|error| TargetError::internal(redact_sensitive(error.to_string())))?;
let path = object_path(&self.config, &identity.key);
let uri = format!(
"{}{}?uploadId={}",
self.config.endpoint,
path,
encode_query(&identity.upload_id)
);
let mut builder = Request::builder()
.method(Method::POST)
.uri(uri)
.header(CONTENT_TYPE, "application/xml")
.header(CONTENT_LENGTH, xml.len().to_string());
if if_not_exists {
builder = builder.header("If-None-Match", "*");
}
let request = builder
.body(Bytes::from(xml))
.map_err(|error| TargetError::internal(error.to_string()))?;
let response = self.send(request).await?;
let status = response.status().as_u16();
let version_id = response
.headers()
.get("x-amz-version-id")
.and_then(|value| value.to_str().ok())
.map(str::to_owned);
let body = response
.text()
.await
.map_err(|error| TargetError::transient(redact_sensitive(error.to_string())))?;
if status / 100 != 2 {
return Err(map_http_status(status, &body));
}
Ok(version_id.filter(|value| !value.is_empty()))
}
pub async fn abort(&self, identity: &MultipartIdentity) -> Result<(), TargetError> {
let path = object_path(&self.config, &identity.key);
let uri = format!(
"{}{}?uploadId={}",
self.config.endpoint,
path,
encode_query(&identity.upload_id)
);
let request = Request::builder()
.method(Method::DELETE)
.uri(uri)
.body(Bytes::new())
.map_err(|error| TargetError::internal(error.to_string()))?;
let response = self.send(request).await?;
let status = response.status().as_u16();
if status == 404 {
return Ok(());
}
if status / 100 != 2 {
let body = response.text().await.unwrap_or_default();
return Err(map_http_status(status, &body));
}
Ok(())
}
/// List object versions and delete markers under an optional key prefix.
pub async fn list_object_versions(
&self,
prefix: &str,
) -> Result<Vec<ObjectVersion>, TargetError> {
let mut versions = Vec::new();
let mut key_marker = String::new();
let mut version_id_marker = String::new();
loop {
let mut uri = format!("{}/{}/?versions", self.config.endpoint, self.config.bucket);
if !prefix.is_empty() {
uri.push_str(&format!("&prefix={}", encode_query(prefix)));
}
if !key_marker.is_empty() {
uri.push_str(&format!("&key-marker={}", encode_query(&key_marker)));
}
if !version_id_marker.is_empty() {
uri.push_str(&format!(
"&version-id-marker={}",
encode_query(&version_id_marker)
));
}
let request = Request::builder()
.method(Method::GET)
.uri(uri)
.body(Bytes::new())
.map_err(|error| TargetError::internal(error.to_string()))?;
let response = self.send(request).await?;
let status = response.status().as_u16();
let body = response
.text()
.await
.map_err(|error| TargetError::transient(redact_sensitive(error.to_string())))?;
if status / 100 != 2 {
return Err(map_http_status(status, &body));
}
let parsed: ListVersionsResult = from_str(&body)
.map_err(|error| TargetError::internal(redact_sensitive(error.to_string())))?;
for version in parsed.versions {
if !version.version_id.is_empty() {
versions.push(ObjectVersion {
key: version.key,
version_id: version.version_id,
is_delete_marker: false,
});
}
}
for marker in parsed.delete_markers {
if !marker.version_id.is_empty() {
versions.push(ObjectVersion {
key: marker.key,
version_id: marker.version_id,
is_delete_marker: true,
});
}
}
if parsed.is_truncated {
key_marker = parsed.next_key_marker.unwrap_or_default();
version_id_marker = parsed.next_version_id_marker.unwrap_or_default();
if key_marker.is_empty() && version_id_marker.is_empty() {
break;
}
} else {
break;
}
}
Ok(versions)
}
/// Delete one exact object version or delete marker.
pub async fn delete_object_version(
&self,
key: &str,
version_id: &str,
) -> Result<(), TargetError> {
let path = object_path(&self.config, key);
let uri = format!(
"{}{}?versionId={}",
self.config.endpoint,
path,
encode_query(version_id)
);
let request = Request::builder()
.method(Method::DELETE)
.uri(uri)
.body(Bytes::new())
.map_err(|error| TargetError::internal(error.to_string()))?;
let response = self.send(request).await?;
let status = response.status().as_u16();
if status == 404 || status / 100 == 2 {
return Ok(());
}
let body = response.text().await.unwrap_or_default();
Err(map_http_status(status, &body))
}
async fn send(&self, request: Request<Bytes>) -> Result<reqwest::Response, TargetError> {
let (parts, body) = sign_request(&self.config, request)?.into_parts();
self.http
.request(parts.method, parts.uri.to_string())
.headers(parts.headers)
.body(body)
.send()
.await
.map_err(|error| TargetError::transient(redact_sensitive(error.to_string())))
}
}
fn object_path(config: &TargetStoreConfig, key: &str) -> String {
let key = key.trim_start_matches('/');
let rooted = if config.root.is_empty() {
key.to_owned()
} else {
let root = config.root.trim_matches('/');
if root.is_empty() {
key.to_owned()
} else {
format!("{root}/{key}")
}
};
let encoded = rooted
.split('/')
.map(|segment| utf8_percent_encode(segment, PATH_SET).to_string())
.collect::<Vec<_>>()
.join("/");
format!("/{}/{}", config.bucket, encoded)
}
fn encode_query(value: &str) -> String {
utf8_percent_encode(value, PATH_SET).to_string()
}
fn sign_request(
config: &TargetStoreConfig,
mut request: Request<Bytes>,
) -> Result<Request<Bytes>, TargetError> {
let identity = Credentials::new(
&config.access_key_id,
&config.secret_access_key,
None,
None,
"remora-target",
)
.into();
let mut settings = SigningSettings::default();
// S3-compatible stores (including Garage) require x-amz-content-sha256 and
// reject URI path normalization in the canonical request.
settings.payload_checksum_kind = PayloadChecksumKind::XAmzSha256;
settings.uri_path_normalization_mode = UriPathNormalizationMode::Disabled;
let signing_params = v4::SigningParams::builder()
.identity(&identity)
.region(&config.region)
.name("s3")
.time(SystemTime::now())
.settings(settings)
.build()
.map_err(|error| TargetError::internal(redact_sensitive(error.to_string())))?
.into();
let signable = SignableRequest::new(
request.method().as_str(),
request.uri().to_string(),
request
.headers()
.iter()
.filter_map(|(name, value)| value.to_str().ok().map(|value| (name.as_str(), value))),
SignableBody::Bytes(request.body().as_ref()),
)
.map_err(|error| TargetError::internal(redact_sensitive(error.to_string())))?;
let (instructions, _signature) = sign(signable, &signing_params)
.map_err(|error| TargetError::internal(redact_sensitive(error.to_string())))?
.into_parts();
instructions.apply_to_request_http1x(&mut request);
Ok(request)
}
#[derive(Debug, Deserialize)]
struct InitiateMultipartUploadResult {
#[serde(rename = "UploadId", default)]
upload_id: String,
}
#[derive(Debug, Deserialize)]
struct ListPartsResult {
#[serde(rename = "Part", default)]
parts: Vec<ListPart>,
#[serde(rename = "IsTruncated", default)]
is_truncated: bool,
#[serde(rename = "NextPartNumberMarker", default)]
next_part_number_marker: Option<u32>,
}
#[derive(Debug, Deserialize)]
struct ListPart {
#[serde(rename = "PartNumber")]
part_number: u32,
#[serde(rename = "ETag")]
etag: String,
#[serde(rename = "Size", default)]
size: u64,
}
#[derive(Debug, Serialize)]
#[serde(rename = "CompleteMultipartUpload")]
struct CompleteMultipartUpload {
#[serde(rename = "Part")]
parts: Vec<CompletePart>,
}
#[derive(Debug, Serialize)]
struct CompletePart {
#[serde(rename = "PartNumber")]
part_number: u32,
#[serde(rename = "ETag")]
etag: String,
}
#[derive(Debug, Deserialize)]
struct ListVersionsResult {
#[serde(rename = "Version", default)]
versions: Vec<ListedVersion>,
#[serde(rename = "DeleteMarker", default)]
delete_markers: Vec<ListedVersion>,
#[serde(rename = "IsTruncated", default)]
is_truncated: bool,
#[serde(rename = "NextKeyMarker", default)]
next_key_marker: Option<String>,
#[serde(rename = "NextVersionIdMarker", default)]
next_version_id_marker: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ListedVersion {
#[serde(rename = "Key", default)]
key: String,
#[serde(rename = "VersionId", default)]
version_id: String,
}

View file

@ -0,0 +1,548 @@
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<ContentSha256>,
chunk_size: Option<NonZeroUsize>,
}
impl CreateObjectRequest {
pub fn parse(key: impl Into<String>) -> Result<Self, TargetError> {
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<ContentSha256>,
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<ContentSha256>,
}
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<Self, TargetError> {
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, TargetError> {
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<OpenDalTargetBody, TargetError> {
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<bytes::Bytes>,
) -> Result<StoredObject, TargetError> {
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<bytes::Bytes, TargetError> {
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<ObjectMetadata, TargetError> {
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<TimeLimitedAccess, TargetError> {
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<MultipartSession, TargetError> {
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<ContentSha256>,
) -> Result<(MultipartSession, Vec<CompletedPart>), 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<CompletedPart, TargetError> {
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<Vec<CompletedPart>, 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<StoredObject, TargetError> {
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<Writer, TargetError> {
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<String>) -> 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<bytes::Bytes>,
) -> Result<StoredObject, TargetError> {
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<bool, TargetError> {
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<usize, TargetError> {
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::StoredObject, Self::Error> {
self.put_create_only(request, bytes::Bytes::new()).await
}
}
fn sha256_metadata(digest: &ContentSha256) -> HashMap<String, String> {
HashMap::from([(
REMORA_SHA256_METADATA_KEY.to_owned(),
digest.as_str().to_owned(),
)])
}
fn observed_sha256(metadata: &Metadata) -> Result<Option<ContentSha256>, 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<StoredWrite, TargetError> {
body.finish_object().await
}

View file

@ -0,0 +1,19 @@
use remora_target::TargetStoreConfig;
#[test]
fn target_store_config_debug_redacts_secret() {
let config = TargetStoreConfig::new_exclusive(
"garage",
"http://garage:3900",
"remora-lane-001",
"garage",
"synthetic-access-key",
"synthetic-secret-key-must-not-leak",
"",
)
.expect("config");
let debug = format!("{config:?}");
assert!(!debug.contains("synthetic-secret-key-must-not-leak"));
assert!(debug.contains("REDACTED"));
assert!(debug.contains("synthetic-access-key"));
}

View file

@ -0,0 +1,436 @@
use std::num::NonZeroUsize;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use remora_domain::OpaqueId;
use remora_stream::TargetBody;
use remora_target::{
CreateObjectRequest, ObjectIdentity, RESERVED_NAMESPACE_SEGMENT, TargetError, TargetStore,
TargetStoreConfig, attempt_object_key, hash_bytes, is_reserved_key, validate_attempt_key,
};
fn require_env() -> bool {
std::env::var("REMORA_TARGET_REQUIRE").is_ok()
}
fn store_from_env() -> Option<TargetStore> {
match TargetStoreConfig::from_env() {
Ok(config) => Some(TargetStore::connect(config).expect("connect target")),
Err(error) => {
if require_env() {
panic!("target env required when REMORA_TARGET_REQUIRE is set: {error}");
}
eprintln!("skipping target test: {error}");
None
}
}
}
fn unique_id(prefix: &str) -> OpaqueId {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time")
.as_nanos();
let value = format!("{prefix}{nanos:x}");
OpaqueId::parse(&value).unwrap_or_else(|_| {
OpaqueId::parse(format!("{prefix}aaaaaaaa")).expect("fallback opaque id")
})
}
fn attempt_key(label: &str) -> String {
attempt_object_key(
&unique_id(&format!("att{label}")),
&unique_id(&format!("obj{label}")),
None,
)
}
fn create_request(key: impl Into<String>) -> CreateObjectRequest {
CreateObjectRequest::parse(key).expect("valid create-only request")
}
#[tokio::test]
async fn create_only_independently_hashes_and_rejects_wrong_expectation() {
let Some(store) = store_from_env() else {
return;
};
let body = b"immutable-member-v1".as_slice();
let digest = hash_bytes(body);
let key = attempt_key("csha");
assert!(is_reserved_key(&key));
assert!(validate_attempt_key(&key).is_ok());
assert!(validate_attempt_key("not-reserved/key").is_err());
let wrong = store
.put_create_only(
&create_request(key.clone()).with_expected_content_sha256(hash_bytes(b"other-payload")),
body,
)
.await;
assert!(
matches!(wrong, Err(TargetError::Integrity(_))),
"wrong expectation must fail: {wrong:?}"
);
let stored = store
.put_create_only(
&create_request(key.clone()).with_expected_content_sha256(digest.clone()),
body,
)
.await
.expect("create-only put");
assert_eq!(stored.identity.key, key);
assert_eq!(
stored.identity.target_store_id,
store.config().target_store_id
);
assert_eq!(stored.content_sha256, digest);
assert_eq!(
store.read_exact(&stored.identity).await.expect("read"),
body
);
let head = store.head_exact(&stored.identity).await.expect("head");
assert_eq!(head.content_sha256, Some(digest));
let conflict = store
.put_create_only(&create_request(key.clone()), b"replacement".as_slice())
.await;
assert!(
matches!(conflict, Err(TargetError::AlreadyExists)),
"expected create-only conflict, got {conflict:?}"
);
// Cross-store identity must fail closed.
let mut foreign = stored.identity.clone();
foreign.target_store_id = "other-store".into();
let cross = store.read_exact(&foreign).await;
assert!(
matches!(cross, Err(TargetError::Validation(_))),
"cross-store identity: {cross:?}"
);
}
#[tokio::test]
async fn streaming_create_only_computes_digest() {
let Some(store) = store_from_env() else {
return;
};
let payload = vec![0x5Au8; 256 * 1024];
let expected = hash_bytes(&payload);
let key = attempt_key("strm");
let mut body = store
.open_create_only(
&create_request(key.clone())
.with_expected_content_sha256(expected.clone())
.with_chunk_size(NonZeroUsize::new(64 * 1024).expect("non-zero chunk size")),
)
.await
.expect("open writer");
body.write_all(&payload).await.expect("write");
let stored = body.finish_object().await.expect("finish");
assert_eq!(stored.bytes_written, payload.len() as u64);
assert_eq!(stored.content_sha256, expected);
assert_eq!(stored.identity.key, key);
assert_eq!(
store.read_exact(&stored.identity).await.expect("read"),
payload
);
}
#[tokio::test]
async fn multipart_relist_complete_abort_and_create_only() {
let Some(store) = store_from_env() else {
return;
};
let replaced_part_one = vec![b'x'; 5 * 1024 * 1024];
let part_one = vec![b'a'; 5 * 1024 * 1024];
let part_two = vec![b'b'; 1024];
let combined = [part_one.as_slice(), part_two.as_slice()].concat();
let digest = hash_bytes(&combined);
let key = attempt_key("mpu");
let multipart_request =
create_request(key.clone()).with_expected_content_sha256(digest.clone());
let session = store
.begin_multipart(&multipart_request)
.await
.expect("begin");
let p2 = store
.upload_part(&session, 2, part_two.clone().into())
.await
.expect("part 2");
let replaced_p1 = store
.upload_part(&session, 1, replaced_part_one.into())
.await
.expect("original part 1");
let stale = store.list_parts(&session).await.expect("stale part list");
let p1 = store
.upload_part(&session, 1, part_one.clone().into())
.await
.expect("replacement part 1");
let durable_identity = session.identity().clone();
drop(session);
let (session, listed) = store
.resume_multipart(durable_identity.clone(), Some(digest.clone()))
.await
.expect("resume and re-list");
assert_eq!(listed.len(), 2);
assert_eq!(listed[0].etag, p1.etag);
assert_eq!(listed[1].etag, p2.etag);
assert_ne!(listed[0].etag, replaced_p1.etag);
let stale_completion = store.complete_multipart(session, &stale).await;
assert!(
matches!(stale_completion, Err(TargetError::Integrity(_))),
"completion must reject a replaced part: {stale_completion:?}"
);
let (session, listed) = store
.resume_multipart(durable_identity, Some(digest.clone()))
.await
.expect("resume after stale completion");
let completed = store
.complete_multipart(session, &listed)
.await
.expect("complete");
assert_eq!(completed.identity.key, key);
assert_eq!(completed.content_sha256, digest);
assert_eq!(
store
.head_exact(&completed.identity)
.await
.expect("head multipart")
.content_sha256,
None
);
let read = store
.read_exact(&completed.identity)
.await
.expect("read completed");
assert_eq!(read.as_ref(), combined.as_slice());
let occupied = store.begin_multipart(&multipart_request).await;
assert!(
matches!(occupied, Err(TargetError::AlreadyExists)),
"multipart begin on occupied key: {occupied:?}"
);
let abort_key = attempt_key("mpa");
let abort_request = create_request(abort_key);
let abort_session = store
.begin_multipart(&abort_request)
.await
.expect("begin abort");
store
.upload_part(&abort_session, 1, part_one.into())
.await
.expect("abort part");
store.abort_multipart(abort_session).await.expect("abort");
}
#[tokio::test]
async fn unicode_key_exact_delete() {
let Some(store) = store_from_env() else {
return;
};
let key = attempt_object_key(
&unique_id("attuni"),
&unique_id("objuni"),
Some("한글/é/member"),
);
let body = "정규화".as_bytes();
let stored = store
.put_create_only(&create_request(key.clone()), body)
.await
.expect("unicode put");
assert_eq!(stored.content_sha256, hash_bytes(body));
store
.delete_exact(&stored.identity)
.await
.expect("exact delete");
let missing = store.read_exact(&stored.identity).await;
assert!(
matches!(missing, Err(TargetError::NotFound)),
"expected not found after delete, got {missing:?}"
);
}
#[tokio::test]
async fn time_limited_exact_access_rejects_tamper_and_expiry() {
let Some(store) = store_from_env() else {
return;
};
let body = b"presign-body";
let key = attempt_key("prs");
let sibling_key = attempt_key("sib");
let stored = store
.put_create_only(&create_request(key.clone()), body.as_slice())
.await
.expect("put");
store
.put_create_only(&create_request(sibling_key.clone()), b"sibling".as_slice())
.await
.expect("sibling put");
let access = store
.presign_read(&stored.identity, Duration::from_secs(2))
.await
.expect("presign");
let client = reqwest::Client::new();
let ok = client
.get(&access.url)
.send()
.await
.expect("presign get")
.error_for_status()
.expect("presign status")
.bytes()
.await
.expect("presign body");
assert_eq!(ok.as_ref(), body);
let tampered = access.url.replacen(&key, &sibling_key, 1);
assert_ne!(tampered, access.url);
let tamper_status = client
.get(&tampered)
.send()
.await
.expect("tampered get")
.status()
.as_u16();
assert!(
matches!(tamper_status, 400 | 403),
"tampered URL must not authorize sibling, got {tamper_status}"
);
tokio::time::sleep(Duration::from_secs(3)).await;
let expired_status = client
.get(&access.url)
.send()
.await
.expect("expired get")
.status()
.as_u16();
assert!(
matches!(expired_status, 400 | 403),
"expired URL must fail, got {expired_status}"
);
}
#[tokio::test]
async fn version_addressed_and_stale_version_matrix() {
let Some(store) = store_from_env() else {
return;
};
if !store
.enable_bucket_versioning()
.await
.expect("enable versioning")
{
if require_env() {
let provider =
std::env::var("REMORA_TARGET_PROVIDER").unwrap_or_else(|_| "unknown".into());
if provider == "versitygw" {
panic!("versitygw must support bucket versioning for WP-08 matrix");
}
}
eprintln!(
"target provider {} cannot enable versioning; skipping version matrix",
std::env::var("REMORA_TARGET_PROVIDER").unwrap_or_else(|_| "unknown".into())
);
return;
}
let key = attempt_key("ver");
let first = store
.put_create_only(&create_request(key.clone()), b"one".as_slice())
.await
.expect("first version");
let first_version = first
.identity
.version_id
.clone()
.expect("version id after enable");
let second = store
.put_overwrite(&create_request(key.clone()), b"two".as_slice())
.await
.expect("second version");
let second_version = second
.identity
.version_id
.clone()
.expect("second version id");
assert_ne!(first_version, second_version);
let first_identity = ObjectIdentity {
target_store_id: store.config().target_store_id.clone(),
bucket: store.config().bucket.clone(),
key: key.clone(),
version_id: Some(first_version),
};
let second_identity = ObjectIdentity {
target_store_id: store.config().target_store_id.clone(),
bucket: store.config().bucket.clone(),
key: key.clone(),
version_id: Some(second_version),
};
assert_eq!(
store.read_exact(&first_identity).await.expect("v1"),
b"one".as_slice()
);
assert_eq!(
store.read_exact(&second_identity).await.expect("v2"),
b"two".as_slice()
);
let versioned_access = store
.presign_read(&first_identity, Duration::from_secs(60))
.await
.expect("versioned presign");
let client = reqwest::Client::new();
let body = client
.get(&versioned_access.url)
.send()
.await
.expect("versioned get")
.bytes()
.await
.expect("versioned body");
assert_eq!(body.as_ref(), b"one");
store
.delete_exact(&first_identity)
.await
.expect("delete v1");
assert!(matches!(
store.read_exact(&first_identity).await,
Err(TargetError::NotFound)
));
let stale_key = attempt_key("stv");
let keep = store
.put_create_only(&create_request(stale_key.clone()), b"keep".as_slice())
.await
.expect("stale put");
let version = keep.identity.version_id.clone().expect("stale version id");
let bogus = ObjectIdentity {
target_store_id: store.config().target_store_id.clone(),
bucket: store.config().bucket.clone(),
key: stale_key,
version_id: Some(format!("{version}-stale")),
};
let result = store.read_exact(&bogus).await;
assert!(
matches!(
result,
Err(TargetError::NotFound | TargetError::Internal(_))
),
"stale version must fail closed: {result:?}"
);
store
.purge_versions_with_prefix(RESERVED_NAMESPACE_SEGMENT)
.await
.expect("purge versions");
}