diff --git a/rustfs/src/storage/ecfs.rs b/rustfs/src/storage/ecfs.rs index 8b83d1053..132bfd26a 100644 --- a/rustfs/src/storage/ecfs.rs +++ b/rustfs/src/storage/ecfs.rs @@ -34,6 +34,10 @@ use crate::storage::s3_api::multipart::{ use crate::storage::s3_api::response::{ access_denied_error, map_abort_multipart_upload_error, not_initialized_error, s3_response, }; +use crate::storage::s3_api::tagging::{ + build_delete_object_tagging_output, build_get_bucket_tagging_output, build_get_object_tagging_output, + build_put_object_tagging_output, validate_object_tag_set, +}; use crate::storage::sse::{ DecryptionRequest, EncryptionRequest, PrepareEncryptionRequest, check_encryption_metadata, sse_decryption, sse_encryption, sse_prepare_encryption, strip_managed_encryption_metadata, @@ -1627,7 +1631,7 @@ impl S3 for FS { let version_id_resp = version_id.clone().unwrap_or_default(); helper = helper.version_id(version_id_resp); - let result = Ok(s3_response(DeleteObjectTaggingOutput { version_id })); + let result = Ok(s3_response(build_delete_object_tagging_output(version_id))); let _ = helper.complete(&result); let duration = start_time.elapsed(); histogram!("rustfs.object_tagging.operation.duration.seconds", "operation" => "delete").record(duration.as_secs_f64()); @@ -2291,7 +2295,7 @@ impl S3 for FS { } }; - Ok(s3_response(GetBucketTaggingOutput { tag_set })) + Ok(s3_response(build_get_bucket_tagging_output(tag_set))) } #[instrument(level = "debug", skip(self))] @@ -3080,7 +3084,12 @@ impl S3 for FS { #[instrument(level = "debug", skip(self))] async fn get_object_tagging(&self, req: S3Request) -> S3Result> { let start_time = std::time::Instant::now(); - let GetObjectTaggingInput { bucket, key: object, .. } = req.input; + let GetObjectTaggingInput { + bucket, + key: object, + version_id, + .. + } = req.input; info!("Starting get_object_tagging for bucket: {}, object: {}", bucket, object); @@ -3090,9 +3099,8 @@ impl S3 for FS { }; // Support versioned objects - let version_id = req.input.version_id.clone(); let opts = ObjectOptions { - version_id: self.parse_version_id(version_id)?.map(Into::into), + version_id: self.parse_version_id(version_id.clone())?.map(Into::into), ..Default::default() }; @@ -3112,10 +3120,7 @@ impl S3 for FS { counter!("rustfs.get_object_tagging.success").increment(1); let duration = start_time.elapsed(); histogram!("rustfs.object_tagging.operation.duration.seconds", "operation" => "put").record(duration.as_secs_f64()); - Ok(s3_response(GetObjectTaggingOutput { - tag_set, - version_id: req.input.version_id.clone(), - })) + Ok(s3_response(build_get_object_tagging_output(tag_set, version_id))) } #[instrument(level = "debug", skip(self, _req))] @@ -4277,49 +4282,15 @@ impl S3 for FS { .. } = req.input.clone(); - if tagging.tag_set.len() > 10 { - // TOTO: Note that Amazon S3 limits the maximum number of tags to 10 tags per object. - // Reference: https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-tagging.html - // Reference: https://docs.aws.amazon.com/zh_cn/AmazonS3/latest/API/API_PutObjectTagging.html - // https://github.com/minio/mint/blob/master/run/core/aws-sdk-go-v2/main.go#L1647 - error!("Tag set exceeds maximum of 10 tags: {}", tagging.tag_set.len()); - return Err(s3_error!(InvalidTag, "Cannot have more than 10 tags per object")); + if let Err(err) = validate_object_tag_set(&tagging.tag_set) { + error!("Invalid object tags for bucket: {}, object: {}: {}", bucket, object, err); + return Err(err); } let Some(store) = new_object_layer_fn() else { return Err(not_initialized_error()); }; - let mut tag_keys = std::collections::HashSet::with_capacity(tagging.tag_set.len()); - for tag in &tagging.tag_set { - let key = tag.key.as_ref().filter(|k| !k.is_empty()).ok_or_else(|| { - error!("Empty tag key"); - s3_error!(InvalidTag, "Tag key cannot be empty") - })?; - - if key.len() > 128 { - error!("Tag key too long: {} bytes", key.len()); - return Err(s3_error!(InvalidTag, "Tag key is too long, maximum allowed length is 128 characters")); - } - - // allow to set the value of a tag to an empty string, but cannot set it to a null value. - // Reference:https://docs.aws.amazon.com/zh_cn/AWSEC2/latest/UserGuide/Using_Tags.html#tag-restrictions - let value = tag.value.as_ref().ok_or_else(|| { - error!("Null tag value"); - s3_error!(InvalidTag, "Tag value cannot be null") - })?; - - if value.len() > 256 { - error!("Tag value too long: {} bytes", value.len()); - return Err(s3_error!(InvalidTag, "Tag value is too long, maximum allowed length is 256 characters")); - } - - if !tag_keys.insert(key) { - error!("Duplicate tag key: {}", key); - return Err(s3_error!(InvalidTag, "Cannot provide multiple Tags with the same key")); - } - } - let tags = encode_tags(tagging.tag_set); debug!("Encoded tags: {}", tags); @@ -4354,9 +4325,7 @@ impl S3 for FS { let version_id_resp = req.input.version_id.clone().unwrap_or_default(); helper = helper.version_id(version_id_resp); - let result = Ok(s3_response(PutObjectTaggingOutput { - version_id: req.input.version_id.clone(), - })); + let result = Ok(s3_response(build_put_object_tagging_output(req.input.version_id.clone()))); let _ = helper.complete(&result); let duration = start_time.elapsed(); histogram!("rustfs.object_tagging.operation.duration.seconds", "operation" => "put").record(duration.as_secs_f64()); diff --git a/rustfs/src/storage/s3_api/mod.rs b/rustfs/src/storage/s3_api/mod.rs index 75f03fc86..f529f2ea1 100644 --- a/rustfs/src/storage/s3_api/mod.rs +++ b/rustfs/src/storage/s3_api/mod.rs @@ -32,4 +32,5 @@ pub(crate) mod replication {} pub(crate) mod response; pub(crate) mod restore {} pub(crate) mod select {} +pub(crate) mod tagging; pub(crate) mod validation {} diff --git a/rustfs/src/storage/s3_api/tagging.rs b/rustfs/src/storage/s3_api/tagging.rs new file mode 100644 index 000000000..1b3cdf2ac --- /dev/null +++ b/rustfs/src/storage/s3_api/tagging.rs @@ -0,0 +1,166 @@ +// 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. + +use s3s::dto::{DeleteObjectTaggingOutput, GetBucketTaggingOutput, GetObjectTaggingOutput, PutObjectTaggingOutput, Tag}; +use s3s::{S3Error, S3ErrorCode, S3Result}; +use std::collections::HashSet; + +fn invalid_tag_error(message: &str) -> S3Error { + S3Error::with_message(S3ErrorCode::InvalidTag, message.to_string()) +} + +pub(crate) fn validate_object_tag_set(tag_set: &[Tag]) -> S3Result<()> { + if tag_set.len() > 10 { + return Err(invalid_tag_error("Cannot have more than 10 tags per object")); + } + + let mut tag_keys = HashSet::with_capacity(tag_set.len()); + for tag in tag_set { + let key = tag + .key + .as_deref() + .filter(|key| !key.is_empty()) + .ok_or_else(|| invalid_tag_error("Tag key cannot be empty"))?; + + if key.len() > 128 { + return Err(invalid_tag_error("Tag key is too long, maximum allowed length is 128 characters")); + } + + let value = tag + .value + .as_deref() + .ok_or_else(|| invalid_tag_error("Tag value cannot be null"))?; + + if value.len() > 256 { + return Err(invalid_tag_error("Tag value is too long, maximum allowed length is 256 characters")); + } + + if !tag_keys.insert(key) { + return Err(invalid_tag_error("Cannot provide multiple Tags with the same key")); + } + } + + Ok(()) +} + +pub(crate) fn build_get_bucket_tagging_output(tag_set: Vec) -> GetBucketTaggingOutput { + GetBucketTaggingOutput { tag_set } +} + +pub(crate) fn build_get_object_tagging_output(tag_set: Vec, version_id: Option) -> GetObjectTaggingOutput { + GetObjectTaggingOutput { tag_set, version_id } +} + +pub(crate) fn build_put_object_tagging_output(version_id: Option) -> PutObjectTaggingOutput { + PutObjectTaggingOutput { version_id } +} + +pub(crate) fn build_delete_object_tagging_output(version_id: Option) -> DeleteObjectTaggingOutput { + DeleteObjectTaggingOutput { version_id } +} + +#[cfg(test)] +mod tests { + use super::{ + build_delete_object_tagging_output, build_get_bucket_tagging_output, build_get_object_tagging_output, + build_put_object_tagging_output, validate_object_tag_set, + }; + use s3s::S3ErrorCode; + use s3s::dto::Tag; + + fn tag(key: Option<&str>, value: Option<&str>) -> Tag { + Tag { + key: key.map(ToOwned::to_owned), + value: value.map(ToOwned::to_owned), + } + } + + #[test] + fn test_validate_object_tag_set_accepts_valid_tag_set() { + let tag_set = vec![tag(Some("k1"), Some("v1")), tag(Some("k2"), Some(""))]; + let result = validate_object_tag_set(&tag_set); + assert!(result.is_ok()); + } + + #[test] + fn test_validate_object_tag_set_rejects_more_than_ten_tags() { + let tag_set: Vec = (0..11).map(|i| tag(Some(&format!("k{i}")), Some(&format!("v{i}")))).collect(); + + let err = validate_object_tag_set(&tag_set).expect_err("tag set with more than ten tags must be rejected"); + assert_eq!(*err.code(), S3ErrorCode::InvalidTag); + assert!(err.to_string().contains("Cannot have more than 10 tags per object")); + } + + #[test] + fn test_validate_object_tag_set_rejects_empty_tag_key() { + let err = validate_object_tag_set(&[tag(Some(""), Some("v1"))]).expect_err("empty tag key must be rejected"); + assert_eq!(*err.code(), S3ErrorCode::InvalidTag); + assert!(err.to_string().contains("Tag key cannot be empty")); + } + + #[test] + fn test_validate_object_tag_set_rejects_too_long_tag_key() { + let long_key = "k".repeat(129); + let err = validate_object_tag_set(&[tag(Some(&long_key), Some("v1"))]).expect_err("too long tag key must be rejected"); + assert_eq!(*err.code(), S3ErrorCode::InvalidTag); + assert!( + err.to_string() + .contains("Tag key is too long, maximum allowed length is 128 characters") + ); + } + + #[test] + fn test_validate_object_tag_set_rejects_null_tag_value() { + let err = validate_object_tag_set(&[tag(Some("k1"), None)]).expect_err("null tag value must be rejected"); + assert_eq!(*err.code(), S3ErrorCode::InvalidTag); + assert!(err.to_string().contains("Tag value cannot be null")); + } + + #[test] + fn test_validate_object_tag_set_rejects_too_long_tag_value() { + let long_value = "v".repeat(257); + let err = + validate_object_tag_set(&[tag(Some("k1"), Some(&long_value))]).expect_err("too long tag value must be rejected"); + assert_eq!(*err.code(), S3ErrorCode::InvalidTag); + assert!( + err.to_string() + .contains("Tag value is too long, maximum allowed length is 256 characters") + ); + } + + #[test] + fn test_validate_object_tag_set_rejects_duplicate_tag_key() { + let err = validate_object_tag_set(&[tag(Some("k1"), Some("v1")), tag(Some("k1"), Some("v2"))]) + .expect_err("duplicate tag key must be rejected"); + assert_eq!(*err.code(), S3ErrorCode::InvalidTag); + assert!(err.to_string().contains("Cannot provide multiple Tags with the same key")); + } + + #[test] + fn test_build_tagging_outputs_preserve_fields() { + let tag_set = vec![tag(Some("k1"), Some("v1"))]; + let version_id = Some("vid-1".to_string()); + + let bucket_output = build_get_bucket_tagging_output(tag_set.clone()); + let get_object_output = build_get_object_tagging_output(tag_set.clone(), version_id.clone()); + let put_object_output = build_put_object_tagging_output(version_id.clone()); + let delete_object_output = build_delete_object_tagging_output(version_id.clone()); + + assert_eq!(bucket_output.tag_set, tag_set); + assert_eq!(get_object_output.tag_set, vec![tag(Some("k1"), Some("v1"))]); + assert_eq!(get_object_output.version_id, version_id); + assert_eq!(put_object_output.version_id, Some("vid-1".to_string())); + assert_eq!(delete_object_output.version_id, Some("vid-1".to_string())); + } +}