mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-27 15:37:02 +00:00
fix(s3): allow CopyObject to restore a historical object version (#4250)
This commit is contained in:
committed by
GitHub
parent
123552d32b
commit
eb85607e37
@@ -0,0 +1,163 @@
|
|||||||
|
// Copyright 2024 RustFS Team
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
//! Regression test for Issue #4238: CopyObject must allow restoring a historical
|
||||||
|
//! object version onto the current key (same bucket/key + sourceVersionId) using the
|
||||||
|
//! default COPY metadata directive, preserving data, Content-Type and user metadata.
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::common::{RustFSTestEnvironment, init_logging};
|
||||||
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
|
use aws_sdk_s3::types::{BucketVersioningStatus, VersioningConfiguration};
|
||||||
|
use serial_test::serial;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn test_self_copy_of_historical_version_restores_data_and_metadata() {
|
||||||
|
init_logging();
|
||||||
|
info!("Issue #4238: self-copy of a historical version must be allowed and preserve metadata");
|
||||||
|
|
||||||
|
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
|
||||||
|
env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");
|
||||||
|
|
||||||
|
let client = env.create_s3_client();
|
||||||
|
let bucket = "copy-object-version-restore-test";
|
||||||
|
let key = "docs/report.txt";
|
||||||
|
|
||||||
|
client
|
||||||
|
.create_bucket()
|
||||||
|
.bucket(bucket)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("Failed to create bucket");
|
||||||
|
|
||||||
|
client
|
||||||
|
.put_bucket_versioning()
|
||||||
|
.bucket(bucket)
|
||||||
|
.versioning_configuration(
|
||||||
|
VersioningConfiguration::builder()
|
||||||
|
.status(BucketVersioningStatus::Enabled)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("Failed to enable versioning");
|
||||||
|
|
||||||
|
// Version 1: the content we want to restore later.
|
||||||
|
let v1_content = b"original report contents -- version one";
|
||||||
|
let put_v1 = client
|
||||||
|
.put_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(key)
|
||||||
|
.content_type("text/plain; charset=utf-8")
|
||||||
|
.metadata("origin", "v1")
|
||||||
|
.body(ByteStream::from_static(v1_content))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("PUT v1 failed");
|
||||||
|
let v1_id = put_v1.version_id().expect("v1 must have a version id").to_string();
|
||||||
|
|
||||||
|
// Version 2: becomes the current version.
|
||||||
|
let v2_content = b"updated report contents -- version two (current)";
|
||||||
|
let put_v2 = client
|
||||||
|
.put_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(key)
|
||||||
|
.content_type("application/octet-stream")
|
||||||
|
.metadata("origin", "v2")
|
||||||
|
.body(ByteStream::from_static(v2_content))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("PUT v2 failed");
|
||||||
|
let v2_id = put_v2.version_id().expect("v2 must have a version id").to_string();
|
||||||
|
|
||||||
|
assert_ne!(v1_id, v2_id, "the two puts must produce distinct versions");
|
||||||
|
|
||||||
|
// Restore v1 by copying it onto the current key with the DEFAULT (COPY) directive.
|
||||||
|
// This is the exact operation issue #4238 says is wrongly rejected as a self-copy.
|
||||||
|
let copy_out = client
|
||||||
|
.copy_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(key)
|
||||||
|
.copy_source(format!("{bucket}/{key}?versionId={v1_id}"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("CopyObject restoring a historical version must succeed (issue #4238)");
|
||||||
|
|
||||||
|
let restored_id = copy_out
|
||||||
|
.copy_object_result()
|
||||||
|
.and_then(|_| copy_out.version_id())
|
||||||
|
.expect("restore copy must create a new version id")
|
||||||
|
.to_string();
|
||||||
|
assert_ne!(restored_id, v1_id, "restore must create a NEW version, not mutate v1");
|
||||||
|
assert_ne!(restored_id, v2_id, "restore must create a NEW version, not mutate v2");
|
||||||
|
|
||||||
|
// The current object must now serve v1's data and metadata (COPY directive preserves both).
|
||||||
|
let head = client
|
||||||
|
.head_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(key)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("HEAD failed after restore");
|
||||||
|
assert_eq!(head.content_length(), Some(v1_content.len() as i64), "current size must equal v1");
|
||||||
|
assert_eq!(
|
||||||
|
head.content_type(),
|
||||||
|
Some("text/plain; charset=utf-8"),
|
||||||
|
"Content-Type must be preserved from v1 (issue #4238 reports it is lost)"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
head.metadata().and_then(|m| m.get("origin")),
|
||||||
|
Some(&"v1".to_string()),
|
||||||
|
"user metadata must be preserved from v1"
|
||||||
|
);
|
||||||
|
|
||||||
|
let get = client
|
||||||
|
.get_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(key)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("GET failed after restore");
|
||||||
|
let body = get.body.collect().await.expect("collect body").into_bytes();
|
||||||
|
assert_eq!(body.as_ref(), v1_content, "current object data must equal v1 after restore");
|
||||||
|
|
||||||
|
// The original versions must remain intact and independently readable.
|
||||||
|
let get_v1 = client
|
||||||
|
.get_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(key)
|
||||||
|
.version_id(&v1_id)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("GET v1 failed");
|
||||||
|
let v1_body = get_v1.body.collect().await.expect("collect v1 body").into_bytes();
|
||||||
|
assert_eq!(v1_body.as_ref(), v1_content, "v1 must remain intact after restore");
|
||||||
|
|
||||||
|
let get_v2 = client
|
||||||
|
.get_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(key)
|
||||||
|
.version_id(&v2_id)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("GET v2 failed");
|
||||||
|
let v2_body = get_v2.body.collect().await.expect("collect v2 body").into_bytes();
|
||||||
|
assert_eq!(v2_body.as_ref(), v2_content, "v2 must remain intact after restore");
|
||||||
|
|
||||||
|
env.stop_server();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
// Copyright 2024 RustFS Team
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
//! Regression test for Issue #4238 (encryption case): restoring a historical version of an
|
||||||
|
//! SSE-S3 encrypted object onto the current key must produce a readable object.
|
||||||
|
//!
|
||||||
|
//! The version-restore path writes a NEW version. A metadata-only (zero-copy) version copy
|
||||||
|
//! would make the new version point at the source ciphertext while carrying freshly derived
|
||||||
|
//! encryption metadata, yielding an undecryptable object. The store layer therefore performs a
|
||||||
|
//! full read/write copy through `put_object` when the handler supplies a reader, keeping the
|
||||||
|
//! stored bytes consistent with the new version's key metadata.
|
||||||
|
|
||||||
|
use super::common::{LocalKMSTestEnvironment, create_key_with_specific_id};
|
||||||
|
use crate::common::init_logging;
|
||||||
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
|
use aws_sdk_s3::types::{BucketVersioningStatus, ServerSideEncryption, VersioningConfiguration};
|
||||||
|
use serial_test::serial;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn test_self_copy_of_historical_sse_s3_version_is_readable() {
|
||||||
|
init_logging();
|
||||||
|
info!("Issue #4238 (SSE): restoring an encrypted historical version must stay decryptable");
|
||||||
|
|
||||||
|
let mut kms_env = LocalKMSTestEnvironment::new().await.expect("failed to create local KMS env");
|
||||||
|
// Start the server with the local KMS backend. The dev-defaults flag is required for the
|
||||||
|
// in-process local KMS master key used by e2e tests.
|
||||||
|
let default_key_id = "rustfs-e2e-test-default-key";
|
||||||
|
let keys_dir = kms_env.kms_keys_dir.clone();
|
||||||
|
create_key_with_specific_id(&keys_dir, default_key_id)
|
||||||
|
.await
|
||||||
|
.expect("failed to create local KMS key");
|
||||||
|
kms_env
|
||||||
|
.base_env
|
||||||
|
.start_rustfs_server_with_env(
|
||||||
|
vec![
|
||||||
|
"--kms-enable",
|
||||||
|
"--kms-backend",
|
||||||
|
"local",
|
||||||
|
"--kms-key-dir",
|
||||||
|
&keys_dir,
|
||||||
|
"--kms-default-key-id",
|
||||||
|
default_key_id,
|
||||||
|
],
|
||||||
|
&[("RUSTFS_KMS_ALLOW_INSECURE_DEV_DEFAULTS", "true")],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("failed to start RustFS with local KMS");
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
|
||||||
|
|
||||||
|
let client = kms_env.base_env.create_s3_client();
|
||||||
|
let bucket = "copy-object-version-restore-sse-test";
|
||||||
|
let key = "secrets/report.txt";
|
||||||
|
|
||||||
|
client
|
||||||
|
.create_bucket()
|
||||||
|
.bucket(bucket)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("failed to create bucket");
|
||||||
|
client
|
||||||
|
.put_bucket_versioning()
|
||||||
|
.bucket(bucket)
|
||||||
|
.versioning_configuration(
|
||||||
|
VersioningConfiguration::builder()
|
||||||
|
.status(BucketVersioningStatus::Enabled)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("failed to enable versioning");
|
||||||
|
|
||||||
|
// Version 1: SSE-S3 encrypted content we want to restore later.
|
||||||
|
let v1_content = b"encrypted original report -- version one";
|
||||||
|
let put_v1 = client
|
||||||
|
.put_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(key)
|
||||||
|
.content_type("text/plain; charset=utf-8")
|
||||||
|
.metadata("origin", "v1")
|
||||||
|
.server_side_encryption(ServerSideEncryption::Aes256)
|
||||||
|
.body(ByteStream::from_static(v1_content))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("PUT v1 failed");
|
||||||
|
assert_eq!(put_v1.server_side_encryption(), Some(&ServerSideEncryption::Aes256));
|
||||||
|
let v1_id = put_v1.version_id().expect("v1 must have a version id").to_string();
|
||||||
|
|
||||||
|
// Version 2: becomes the current version.
|
||||||
|
let v2_content = b"encrypted updated report -- version two (current)";
|
||||||
|
let put_v2 = client
|
||||||
|
.put_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(key)
|
||||||
|
.content_type("application/octet-stream")
|
||||||
|
.metadata("origin", "v2")
|
||||||
|
.server_side_encryption(ServerSideEncryption::Aes256)
|
||||||
|
.body(ByteStream::from_static(v2_content))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("PUT v2 failed");
|
||||||
|
let v2_id = put_v2.version_id().expect("v2 must have a version id").to_string();
|
||||||
|
assert_ne!(v1_id, v2_id, "the two puts must produce distinct versions");
|
||||||
|
|
||||||
|
// Restore v1 by copying it onto the current key with the DEFAULT (COPY) directive, keeping
|
||||||
|
// the destination encrypted. Under the old zero-copy version-restore path this new version
|
||||||
|
// would be undecryptable.
|
||||||
|
let copy_out = client
|
||||||
|
.copy_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(key)
|
||||||
|
.copy_source(format!("{bucket}/{key}?versionId={v1_id}"))
|
||||||
|
.server_side_encryption(ServerSideEncryption::Aes256)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("CopyObject restoring an encrypted historical version must succeed (issue #4238)");
|
||||||
|
let restored_id = copy_out
|
||||||
|
.version_id()
|
||||||
|
.expect("restore copy must create a new version id")
|
||||||
|
.to_string();
|
||||||
|
assert_ne!(restored_id, v1_id, "restore must create a NEW version, not mutate v1");
|
||||||
|
assert_ne!(restored_id, v2_id, "restore must create a NEW version, not mutate v2");
|
||||||
|
|
||||||
|
// The current object must now decrypt to v1's plaintext (proves stored ciphertext matches
|
||||||
|
// the new version's key metadata) and preserve Content-Type and user metadata.
|
||||||
|
let get = client
|
||||||
|
.get_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(key)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("GET after restore failed");
|
||||||
|
assert_eq!(get.server_side_encryption(), Some(&ServerSideEncryption::Aes256));
|
||||||
|
assert_eq!(
|
||||||
|
get.content_type(),
|
||||||
|
Some("text/plain; charset=utf-8"),
|
||||||
|
"Content-Type must be preserved from v1"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
get.metadata().and_then(|m| m.get("origin")),
|
||||||
|
Some(&"v1".to_string()),
|
||||||
|
"user metadata must be preserved from v1"
|
||||||
|
);
|
||||||
|
let body = get.body.collect().await.expect("collect body").into_bytes();
|
||||||
|
assert_eq!(body.as_ref(), v1_content, "restored current object must decrypt to v1 content");
|
||||||
|
|
||||||
|
// Original versions must remain intact and independently decryptable.
|
||||||
|
let get_v1 = client
|
||||||
|
.get_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(key)
|
||||||
|
.version_id(&v1_id)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("GET v1 failed");
|
||||||
|
let v1_body = get_v1.body.collect().await.expect("collect v1 body").into_bytes();
|
||||||
|
assert_eq!(v1_body.as_ref(), v1_content, "v1 must remain intact after restore");
|
||||||
|
|
||||||
|
let get_v2 = client
|
||||||
|
.get_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(key)
|
||||||
|
.version_id(&v2_id)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("GET v2 failed");
|
||||||
|
let v2_body = get_v2.body.collect().await.expect("collect v2 body").into_bytes();
|
||||||
|
assert_eq!(v2_body.as_ref(), v2_content, "v2 must remain intact after restore");
|
||||||
|
|
||||||
|
kms_env.base_env.stop_server();
|
||||||
|
}
|
||||||
@@ -47,3 +47,6 @@ mod bucket_default_encryption_test;
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod encryption_metadata_test;
|
mod encryption_metadata_test;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod copy_object_version_restore_sse_test;
|
||||||
|
|||||||
@@ -141,6 +141,9 @@ mod heal_erasure_disk_rebuild_test;
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod copy_object_metadata_test;
|
mod copy_object_metadata_test;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod copy_object_version_restore_test;
|
||||||
|
|
||||||
// S3 dummy-compat bucket API tests
|
// S3 dummy-compat bucket API tests
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod bucket_logging_test;
|
mod bucket_logging_test;
|
||||||
|
|||||||
@@ -778,6 +778,25 @@ impl ECStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if dst_opts.versioned && src_opts.version_id != dst_opts.version_id {
|
if dst_opts.versioned && src_opts.version_id != dst_opts.version_id {
|
||||||
|
// Restoring a specific historical version onto the current key creates a NEW
|
||||||
|
// version. When the caller supplies a reader (S3 CopyObject), write the fetched
|
||||||
|
// bytes through put_object so any re-encryption/compression applied to the reader
|
||||||
|
// stays consistent with the new version's metadata. Sharing the source data_dir via
|
||||||
|
// a metadata-only version copy would corrupt SSE/compressed objects (issue #4238).
|
||||||
|
if let Some(reader) = src_info.put_object_reader.as_mut() {
|
||||||
|
let put_opts = ObjectOptions {
|
||||||
|
user_defined: (*src_info.user_defined).clone(),
|
||||||
|
versioned: dst_opts.versioned,
|
||||||
|
version_id: dst_opts.version_id.clone(),
|
||||||
|
no_lock: dst_opts.no_lock,
|
||||||
|
mod_time: dst_opts.mod_time,
|
||||||
|
http_preconditions: dst_opts.http_preconditions.clone(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
return self.pools[pool_idx]
|
||||||
|
.put_object(dst_bucket, &dst_object, reader, &put_opts)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
src_info.version_only = true;
|
src_info.version_only = true;
|
||||||
return self.pools[pool_idx]
|
return self.pools[pool_idx]
|
||||||
.copy_object(src_bucket, &src_object, dst_bucket, &dst_object, src_info, src_opts, &dst_opts)
|
.copy_object(src_bucket, &src_object, dst_bucket, &dst_object, src_info, src_opts, &dst_opts)
|
||||||
|
|||||||
@@ -4234,6 +4234,22 @@ impl DefaultObjectUsecase {
|
|||||||
} => (bucket.to_string(), key.to_string(), version_id.map(|v| v.to_string())),
|
} => (bucket.to_string(), key.to_string(), version_id.map(|v| v.to_string())),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Normalize the copy-source version id like GET/HEAD do: trim, treat "null" as the
|
||||||
|
// nil UUID, and reject malformed ids up front (issue #4238).
|
||||||
|
let version_id = match version_id {
|
||||||
|
Some(v) => {
|
||||||
|
let trimmed = v.trim();
|
||||||
|
if trimmed.eq_ignore_ascii_case("null") {
|
||||||
|
Some(Uuid::nil().to_string())
|
||||||
|
} else if Uuid::parse_str(trimmed).is_ok() {
|
||||||
|
Some(trimmed.to_string())
|
||||||
|
} else {
|
||||||
|
return Err(s3_error!(InvalidArgument, "Invalid version id specified in copy source"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
if let Some(ref sc) = storage_class
|
if let Some(ref sc) = storage_class
|
||||||
&& !is_valid_storage_class(sc.as_str())
|
&& !is_valid_storage_class(sc.as_str())
|
||||||
{
|
{
|
||||||
@@ -4246,10 +4262,12 @@ impl DefaultObjectUsecase {
|
|||||||
validate_table_catalog_object_mutation(&bucket, &key).await?;
|
validate_table_catalog_object_mutation(&bucket, &key).await?;
|
||||||
|
|
||||||
// AWS S3 allows self-copy when metadata directive is REPLACE (used to update metadata in-place),
|
// AWS S3 allows self-copy when metadata directive is REPLACE (used to update metadata in-place),
|
||||||
// or when an explicit storage class change is requested.
|
// when an explicit storage class change is requested, or when restoring a specific historical
|
||||||
// Reject only when neither condition applies.
|
// version onto the current key (source carries a versionId). Reject only a true no-op self-copy
|
||||||
|
// where none of these apply (issue #4238).
|
||||||
if metadata_directive.as_ref().map(|d| d.as_str()) != Some(MetadataDirective::REPLACE)
|
if metadata_directive.as_ref().map(|d| d.as_str()) != Some(MetadataDirective::REPLACE)
|
||||||
&& storage_class.is_none()
|
&& storage_class.is_none()
|
||||||
|
&& version_id.is_none()
|
||||||
&& src_bucket == bucket
|
&& src_bucket == bucket
|
||||||
&& src_key == key
|
&& src_key == key
|
||||||
{
|
{
|
||||||
@@ -7994,6 +8012,71 @@ mod tests {
|
|||||||
assert_ne!(err.code(), &S3ErrorCode::NotImplemented);
|
assert_ne!(err.code(), &S3ErrorCode::NotImplemented);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn execute_copy_object_allows_self_copy_of_historical_version() {
|
||||||
|
// Restoring a specific historical version onto the current key (same bucket/key with a
|
||||||
|
// source versionId, default COPY directive) must pass the self-copy guard (issue #4238).
|
||||||
|
let input = CopyObjectInput::builder()
|
||||||
|
.copy_source(CopySource::Bucket {
|
||||||
|
bucket: "test-bucket".into(),
|
||||||
|
key: "test-key".into(),
|
||||||
|
version_id: Some("11111111-1111-1111-1111-111111111111".into()),
|
||||||
|
})
|
||||||
|
.bucket("test-bucket".to_string())
|
||||||
|
.key("test-key".to_string())
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let req = build_request(input, Method::PUT);
|
||||||
|
let usecase = DefaultObjectUsecase::without_context();
|
||||||
|
|
||||||
|
let err = Box::pin(usecase.execute_copy_object(req)).await.unwrap_err();
|
||||||
|
// Must not be rejected by the self-copy guard; it fails later at store init instead.
|
||||||
|
assert_ne!(err.code(), &S3ErrorCode::InvalidRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn execute_copy_object_allows_self_copy_of_null_version() {
|
||||||
|
// A "null" source version id is a restore of the null version, not a no-op self-copy.
|
||||||
|
let input = CopyObjectInput::builder()
|
||||||
|
.copy_source(CopySource::Bucket {
|
||||||
|
bucket: "test-bucket".into(),
|
||||||
|
key: "test-key".into(),
|
||||||
|
version_id: Some("null".into()),
|
||||||
|
})
|
||||||
|
.bucket("test-bucket".to_string())
|
||||||
|
.key("test-key".to_string())
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let req = build_request(input, Method::PUT);
|
||||||
|
let usecase = DefaultObjectUsecase::without_context();
|
||||||
|
|
||||||
|
let err = Box::pin(usecase.execute_copy_object(req)).await.unwrap_err();
|
||||||
|
assert_ne!(err.code(), &S3ErrorCode::InvalidRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn execute_copy_object_rejects_malformed_copy_source_version_id() {
|
||||||
|
// A malformed (non-null, non-UUID) source version id is rejected up front.
|
||||||
|
let input = CopyObjectInput::builder()
|
||||||
|
.copy_source(CopySource::Bucket {
|
||||||
|
bucket: "src-bucket".into(),
|
||||||
|
key: "src-key".into(),
|
||||||
|
version_id: Some("not-a-uuid".into()),
|
||||||
|
})
|
||||||
|
.bucket("dst-bucket".to_string())
|
||||||
|
.key("dst-key".to_string())
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let req = build_request(input, Method::PUT);
|
||||||
|
let usecase = DefaultObjectUsecase::without_context();
|
||||||
|
|
||||||
|
let err = Box::pin(usecase.execute_copy_object(req)).await.unwrap_err();
|
||||||
|
assert_eq!(err.code(), &S3ErrorCode::InvalidArgument);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn execute_delete_object_rejects_invalid_object_key() {
|
async fn execute_delete_object_rejects_invalid_object_key() {
|
||||||
let input = DeleteObjectInput::builder()
|
let input = DeleteObjectInput::builder()
|
||||||
|
|||||||
Reference in New Issue
Block a user