From 2ddbce5843e7073d323deff633063211cd4b996b Mon Sep 17 00:00:00 2001 From: Yuang Gao Date: Sun, 24 May 2026 19:42:41 -0700 Subject: [PATCH 1/5] feat(services/s3): support source-side If-Match on copy --- core/core/src/layers/correctness_check.rs | 7 ++ core/core/src/raw/ops.rs | 16 +++++ core/core/src/types/capability.rs | 2 + .../src/types/operator/operator_futures.rs | 9 +++ core/core/src/types/options.rs | 13 ++++ core/layers/capability-check/src/lib.rs | 7 ++ core/services/s3/src/backend.rs | 1 + core/services/s3/src/core.rs | 7 ++ core/tests/behavior/async_copy.rs | 72 +++++++++++++++++++ 9 files changed, 134 insertions(+) diff --git a/core/core/src/layers/correctness_check.rs b/core/core/src/layers/correctness_check.rs index 020acf4075c5..64669ddf58ff 100644 --- a/core/core/src/layers/correctness_check.rs +++ b/core/core/src/layers/correctness_check.rs @@ -240,6 +240,13 @@ impl LayeredAccess for CorrectnessAccessor { "if_match", )); } + if args.source_if_match().is_some() && !capability.copy_with_source_if_match { + return Err(new_unsupported_error( + &self.info, + Operation::Copy, + "source_if_match", + )); + } self.inner.copy(from, to, args, opts).await } diff --git a/core/core/src/raw/ops.rs b/core/core/src/raw/ops.rs index 30ca7395c3a5..07b9925bc479 100644 --- a/core/core/src/raw/ops.rs +++ b/core/core/src/raw/ops.rs @@ -895,6 +895,7 @@ impl From for (OpWrite, OpWriter) { pub struct OpCopy { if_not_exists: bool, if_match: Option, + source_if_match: Option, } impl OpCopy { @@ -930,6 +931,20 @@ impl OpCopy { pub fn if_match(&self) -> Option<&str> { self.if_match.as_deref() } + + /// Set the source_if_match condition for the operation. + /// + /// When set, the copy operation will only proceed if the source object's + /// ETag matches the given value. + pub fn with_source_if_match(mut self, source_if_match: impl Into) -> Self { + self.source_if_match = Some(source_if_match.into()); + self + } + + /// Get source_if_match condition. + pub fn source_if_match(&self) -> Option<&str> { + self.source_if_match.as_deref() + } } /// Args for `copier` operation. @@ -986,6 +1001,7 @@ impl From for (OpCopy, OpCopier) { OpCopy { if_not_exists: value.if_not_exists, if_match: value.if_match, + source_if_match: value.source_if_match, }, OpCopier { concurrent: value.concurrent.max(1), diff --git a/core/core/src/types/capability.rs b/core/core/src/types/capability.rs index 032077765a3c..8f0ed167475e 100644 --- a/core/core/src/types/capability.rs +++ b/core/core/src/types/capability.rs @@ -158,6 +158,8 @@ pub struct Capability { pub copy_with_if_not_exists: bool, /// Indicates if conditional copy operations with if-match are supported. pub copy_with_if_match: bool, + /// Indicates if conditional copy operations with source-side if-match are supported. + pub copy_with_source_if_match: bool, /// Indicates if copy operations can be split into multiple server-side tasks. pub copy_can_multi: bool, /// Maximum size supported for segmented copy tasks. diff --git a/core/core/src/types/operator/operator_futures.rs b/core/core/src/types/operator/operator_futures.rs index 093395fb3364..002bda33a863 100644 --- a/core/core/src/types/operator/operator_futures.rs +++ b/core/core/src/types/operator/operator_futures.rs @@ -1437,6 +1437,15 @@ impl>> FutureCopy { self } + /// Sets the condition that copy operation will succeed only if the source + /// object currently has the given ETag. + /// + /// Refer to [`options::CopyOptions::source_if_match`] for more details. + pub fn source_if_match(mut self, etag: &str) -> Self { + self.args.0.source_if_match = Some(etag.to_string()); + self + } + /// Sets concurrent copy operations for this copy. /// /// Refer to [`options::CopyOptions::concurrent`] for more details. diff --git a/core/core/src/types/options.rs b/core/core/src/types/options.rs index b5bdc9102e52..e52de14944e4 100644 --- a/core/core/src/types/options.rs +++ b/core/core/src/types/options.rs @@ -560,6 +560,19 @@ pub struct CopyOptions { /// destination object's ETag matches the given value. pub if_match: Option, + /// Sets the condition that copy operation will succeed only if the source + /// object currently has the given ETag. + /// + /// ### Capability + /// + /// Check [`Capability::copy_with_source_if_match`] before using this feature. + /// + /// ### Behavior + /// + /// - If supported, the copy operation will only succeed when the source + /// object's ETag matches the given value. + pub source_if_match: Option, + /// Known content length of the source object. /// /// This is an execution hint that allows OpenDAL to avoid extra metadata diff --git a/core/layers/capability-check/src/lib.rs b/core/layers/capability-check/src/lib.rs index 96f1c8cdec68..df8111870bd5 100644 --- a/core/layers/capability-check/src/lib.rs +++ b/core/layers/capability-check/src/lib.rs @@ -162,6 +162,13 @@ impl LayeredAccess for CapabilityAccessor { "if_not_exists", )); } + if args.source_if_match().is_some() && !capability.copy_with_source_if_match { + return Err(new_unsupported_error( + self.info.as_ref(), + Operation::Copy, + "source_if_match", + )); + } self.inner.copy(from, to, args, opts).await } diff --git a/core/services/s3/src/backend.rs b/core/services/s3/src/backend.rs index 86431e53362d..a3f69596f6e5 100644 --- a/core/services/s3/src/backend.rs +++ b/core/services/s3/src/backend.rs @@ -983,6 +983,7 @@ impl Builder for S3Builder { copy_can_multi: true, copy_with_if_not_exists: true, copy_with_if_match: true, + copy_with_source_if_match: true, // The min multipart size of S3 is 5 MiB. // // ref: diff --git a/core/services/s3/src/core.rs b/core/services/s3/src/core.rs index 944a1d32c24e..2274d82dab68 100644 --- a/core/services/s3/src/core.rs +++ b/core/services/s3/src/core.rs @@ -49,6 +49,7 @@ use opendal_core::*; pub mod constants { pub const X_AMZ_COPY_SOURCE: &str = "x-amz-copy-source"; pub const X_AMZ_COPY_SOURCE_RANGE: &str = "x-amz-copy-source-range"; + pub const X_AMZ_COPY_SOURCE_IF_MATCH: &str = "x-amz-copy-source-if-match"; pub const X_AMZ_SERVER_SIDE_ENCRYPTION: &str = "x-amz-server-side-encryption"; pub const X_AMZ_SERVER_REQUEST_PAYER: (&str, &str) = ("x-amz-request-payer", "requester"); @@ -669,6 +670,9 @@ impl S3Core { if let Some(if_match) = args.if_match() { req = req.header(IF_MATCH, if_match); } + if let Some(source_if_match) = args.source_if_match() { + req = req.header(constants::X_AMZ_COPY_SOURCE_IF_MATCH, source_if_match); + } // Set SSE headers. req = self.insert_sse_headers(req, true); @@ -1119,6 +1123,9 @@ impl S3Core { if let Some(if_match) = args.if_match() { req = req.header(IF_MATCH, if_match); } + if let Some(source_if_match) = args.source_if_match() { + req = req.header(constants::X_AMZ_COPY_SOURCE_IF_MATCH, source_if_match); + } // Set request payer header if enabled. req = self.insert_request_payer_header(req); diff --git a/core/tests/behavior/async_copy.rs b/core/tests/behavior/async_copy.rs index d043992a2829..342ec439e204 100644 --- a/core/tests/behavior/async_copy.rs +++ b/core/tests/behavior/async_copy.rs @@ -56,6 +56,14 @@ pub fn tests(op: &Operator, tests: &mut Vec) { test_copier_with_if_not_exists_to_existing_file )) } + + if cap.read && cap.write && cap.copy && cap.copy_with_source_if_match { + tests.extend(async_trials!( + op, + test_copy_with_source_if_match_match, + test_copy_with_source_if_match_mismatch + )) + } } fn copy_multi_chunk_size(cap: Capability) -> Option<(usize, usize)> { @@ -349,6 +357,70 @@ pub async fn test_copy_with_if_not_exists_to_existing_file(op: Operator) -> Resu Ok(()) } +/// Copy with source_if_match matching the source ETag should succeed. +pub async fn test_copy_with_source_if_match_match(op: Operator) -> Result<()> { + if !op.info().full_capability().copy_with_source_if_match { + return Ok(()); + } + + let source_path = uuid::Uuid::new_v4().to_string(); + let (source_content, _) = gen_bytes(op.info().full_capability()); + op.write(&source_path, source_content.clone()).await?; + + let Some(etag) = op.stat(&source_path).await?.etag().map(|s| s.to_string()) else { + op.delete(&source_path).await.expect("delete must succeed"); + return Ok(()); + }; + + let target_path = uuid::Uuid::new_v4().to_string(); + + op.copy_with(&source_path, &target_path) + .source_if_match(&etag) + .await?; + + let target_content = op + .read(&target_path) + .await + .expect("read must succeed") + .to_bytes(); + assert_eq!( + sha256_digest(target_content), + sha256_digest(&source_content), + ); + + op.delete(&source_path).await.expect("delete must succeed"); + op.delete(&target_path).await.expect("delete must succeed"); + Ok(()) +} + +/// Copy with source_if_match not matching should fail with ConditionNotMatch. +pub async fn test_copy_with_source_if_match_mismatch(op: Operator) -> Result<()> { + if !op.info().full_capability().copy_with_source_if_match { + return Ok(()); + } + + let source_path = uuid::Uuid::new_v4().to_string(); + let (source_content, _) = gen_bytes(op.info().full_capability()); + op.write(&source_path, source_content.clone()).await?; + + let target_path = uuid::Uuid::new_v4().to_string(); + + let err = op + .copy_with(&source_path, &target_path) + .source_if_match("\"00000000000000000000000000000000\"") + .await + .expect_err("copy must fail"); + assert_eq!(err.kind(), ErrorKind::ConditionNotMatch); + + assert!( + !op.exists(&target_path).await.expect("exists must succeed"), + "target must not be created on mismatch" + ); + + op.delete(&source_path).await.expect("delete must succeed"); + Ok(()) +} + /// Copy with chunk should copy a file successfully. pub async fn test_copy_with_chunk(op: Operator) -> Result<()> { let cap = op.info().full_capability(); From a027c0150f93cf9440174b5ee303ec27c0d44d0f Mon Sep 17 00:00:00 2001 From: Yuang Gao Date: Sun, 24 May 2026 19:46:35 -0700 Subject: [PATCH 2/5] ci: retrigger From 451dafc8f1c3c7fe1cae0494091c391e7e23cacc Mon Sep 17 00:00:00 2001 From: Yuang Gao Date: Sun, 24 May 2026 20:28:35 -0700 Subject: [PATCH 3/5] feat(services/s3): support source-side conditional copy --- core/core/src/layers/correctness_check.rs | 25 +++++++ core/core/src/raw/ops.rs | 48 +++++++++++++ core/core/src/types/capability.rs | 6 ++ .../src/types/operator/operator_futures.rs | 27 +++++++ core/core/src/types/options.rs | 42 +++++++++++ core/layers/capability-check/src/lib.rs | 25 +++++++ core/services/s3/src/backend.rs | 3 + core/services/s3/src/core.rs | 39 ++++++++++ core/tests/behavior/async_copy.rs | 72 +++++++++++++++++++ 9 files changed, 287 insertions(+) diff --git a/core/core/src/layers/correctness_check.rs b/core/core/src/layers/correctness_check.rs index 64669ddf58ff..355ac05db72f 100644 --- a/core/core/src/layers/correctness_check.rs +++ b/core/core/src/layers/correctness_check.rs @@ -247,6 +247,31 @@ impl LayeredAccess for CorrectnessAccessor { "source_if_match", )); } + if args.source_if_none_match().is_some() && !capability.copy_with_source_if_none_match { + return Err(new_unsupported_error( + &self.info, + Operation::Copy, + "source_if_none_match", + )); + } + if args.source_if_modified_since().is_some() + && !capability.copy_with_source_if_modified_since + { + return Err(new_unsupported_error( + &self.info, + Operation::Copy, + "source_if_modified_since", + )); + } + if args.source_if_unmodified_since().is_some() + && !capability.copy_with_source_if_unmodified_since + { + return Err(new_unsupported_error( + &self.info, + Operation::Copy, + "source_if_unmodified_since", + )); + } self.inner.copy(from, to, args, opts).await } diff --git a/core/core/src/raw/ops.rs b/core/core/src/raw/ops.rs index 07b9925bc479..5aa49041a437 100644 --- a/core/core/src/raw/ops.rs +++ b/core/core/src/raw/ops.rs @@ -896,6 +896,9 @@ pub struct OpCopy { if_not_exists: bool, if_match: Option, source_if_match: Option, + source_if_none_match: Option, + source_if_modified_since: Option, + source_if_unmodified_since: Option, } impl OpCopy { @@ -945,6 +948,48 @@ impl OpCopy { pub fn source_if_match(&self) -> Option<&str> { self.source_if_match.as_deref() } + + /// Set the source_if_none_match condition for the operation. + /// + /// When set, the copy operation will only proceed if the source object's + /// ETag does not match the given value. + pub fn with_source_if_none_match(mut self, source_if_none_match: impl Into) -> Self { + self.source_if_none_match = Some(source_if_none_match.into()); + self + } + + /// Get source_if_none_match condition. + pub fn source_if_none_match(&self) -> Option<&str> { + self.source_if_none_match.as_deref() + } + + /// Set the source_if_modified_since condition for the operation. + /// + /// When set, the copy operation will only proceed if the source object has + /// been modified after the given timestamp. + pub fn with_source_if_modified_since(mut self, v: Timestamp) -> Self { + self.source_if_modified_since = Some(v); + self + } + + /// Get source_if_modified_since condition. + pub fn source_if_modified_since(&self) -> Option { + self.source_if_modified_since + } + + /// Set the source_if_unmodified_since condition for the operation. + /// + /// When set, the copy operation will only proceed if the source object has + /// not been modified after the given timestamp. + pub fn with_source_if_unmodified_since(mut self, v: Timestamp) -> Self { + self.source_if_unmodified_since = Some(v); + self + } + + /// Get source_if_unmodified_since condition. + pub fn source_if_unmodified_since(&self) -> Option { + self.source_if_unmodified_since + } } /// Args for `copier` operation. @@ -1002,6 +1047,9 @@ impl From for (OpCopy, OpCopier) { if_not_exists: value.if_not_exists, if_match: value.if_match, source_if_match: value.source_if_match, + source_if_none_match: value.source_if_none_match, + source_if_modified_since: value.source_if_modified_since, + source_if_unmodified_since: value.source_if_unmodified_since, }, OpCopier { concurrent: value.concurrent.max(1), diff --git a/core/core/src/types/capability.rs b/core/core/src/types/capability.rs index 8f0ed167475e..96e9460ef421 100644 --- a/core/core/src/types/capability.rs +++ b/core/core/src/types/capability.rs @@ -160,6 +160,12 @@ pub struct Capability { pub copy_with_if_match: bool, /// Indicates if conditional copy operations with source-side if-match are supported. pub copy_with_source_if_match: bool, + /// Indicates if conditional copy operations with source-side if-none-match are supported. + pub copy_with_source_if_none_match: bool, + /// Indicates if conditional copy operations with source-side if-modified-since are supported. + pub copy_with_source_if_modified_since: bool, + /// Indicates if conditional copy operations with source-side if-unmodified-since are supported. + pub copy_with_source_if_unmodified_since: bool, /// Indicates if copy operations can be split into multiple server-side tasks. pub copy_can_multi: bool, /// Maximum size supported for segmented copy tasks. diff --git a/core/core/src/types/operator/operator_futures.rs b/core/core/src/types/operator/operator_futures.rs index 002bda33a863..a9129c4af111 100644 --- a/core/core/src/types/operator/operator_futures.rs +++ b/core/core/src/types/operator/operator_futures.rs @@ -1446,6 +1446,33 @@ impl>> FutureCopy { self } + /// Sets the condition that copy operation will succeed only if the source + /// object's ETag does not match the given value. + /// + /// Refer to [`options::CopyOptions::source_if_none_match`] for more details. + pub fn source_if_none_match(mut self, etag: &str) -> Self { + self.args.0.source_if_none_match = Some(etag.to_string()); + self + } + + /// Sets the condition that copy operation will succeed only if the source + /// object has been modified after the given timestamp. + /// + /// Refer to [`options::CopyOptions::source_if_modified_since`] for more details. + pub fn source_if_modified_since(mut self, v: Timestamp) -> Self { + self.args.0.source_if_modified_since = Some(v); + self + } + + /// Sets the condition that copy operation will succeed only if the source + /// object has not been modified after the given timestamp. + /// + /// Refer to [`options::CopyOptions::source_if_unmodified_since`] for more details. + pub fn source_if_unmodified_since(mut self, v: Timestamp) -> Self { + self.args.0.source_if_unmodified_since = Some(v); + self + } + /// Sets concurrent copy operations for this copy. /// /// Refer to [`options::CopyOptions::concurrent`] for more details. diff --git a/core/core/src/types/options.rs b/core/core/src/types/options.rs index e52de14944e4..dbc06987321e 100644 --- a/core/core/src/types/options.rs +++ b/core/core/src/types/options.rs @@ -573,6 +573,48 @@ pub struct CopyOptions { /// object's ETag matches the given value. pub source_if_match: Option, + /// Sets the condition that copy operation will succeed only if the source + /// object's ETag does not match the given value. + /// + /// ### Capability + /// + /// Check [`Capability::copy_with_source_if_none_match`] before using this + /// feature. + /// + /// ### Behavior + /// + /// - If supported, the copy operation will only succeed when the source + /// object's ETag does not match the given value. + pub source_if_none_match: Option, + + /// Sets the condition that copy operation will succeed only if the source + /// object has been modified after the given timestamp. + /// + /// ### Capability + /// + /// Check [`Capability::copy_with_source_if_modified_since`] before using + /// this feature. + /// + /// ### Behavior + /// + /// - If supported, the copy operation will only succeed when the source + /// object has been modified after the given timestamp. + pub source_if_modified_since: Option, + + /// Sets the condition that copy operation will succeed only if the source + /// object has not been modified after the given timestamp. + /// + /// ### Capability + /// + /// Check [`Capability::copy_with_source_if_unmodified_since`] before using + /// this feature. + /// + /// ### Behavior + /// + /// - If supported, the copy operation will only succeed when the source + /// object has not been modified after the given timestamp. + pub source_if_unmodified_since: Option, + /// Known content length of the source object. /// /// This is an execution hint that allows OpenDAL to avoid extra metadata diff --git a/core/layers/capability-check/src/lib.rs b/core/layers/capability-check/src/lib.rs index df8111870bd5..cee272de1940 100644 --- a/core/layers/capability-check/src/lib.rs +++ b/core/layers/capability-check/src/lib.rs @@ -169,6 +169,31 @@ impl LayeredAccess for CapabilityAccessor { "source_if_match", )); } + if args.source_if_none_match().is_some() && !capability.copy_with_source_if_none_match { + return Err(new_unsupported_error( + self.info.as_ref(), + Operation::Copy, + "source_if_none_match", + )); + } + if args.source_if_modified_since().is_some() + && !capability.copy_with_source_if_modified_since + { + return Err(new_unsupported_error( + self.info.as_ref(), + Operation::Copy, + "source_if_modified_since", + )); + } + if args.source_if_unmodified_since().is_some() + && !capability.copy_with_source_if_unmodified_since + { + return Err(new_unsupported_error( + self.info.as_ref(), + Operation::Copy, + "source_if_unmodified_since", + )); + } self.inner.copy(from, to, args, opts).await } diff --git a/core/services/s3/src/backend.rs b/core/services/s3/src/backend.rs index a3f69596f6e5..f0a329040e74 100644 --- a/core/services/s3/src/backend.rs +++ b/core/services/s3/src/backend.rs @@ -984,6 +984,9 @@ impl Builder for S3Builder { copy_with_if_not_exists: true, copy_with_if_match: true, copy_with_source_if_match: true, + copy_with_source_if_none_match: true, + copy_with_source_if_modified_since: true, + copy_with_source_if_unmodified_since: true, // The min multipart size of S3 is 5 MiB. // // ref: diff --git a/core/services/s3/src/core.rs b/core/services/s3/src/core.rs index 2274d82dab68..c59cd87b3f06 100644 --- a/core/services/s3/src/core.rs +++ b/core/services/s3/src/core.rs @@ -50,6 +50,9 @@ pub mod constants { pub const X_AMZ_COPY_SOURCE: &str = "x-amz-copy-source"; pub const X_AMZ_COPY_SOURCE_RANGE: &str = "x-amz-copy-source-range"; pub const X_AMZ_COPY_SOURCE_IF_MATCH: &str = "x-amz-copy-source-if-match"; + pub const X_AMZ_COPY_SOURCE_IF_NONE_MATCH: &str = "x-amz-copy-source-if-none-match"; + pub const X_AMZ_COPY_SOURCE_IF_MODIFIED_SINCE: &str = "x-amz-copy-source-if-modified-since"; + pub const X_AMZ_COPY_SOURCE_IF_UNMODIFIED_SINCE: &str = "x-amz-copy-source-if-unmodified-since"; pub const X_AMZ_SERVER_SIDE_ENCRYPTION: &str = "x-amz-server-side-encryption"; pub const X_AMZ_SERVER_REQUEST_PAYER: (&str, &str) = ("x-amz-request-payer", "requester"); @@ -673,6 +676,24 @@ impl S3Core { if let Some(source_if_match) = args.source_if_match() { req = req.header(constants::X_AMZ_COPY_SOURCE_IF_MATCH, source_if_match); } + if let Some(source_if_none_match) = args.source_if_none_match() { + req = req.header( + constants::X_AMZ_COPY_SOURCE_IF_NONE_MATCH, + source_if_none_match, + ); + } + if let Some(v) = args.source_if_modified_since() { + req = req.header( + constants::X_AMZ_COPY_SOURCE_IF_MODIFIED_SINCE, + v.format_http_date(), + ); + } + if let Some(v) = args.source_if_unmodified_since() { + req = req.header( + constants::X_AMZ_COPY_SOURCE_IF_UNMODIFIED_SINCE, + v.format_http_date(), + ); + } // Set SSE headers. req = self.insert_sse_headers(req, true); @@ -1126,6 +1147,24 @@ impl S3Core { if let Some(source_if_match) = args.source_if_match() { req = req.header(constants::X_AMZ_COPY_SOURCE_IF_MATCH, source_if_match); } + if let Some(source_if_none_match) = args.source_if_none_match() { + req = req.header( + constants::X_AMZ_COPY_SOURCE_IF_NONE_MATCH, + source_if_none_match, + ); + } + if let Some(v) = args.source_if_modified_since() { + req = req.header( + constants::X_AMZ_COPY_SOURCE_IF_MODIFIED_SINCE, + v.format_http_date(), + ); + } + if let Some(v) = args.source_if_unmodified_since() { + req = req.header( + constants::X_AMZ_COPY_SOURCE_IF_UNMODIFIED_SINCE, + v.format_http_date(), + ); + } // Set request payer header if enabled. req = self.insert_request_payer_header(req); diff --git a/core/tests/behavior/async_copy.rs b/core/tests/behavior/async_copy.rs index 342ec439e204..bdcf20ac7b4d 100644 --- a/core/tests/behavior/async_copy.rs +++ b/core/tests/behavior/async_copy.rs @@ -64,6 +64,14 @@ pub fn tests(op: &Operator, tests: &mut Vec) { test_copy_with_source_if_match_mismatch )) } + + if cap.read && cap.write && cap.copy && cap.copy_with_source_if_none_match { + tests.extend(async_trials!( + op, + test_copy_with_source_if_none_match_mismatch, + test_copy_with_source_if_none_match_match + )) + } } fn copy_multi_chunk_size(cap: Capability) -> Option<(usize, usize)> { @@ -421,6 +429,70 @@ pub async fn test_copy_with_source_if_match_mismatch(op: Operator) -> Result<()> Ok(()) } +/// Copy with source_if_none_match not matching the source ETag should succeed. +pub async fn test_copy_with_source_if_none_match_mismatch(op: Operator) -> Result<()> { + if !op.info().full_capability().copy_with_source_if_none_match { + return Ok(()); + } + + let source_path = uuid::Uuid::new_v4().to_string(); + let (source_content, _) = gen_bytes(op.info().full_capability()); + op.write(&source_path, source_content.clone()).await?; + + let target_path = uuid::Uuid::new_v4().to_string(); + + op.copy_with(&source_path, &target_path) + .source_if_none_match("\"00000000000000000000000000000000\"") + .await?; + + let target_content = op + .read(&target_path) + .await + .expect("read must succeed") + .to_bytes(); + assert_eq!( + sha256_digest(target_content), + sha256_digest(&source_content), + ); + + op.delete(&source_path).await.expect("delete must succeed"); + op.delete(&target_path).await.expect("delete must succeed"); + Ok(()) +} + +/// Copy with source_if_none_match matching the source ETag should fail. +pub async fn test_copy_with_source_if_none_match_match(op: Operator) -> Result<()> { + if !op.info().full_capability().copy_with_source_if_none_match { + return Ok(()); + } + + let source_path = uuid::Uuid::new_v4().to_string(); + let (source_content, _) = gen_bytes(op.info().full_capability()); + op.write(&source_path, source_content.clone()).await?; + + let Some(etag) = op.stat(&source_path).await?.etag().map(|s| s.to_string()) else { + op.delete(&source_path).await.expect("delete must succeed"); + return Ok(()); + }; + + let target_path = uuid::Uuid::new_v4().to_string(); + + let err = op + .copy_with(&source_path, &target_path) + .source_if_none_match(&etag) + .await + .expect_err("copy must fail"); + assert_eq!(err.kind(), ErrorKind::ConditionNotMatch); + + assert!( + !op.exists(&target_path).await.expect("exists must succeed"), + "target must not be created on mismatch" + ); + + op.delete(&source_path).await.expect("delete must succeed"); + Ok(()) +} + /// Copy with chunk should copy a file successfully. pub async fn test_copy_with_chunk(op: Operator) -> Result<()> { let cap = op.info().full_capability(); From a01be7f9f847566bd5acd30b212e647f8db15f6b Mon Sep 17 00:00:00 2001 From: Yuang Gao Date: Thu, 28 May 2026 17:42:41 -0700 Subject: [PATCH 4/5] ci: retrigger From 92dfd641b98244184dce1d44d53e2a53aefc0043 Mon Sep 17 00:00:00 2001 From: Yuang Gao Date: Mon, 1 Jun 2026 19:54:08 -0700 Subject: [PATCH 5/5] ci: retrigger