436 lines
13 KiB
Rust
436 lines
13 KiB
Rust
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");
|
|
}
|