diff --git a/bindings/nodejs/src/options.rs b/bindings/nodejs/src/options.rs index 34576cf8365d..242993694d07 100644 --- a/bindings/nodejs/src/options.rs +++ b/bindings/nodejs/src/options.rs @@ -508,6 +508,8 @@ impl From for opendal::options::WriteOptions { #[derive(Default)] pub struct DeleteOptions { pub version: Option, + /// Set `if_match` for this operation. + pub if_match: Option, /// Whether to delete recursively. pub recursive: Option, } @@ -516,6 +518,7 @@ impl From for opendal::options::DeleteOptions { fn from(value: DeleteOptions) -> Self { Self { version: value.version, + if_match: value.if_match, recursive: value.recursive.unwrap_or_default(), } } diff --git a/core/core/src/raw/ops.rs b/core/core/src/raw/ops.rs index 41182df6fa0b..8f3fd91149ab 100644 --- a/core/core/src/raw/ops.rs +++ b/core/core/src/raw/ops.rs @@ -44,6 +44,7 @@ impl OpCreateDir { pub struct OpDelete { version: Option, recursive: bool, + if_match: Option, } impl OpDelete { @@ -75,6 +76,17 @@ impl OpDelete { pub fn recursive(&self) -> bool { self.recursive } + + /// Set the If-Match of the option + pub fn with_if_match(mut self, if_match: &str) -> Self { + self.if_match = Some(if_match.to_string()); + self + } + + /// Get If-Match from option + pub fn if_match(&self) -> Option<&str> { + self.if_match.as_deref() + } } impl From for OpDelete { @@ -82,6 +94,7 @@ impl From for OpDelete { Self { version: value.version, recursive: value.recursive, + if_match: value.if_match, } } } @@ -878,6 +891,7 @@ impl From for (OpWrite, OpWriter) { #[derive(Debug, Clone, Default)] pub struct OpCopy { if_not_exists: bool, + if_match: Option, } impl OpCopy { @@ -899,6 +913,17 @@ impl OpCopy { pub fn if_not_exists(&self) -> bool { self.if_not_exists } + + /// Set the If-Match of the option + pub fn with_if_match(mut self, if_match: &str) -> Self { + self.if_match = Some(if_match.to_string()); + self + } + + /// Get If-Match from option + pub fn if_match(&self) -> Option<&str> { + self.if_match.as_deref() + } } /// Args for `rename` operation. diff --git a/core/core/src/types/capability.rs b/core/core/src/types/capability.rs index 7c49eee181b6..d5cb3f56dd13 100644 --- a/core/core/src/types/capability.rs +++ b/core/core/src/types/capability.rs @@ -145,6 +145,8 @@ pub struct Capability { pub delete_with_version: bool, /// Indicates if recursive delete operations are supported. pub delete_with_recursive: bool, + /// Indicates if conditional delete operations with if-match are supported. + pub delete_with_if_match: bool, /// Maximum size supported for single delete operations. pub delete_max_size: Option, @@ -152,6 +154,8 @@ pub struct Capability { pub copy: bool, /// Indicates if conditional copy operations with if-not-exists are supported. 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 rename operations are supported. pub rename: bool, diff --git a/core/core/src/types/operator/operator_futures.rs b/core/core/src/types/operator/operator_futures.rs index 7482bf75b9d7..a6768107a19d 100644 --- a/core/core/src/types/operator/operator_futures.rs +++ b/core/core/src/types/operator/operator_futures.rs @@ -1263,6 +1263,35 @@ impl>> FutureDelete { self.args.recursive = recursive; self } + + /// Sets If-Match header for this delete request. + /// + /// ### Behavior + /// + /// - If supported, the delete operation will only succeed if the file's ETag matches the specified value + /// - The value should be a valid ETag string + /// - If not supported, the value will be ignored + /// + /// This operation provides conditional delete functionality based on ETag matching. + /// + /// ### Example + /// + /// ``` + /// # use opendal_core::Result; + /// # use opendal_core::Operator; + /// + /// # async fn test(op: Operator) -> Result<()> { + /// let _ = op + /// .delete_with("path/to/file") + /// .if_match("\"686897696a7c876b7e\"") + /// .await?; + /// # Ok(()) + /// # } + /// ``` + pub fn if_match(mut self, s: &str) -> Self { + self.args.if_match = Some(s.to_string()); + self + } } /// Future that generated by [`Operator::deleter_with`]. @@ -1420,4 +1449,33 @@ impl>> FutureCopy { self.args.0.if_not_exists = v; self } + + /// Sets If-Match header for this copy request. + /// + /// ### Behavior + /// + /// - If supported, the copy operation will only succeed if the source's ETag matches the specified value + /// - The value should be a valid ETag string + /// - If not supported, the value will be ignored + /// + /// This operation provides conditional copy functionality based on ETag matching. + /// + /// ### Example + /// + /// ``` + /// # use opendal_core::Result; + /// # use opendal_core::Operator; + /// + /// # async fn test(op: Operator) -> Result<()> { + /// let _ = op + /// .copy_with("source/path", "target/path") + /// .if_match("\"686897696a7c876b7e\"") + /// .await?; + /// # Ok(()) + /// # } + /// ``` + pub fn if_match(mut self, s: &str) -> Self { + self.args.0.if_match = Some(s.to_string()); + self + } } diff --git a/core/core/src/types/options.rs b/core/core/src/types/options.rs index 4ef109c61a29..0d4a649a8549 100644 --- a/core/core/src/types/options.rs +++ b/core/core/src/types/options.rs @@ -21,10 +21,17 @@ use crate::raw::{BytesRange, Timestamp}; use std::collections::HashMap; /// Options for delete operations. -#[derive(Debug, Clone, Default, Eq, PartialEq)] +#[derive(Debug, Clone, Default)] pub struct DeleteOptions { /// The version of the file to delete. pub version: Option, + /// Set `if_match` for this operation. + /// + /// This option can be used to check if the file's `ETag` matches the given `ETag`. + /// + /// If file exists and it's etag doesn't match, an error with kind [`ErrorKind::ConditionNotMatch`] + /// will be returned. + pub if_match: Option, /// Whether to delete the target recursively. /// /// - If `false`, behaves like the traditional single-object delete. @@ -532,4 +539,12 @@ pub struct CopyOptions { /// This operation provides a way to ensure copy operations only create new resources /// without overwriting existing ones, useful for implementing "copy if not exists" logic. pub if_not_exists: bool, + + /// Set `if_match` for this operation. + /// + /// This option can be used to check if the source file's `ETag` matches the given `ETag`. + /// + /// If file exists and it's etag doesn't match, an error with kind [`ErrorKind::ConditionNotMatch`] + /// will be returned. + pub if_match: Option, } diff --git a/core/services/s3/src/backend.rs b/core/services/s3/src/backend.rs index d3ae6aa76787..7bc8254714ae 100644 --- a/core/services/s3/src/backend.rs +++ b/core/services/s3/src/backend.rs @@ -922,12 +922,16 @@ impl Builder for S3Builder { }, delete: true, + delete_with_if_match: true, delete_max_size: Some(delete_max_size), delete_with_version: config.enable_versioning, copy: true, + copy_with_if_not_exists: true, + copy_with_if_match: true, list: true, + rename: false, list_with_limit: true, list_with_start_after: true, list_with_recursive: true, @@ -1072,8 +1076,8 @@ impl Access for S3Backend { Ok((RpList::default(), l)) } - async fn copy(&self, from: &str, to: &str, _args: OpCopy) -> Result { - let resp = self.core.s3_copy_object(from, to).await?; + async fn copy(&self, from: &str, to: &str, args: OpCopy) -> Result { + let resp = self.core.s3_copy_object(from, to, args).await?; let status = resp.status(); diff --git a/core/services/s3/src/core.rs b/core/services/s3/src/core.rs index 03e14483a778..b2779f2988c6 100644 --- a/core/services/s3/src/core.rs +++ b/core/services/s3/src/core.rs @@ -613,6 +613,11 @@ impl S3Core { let mut req = Request::delete(&url); + // Add if_match condition using If-Match header + if let Some(if_match) = args.if_match() { + req = req.header(IF_MATCH, if_match); + } + // Set request payer header if enabled. req = self.insert_request_payer_header(req); @@ -625,7 +630,12 @@ impl S3Core { self.send(req).await } - pub async fn s3_copy_object(&self, from: &str, to: &str) -> Result> { + pub async fn s3_copy_object( + &self, + from: &str, + to: &str, + args: OpCopy, + ) -> Result> { let from = build_abs_path(&self.root, from); let to = build_abs_path(&self.root, to); @@ -673,6 +683,16 @@ impl S3Core { ) } + // Add if_not_exists condition using If-None-Match header + if args.if_not_exists() { + req = req.header(IF_NONE_MATCH, "*"); + } + + // Add if_match condition using If-Match header + if let Some(if_match) = args.if_match() { + req = req.header(IF_MATCH, 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 58b7c3138655..e4e960c28125 100644 --- a/core/tests/behavior/async_copy.rs +++ b/core/tests/behavior/async_copy.rs @@ -45,6 +45,14 @@ pub fn tests(op: &Operator, tests: &mut Vec) { test_copy_with_if_not_exists_to_existing_file )) } + + if cap.read && cap.write && cap.copy && cap.copy_with_if_match { + tests.extend(async_trials!( + op, + test_copy_with_if_match_success, + test_copy_with_if_match_failure + )) + } } /// Copy a file with ascii name and test contents. @@ -313,3 +321,68 @@ pub async fn test_copy_with_if_not_exists_to_existing_file(op: Operator) -> Resu op.delete(&target_path).await.expect("delete must succeed"); Ok(()) } + +/// Copy with if_match should succeed when ETag matches. +pub async fn test_copy_with_if_match_success(op: Operator) -> Result<()> { + if !op.info().full_capability().copy_with_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?; + + // Get the ETag of the source file + let source_meta = op.stat(&source_path).await?; + let etag = source_meta.etag().expect("source should have etag"); + + let target_path = uuid::Uuid::new_v4().to_string(); + + // Copy with matching ETag should succeed + op.copy_with(&source_path, &target_path) + .if_match(etag) + .await?; + + let target_content = op + .read(&target_path) + .await + .expect("read must succeed") + .to_bytes(); + assert_eq!( + format!("{:x}", Sha256::digest(target_content)), + format!("{:x}", 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 if_match should fail when ETag doesn't match. +pub async fn test_copy_with_if_match_failure(op: Operator) -> Result<()> { + if !op.info().full_capability().copy_with_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(); + + // Copy with non-matching ETag should fail + let err = op + .copy_with(&source_path, &target_path) + .if_match("invalid-etag") + .await + .expect_err("copy must fail"); + assert_eq!(err.kind(), ErrorKind::ConditionNotMatch); + + // Verify target file was not created + assert!(!op.exists(&target_path).await?); + + op.delete(&source_path).await.expect("delete must succeed"); + Ok(()) +} diff --git a/core/tests/behavior/async_delete.rs b/core/tests/behavior/async_delete.rs index b1584badd923..fdb7b420829f 100644 --- a/core/tests/behavior/async_delete.rs +++ b/core/tests/behavior/async_delete.rs @@ -48,6 +48,13 @@ pub fn tests(op: &Operator, tests: &mut Vec) { tests.extend(async_trials!(op, test_remove_all_with_prefix_exists)); } } + if cap.delete_with_if_match { + tests.extend(async_trials!( + op, + test_delete_with_if_match_success, + test_delete_with_if_match_failure + )); + } } } @@ -381,3 +388,53 @@ pub async fn test_batch_delete_with_version(op: Operator) -> Result<()> { Ok(()) } + +/// Delete with if_match should succeed when ETag matches. +pub async fn test_delete_with_if_match_success(op: Operator) -> Result<()> { + if !op.info().full_capability().delete_with_if_match { + return Ok(()); + } + + let (path, content, _) = TEST_FIXTURE.new_file(op.clone()); + + op.write(&path, content).await.expect("write must succeed"); + + // Get the ETag of the file + let meta = op.stat(&path).await.expect("stat must succeed"); + let etag = meta.etag().expect("file should have etag"); + + // Delete with matching ETag should succeed + op.delete_with(&path).if_match(etag).await?; + + // Verify file is deleted + assert!(!op.exists(&path).await?); + + Ok(()) +} + +/// Delete with if_match should fail when ETag doesn't match. +pub async fn test_delete_with_if_match_failure(op: Operator) -> Result<()> { + if !op.info().full_capability().delete_with_if_match { + return Ok(()); + } + + let (path, content, _) = TEST_FIXTURE.new_file(op.clone()); + + op.write(&path, content).await.expect("write must succeed"); + + // Delete with non-matching ETag should fail + let err = op + .delete_with(&path) + .if_match("invalid-etag") + .await + .expect_err("delete must fail"); + assert_eq!(err.kind(), ErrorKind::ConditionNotMatch); + + // Verify file still exists + assert!(op.exists(&path).await?); + + // Clean up + op.delete(&path).await.expect("delete must succeed"); + + Ok(()) +}