mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-09 06:39:25 +00:00
fix(s3): honor CopyObject tagging directives (#5165)
Closes rustfs/backlog#1462.
This commit is contained in:
@@ -179,6 +179,7 @@ uuid = { workspace = true, features = ["v4", "fast-rng", "macro-diagnostics"] }
|
||||
zip = { workspace = true }
|
||||
libc = { workspace = true }
|
||||
rand = { workspace = true, features = ["serde"] }
|
||||
regex = { workspace = true }
|
||||
aes-gcm = { workspace = true, features = ["rand_core"] }
|
||||
chacha20poly1305 = { workspace = true }
|
||||
|
||||
|
||||
@@ -160,7 +160,7 @@ use s3s::dto::{
|
||||
ObjectLockLegalHoldStatus, ObjectLockMode, ObjectLockRetention, ObjectLockRetentionMode, ObjectPart, PutObjectInput,
|
||||
PutObjectOutput, Range, RequestCharged, RestoreObjectInput, RestoreObjectOutput, RestoreStatus, SSECustomerAlgorithm,
|
||||
SSECustomerKeyMD5, SSEKMSKeyId, SelectObjectContentInput, SelectObjectContentOutput, ServerSideEncryption, StorageClass,
|
||||
StreamingBlob, TaggingHeader, Timestamp, TimestampFormat, WebsiteRedirectLocation,
|
||||
StreamingBlob, TaggingDirective, TaggingHeader, Timestamp, TimestampFormat, WebsiteRedirectLocation,
|
||||
};
|
||||
use s3s::header::{X_AMZ_RESTORE, X_AMZ_RESTORE_OUTPUT_PATH};
|
||||
use s3s::stream::{ByteStream, DynByteStream, RemainingLength};
|
||||
@@ -5736,6 +5736,8 @@ impl DefaultObjectUsecase {
|
||||
copy_source_sse_customer_key_md5,
|
||||
metadata_directive,
|
||||
metadata,
|
||||
tagging,
|
||||
tagging_directive,
|
||||
copy_source_if_match,
|
||||
copy_source_if_none_match,
|
||||
content_type,
|
||||
@@ -5787,7 +5789,13 @@ impl DefaultObjectUsecase {
|
||||
// when an explicit storage class change is requested, or when restoring a specific historical
|
||||
// version onto the current key (source carries a versionId). Reject only a true no-op self-copy
|
||||
// where none of these apply (issue #4238).
|
||||
let replacement_tags = super::storage_api::object_usecase::s3_api::tagging::resolve_copy_object_tags(
|
||||
tagging.as_deref(),
|
||||
tagging_directive.as_ref(),
|
||||
)?;
|
||||
|
||||
if metadata_directive.as_ref().map(|d| d.as_str()) != Some(MetadataDirective::REPLACE)
|
||||
&& tagging_directive.as_ref().map(TaggingDirective::as_str) != Some(TaggingDirective::REPLACE)
|
||||
&& storage_class.is_none()
|
||||
&& version_id.is_none()
|
||||
&& src_bucket == bucket
|
||||
@@ -5940,6 +5948,7 @@ impl DefaultObjectUsecase {
|
||||
|
||||
// Extract user_defined from Arc for mutation; it will be re-wrapped after all edits.
|
||||
let mut user_defined = (*src_info.user_defined).clone();
|
||||
let effective_tags = replacement_tags.unwrap_or_else(|| (*src_info.user_tags).clone());
|
||||
|
||||
strip_managed_encryption_metadata(&mut user_defined);
|
||||
|
||||
@@ -5983,6 +5992,12 @@ impl DefaultObjectUsecase {
|
||||
}
|
||||
}
|
||||
|
||||
user_defined.retain(|key, _| !key.eq_ignore_ascii_case(AMZ_OBJECT_TAGGING));
|
||||
if !effective_tags.is_empty() {
|
||||
user_defined.insert(AMZ_OBJECT_TAGGING.to_string(), effective_tags.clone());
|
||||
}
|
||||
src_info.user_tags = Arc::new(effective_tags);
|
||||
|
||||
let has_explicit_object_lock_retention = object_lock_mode.is_some() || object_lock_retain_until_date.is_some();
|
||||
remove_object_lock_metadata_for_copy(&mut user_defined);
|
||||
if let Some(object_lock_metadata) = build_put_like_object_lock_metadata(
|
||||
|
||||
@@ -928,6 +928,10 @@ pub(crate) mod s3_api {
|
||||
parse_list_multipart_uploads_params, parse_list_parts_params, parse_upload_part_number,
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) mod tagging {
|
||||
pub(crate) use crate::storage::storage_api::s3_api_consumer::tagging::resolve_copy_object_tags;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) mod admin_usecase {
|
||||
|
||||
@@ -24,6 +24,7 @@ use crate::server::RemoteAddr;
|
||||
use crate::storage::request_context::RequestContext;
|
||||
use crate::storage::storage_api::access_consumer::contract::bucket::BucketOperations;
|
||||
use crate::storage::storage_api::runtime_sources_consumer::runtime_sources;
|
||||
use http::HeaderMap;
|
||||
use metrics::counter;
|
||||
use rustfs_iam::error::Error as IamError;
|
||||
use rustfs_policy::policy::action::{Action, AdminAction, S3Action};
|
||||
@@ -246,6 +247,57 @@ fn merge_list_bucket_query_conditions(action: Action, query: Option<&str>, condi
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_request_object_tag_conditions(
|
||||
action: Action,
|
||||
headers: &HeaderMap,
|
||||
conditions: &mut HashMap<String, Vec<String>>,
|
||||
) -> S3Result<()> {
|
||||
if action != Action::S3Action(S3Action::PutObjectAction) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Some(tagging) = headers.get("x-amz-tagging").and_then(|value| value.to_str().ok()) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let mut tag_keys = Vec::new();
|
||||
for tag in crate::storage::s3_api::tagging::parse_object_tag_header(tagging)? {
|
||||
let Some(key) = tag.key else {
|
||||
continue;
|
||||
};
|
||||
let Some(value) = tag.value else {
|
||||
continue;
|
||||
};
|
||||
tag_keys.push(key.clone());
|
||||
conditions.entry(format!("RequestObjectTag/{key}")).or_default().push(value);
|
||||
}
|
||||
conditions.insert("RequestObjectTagKeys".to_string(), tag_keys);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn authorization_conditions<T>(
|
||||
req: &S3Request<T>,
|
||||
cred: &rustfs_credentials::Credentials,
|
||||
version_id: Option<&str>,
|
||||
region: Option<s3s::region::Region>,
|
||||
remote_addr: Option<std::net::SocketAddr>,
|
||||
client_info: Option<&ClientInfo>,
|
||||
action: Action,
|
||||
) -> S3Result<HashMap<String, Vec<String>>> {
|
||||
let mut conditions = get_condition_values_with_query_and_client_info(
|
||||
&req.headers,
|
||||
cred,
|
||||
version_id,
|
||||
region,
|
||||
remote_addr,
|
||||
req.uri.query(),
|
||||
client_info,
|
||||
);
|
||||
merge_list_bucket_query_conditions(action, req.uri.query(), &mut conditions);
|
||||
merge_request_object_tag_conditions(action, &req.headers, &mut conditions)?;
|
||||
Ok(conditions)
|
||||
}
|
||||
|
||||
fn auth_fs() -> &'static FS {
|
||||
static AUTH_FS: OnceLock<FS> = OnceLock::new();
|
||||
AUTH_FS.get_or_init(FS::new)
|
||||
@@ -358,16 +410,7 @@ pub async fn authorize_request<T>(req: &mut S3Request<T>, action: Action) -> S3R
|
||||
let default_claims = HashMap::new();
|
||||
let claims = cred.claims.as_ref().unwrap_or(&default_claims);
|
||||
let client_info = req.extensions.get::<ClientInfo>();
|
||||
let mut conditions = get_condition_values_with_query_and_client_info(
|
||||
&req.headers,
|
||||
cred,
|
||||
version_id.as_deref(),
|
||||
None,
|
||||
remote_addr,
|
||||
req.uri.query(),
|
||||
client_info,
|
||||
);
|
||||
merge_list_bucket_query_conditions(action, req.uri.query(), &mut conditions);
|
||||
let mut conditions = authorization_conditions(req, cred, version_id.as_deref(), None, remote_addr, client_info, action)?;
|
||||
|
||||
let action_args = Args {
|
||||
account: &cred.access_key,
|
||||
@@ -579,16 +622,15 @@ pub async fn authorize_request<T>(req: &mut S3Request<T>, action: Action) -> S3R
|
||||
} else {
|
||||
let default_cred = rustfs_credentials::Credentials::default();
|
||||
let client_info = req.extensions.get::<ClientInfo>();
|
||||
let mut conditions = get_condition_values_with_query_and_client_info(
|
||||
&req.headers,
|
||||
let mut conditions = authorization_conditions(
|
||||
req,
|
||||
&default_cred,
|
||||
version_id.as_deref(),
|
||||
req.region.clone(),
|
||||
remote_addr,
|
||||
req.uri.query(),
|
||||
client_info,
|
||||
);
|
||||
merge_list_bucket_query_conditions(action, req.uri.query(), &mut conditions);
|
||||
action,
|
||||
)?;
|
||||
|
||||
let no_groups: Option<Vec<String>> = None;
|
||||
let bucket_tag_hint = if !bucket.is_empty() && !object.is_empty() {
|
||||
@@ -2105,12 +2147,12 @@ mod tests {
|
||||
bucket_policy_needs_existing_object_tag_from_hint, classify_bucket_policy_raw_load_error,
|
||||
complete_multipart_upload_authorize_action, get_bucket_policy_authorize_action, has_write_offset_bytes_header,
|
||||
legal_hold_write_requested, list_parts_authorize_action, load_bucket_policy_existing_object_tag_hint,
|
||||
merge_list_bucket_query_conditions, owner_can_bypass_policy_deny, post_object_authorize_action,
|
||||
put_bucket_policy_authorize_action, request_context_from_req, retention_write_requested, secondary_tag_hint_action,
|
||||
table_data_plane_admin_action, validate_post_object_success_controls, versioned_read_action,
|
||||
merge_list_bucket_query_conditions, merge_request_object_tag_conditions, owner_can_bypass_policy_deny,
|
||||
post_object_authorize_action, put_bucket_policy_authorize_action, request_context_from_req, retention_write_requested,
|
||||
secondary_tag_hint_action, table_data_plane_admin_action, validate_post_object_success_controls, versioned_read_action,
|
||||
};
|
||||
use crate::error::ApiError;
|
||||
use http::{Extensions, HeaderMap, Method, Uri};
|
||||
use http::{Extensions, HeaderMap, HeaderValue, Method, Uri};
|
||||
use rustfs_policy::policy::action::{Action, S3Action};
|
||||
use rustfs_policy::policy::{BucketPolicy, bucket_policy_uses_existing_object_tag_conditions};
|
||||
use s3s::{S3ErrorCode, S3Request, dto::*};
|
||||
@@ -2323,6 +2365,33 @@ mod tests {
|
||||
assert!(conditions.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merge_request_object_tag_conditions_applies_only_to_put_object() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-amz-tagging", HeaderValue::from_static("classification=restricted&label=copy%20test"));
|
||||
|
||||
let mut source_conditions = HashMap::new();
|
||||
merge_request_object_tag_conditions(Action::S3Action(S3Action::GetObjectAction), &headers, &mut source_conditions)
|
||||
.expect("GetObject condition construction should succeed");
|
||||
assert!(
|
||||
source_conditions.is_empty(),
|
||||
"destination request tags must not affect CopyObject source authorization"
|
||||
);
|
||||
|
||||
let mut destination_conditions = HashMap::new();
|
||||
merge_request_object_tag_conditions(Action::S3Action(S3Action::PutObjectAction), &headers, &mut destination_conditions)
|
||||
.expect("PutObject condition construction should succeed");
|
||||
assert_eq!(
|
||||
destination_conditions.get("RequestObjectTag/classification"),
|
||||
Some(&vec!["restricted".to_string()])
|
||||
);
|
||||
assert_eq!(destination_conditions.get("RequestObjectTag/label"), Some(&vec!["copy test".to_string()]));
|
||||
assert_eq!(
|
||||
destination_conditions.get("RequestObjectTagKeys"),
|
||||
Some(&vec!["classification".to_string(), "label".to_string()])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_object_tag_conditions_key_format() {
|
||||
let mut tags = HashMap::new();
|
||||
|
||||
@@ -12,14 +12,27 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use s3s::dto::Tag;
|
||||
use percent_encoding::percent_decode_str;
|
||||
use s3s::dto::{Tag, TaggingDirective};
|
||||
use s3s::{S3Error, S3ErrorCode, S3Result};
|
||||
use std::collections::HashSet;
|
||||
use std::sync::OnceLock;
|
||||
use url::form_urlencoded;
|
||||
|
||||
use crate::storage::storage_api::encode_tags;
|
||||
|
||||
fn invalid_tag_error(message: &str) -> S3Error {
|
||||
S3Error::with_message(S3ErrorCode::InvalidTag, message.to_string())
|
||||
}
|
||||
|
||||
fn is_valid_tag_text(value: &str) -> bool {
|
||||
static VALID_OBJECT_TAG_TEXT: OnceLock<Result<regex::Regex, regex::Error>> = OnceLock::new();
|
||||
VALID_OBJECT_TAG_TEXT
|
||||
.get_or_init(|| regex::Regex::new(r"^[\p{L}\p{N}\p{Z}+\-=._:/@]*$"))
|
||||
.as_ref()
|
||||
.is_ok_and(|pattern| pattern.is_match(value))
|
||||
}
|
||||
|
||||
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"));
|
||||
@@ -33,8 +46,8 @@ pub(crate) fn validate_object_tag_set(tag_set: &[Tag]) -> S3Result<()> {
|
||||
.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"));
|
||||
if key.encode_utf16().count() > 128 || !is_valid_tag_text(key) {
|
||||
return Err(invalid_tag_error("The TagKey you have provided is invalid"));
|
||||
}
|
||||
|
||||
let value = tag
|
||||
@@ -42,8 +55,8 @@ pub(crate) fn validate_object_tag_set(tag_set: &[Tag]) -> S3Result<()> {
|
||||
.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 value.encode_utf16().count() > 256 || !is_valid_tag_text(value) {
|
||||
return Err(invalid_tag_error("The TagValue you have provided is invalid"));
|
||||
}
|
||||
|
||||
if !tag_keys.insert(key) {
|
||||
@@ -54,11 +67,80 @@ pub(crate) fn validate_object_tag_set(tag_set: &[Tag]) -> S3Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_tag_component_encoding(component: &str) -> S3Result<()> {
|
||||
let bytes = component.as_bytes();
|
||||
let mut index = 0;
|
||||
while index < bytes.len() {
|
||||
if bytes[index] == b'%' {
|
||||
if index + 2 >= bytes.len() || !bytes[index + 1].is_ascii_hexdigit() || !bytes[index + 2].is_ascii_hexdigit() {
|
||||
return Err(invalid_tag_error("The Tagging header contains invalid percent encoding"));
|
||||
}
|
||||
index += 3;
|
||||
} else {
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
percent_decode_str(component)
|
||||
.decode_utf8()
|
||||
.map_err(|_| invalid_tag_error("The Tagging header contains invalid UTF-8"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn parse_object_tag_header(tagging: &str) -> S3Result<Vec<Tag>> {
|
||||
let mut tag_set = Vec::with_capacity(10);
|
||||
for pair in tagging.split('&').filter(|pair| !pair.is_empty()) {
|
||||
if tag_set.len() == 10 {
|
||||
return Err(invalid_tag_error("Cannot have more than 10 tags per object"));
|
||||
}
|
||||
|
||||
let (key, value) = pair.split_once('=').unwrap_or((pair, ""));
|
||||
validate_tag_component_encoding(key)?;
|
||||
validate_tag_component_encoding(value)?;
|
||||
let mut parsed = form_urlencoded::parse(pair.as_bytes());
|
||||
if let Some((key, value)) = parsed.next() {
|
||||
tag_set.push(Tag {
|
||||
key: Some(key.into_owned()),
|
||||
value: Some(value.into_owned()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
validate_object_tag_set(&tag_set)?;
|
||||
Ok(tag_set)
|
||||
}
|
||||
|
||||
pub(crate) fn parse_copy_object_tags(tagging: &str) -> S3Result<String> {
|
||||
parse_object_tag_header(tagging).map(encode_tags)
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_copy_object_tags(
|
||||
tagging: Option<&str>,
|
||||
tagging_directive: Option<&TaggingDirective>,
|
||||
) -> S3Result<Option<String>> {
|
||||
match tagging_directive.map(TaggingDirective::as_str) {
|
||||
None | Some(TaggingDirective::COPY) => {
|
||||
if tagging.is_some() {
|
||||
return Err(S3Error::with_message(
|
||||
S3ErrorCode::InvalidRequest,
|
||||
"The Tagging header requires the REPLACE tagging directive".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
Some(TaggingDirective::REPLACE) => parse_copy_object_tags(tagging.unwrap_or_default()).map(Some),
|
||||
Some(_) => Err(S3Error::with_message(
|
||||
S3ErrorCode::InvalidArgument,
|
||||
"The TaggingDirective header is invalid".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::validate_object_tag_set;
|
||||
use super::{parse_copy_object_tags, resolve_copy_object_tags, validate_object_tag_set};
|
||||
use s3s::S3ErrorCode;
|
||||
use s3s::dto::Tag;
|
||||
use s3s::dto::{Tag, TaggingDirective};
|
||||
|
||||
fn tag(key: Option<&str>, value: Option<&str>) -> Tag {
|
||||
Tag {
|
||||
@@ -95,10 +177,7 @@ mod tests {
|
||||
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")
|
||||
);
|
||||
assert!(err.to_string().contains("The TagKey you have provided is invalid"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -114,10 +193,7 @@ mod tests {
|
||||
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")
|
||||
);
|
||||
assert!(err.to_string().contains("The TagValue you have provided is invalid"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -127,4 +203,122 @@ mod tests {
|
||||
assert_eq!(*err.code(), S3ErrorCode::InvalidTag);
|
||||
assert!(err.to_string().contains("Cannot provide multiple Tags with the same key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_object_tag_set_accepts_maximum_lengths_and_allowed_characters() {
|
||||
let key = "𐐀".repeat(64);
|
||||
let value = format!("{}=", "v".repeat(255));
|
||||
|
||||
assert!(validate_object_tag_set(&[tag(Some(&key), Some(&value))]).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_object_tag_set_counts_utf16_code_units() {
|
||||
let too_long_key = "𐐀".repeat(65);
|
||||
let err = validate_object_tag_set(&[tag(Some(&too_long_key), Some("value"))])
|
||||
.expect_err("a key longer than 128 UTF-16 code units must be rejected");
|
||||
|
||||
assert_eq!(*err.code(), S3ErrorCode::InvalidTag);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_object_tag_set_rejects_unsupported_characters() {
|
||||
let key_err = validate_object_tag_set(&[tag(Some("invalid?key"), Some("value"))]).expect_err("unsupported key character");
|
||||
assert_eq!(*key_err.code(), S3ErrorCode::InvalidTag);
|
||||
|
||||
let value_err =
|
||||
validate_object_tag_set(&[tag(Some("key"), Some("invalid&value"))]).expect_err("unsupported value character");
|
||||
assert_eq!(*value_err.code(), S3ErrorCode::InvalidTag);
|
||||
|
||||
let combining_mark_err = validate_object_tag_set(&[tag(Some("invalid\u{0345}key"), Some("value"))])
|
||||
.expect_err("a combining mark outside the S3 letter/number/separator categories must be rejected");
|
||||
assert_eq!(*combining_mark_err.code(), S3ErrorCode::InvalidTag);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_copy_object_tags_accepts_encoded_tag_set() {
|
||||
let tags =
|
||||
parse_copy_object_tags("project=rustfs&label=copy%20test").expect("valid URL-encoded CopyObject tags should parse");
|
||||
|
||||
assert_eq!(tags, "project=rustfs&label=copy+test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_copy_object_tags_accepts_empty_replacement() {
|
||||
let tags = parse_copy_object_tags("").expect("an empty replacement tag set should parse");
|
||||
|
||||
assert!(tags.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_copy_object_tags_rejects_invalid_percent_encoding() {
|
||||
let err = parse_copy_object_tags("project=rustfs%ZZ")
|
||||
.expect_err("invalid percent encoding must be rejected before CopyObject writes");
|
||||
|
||||
assert_eq!(*err.code(), S3ErrorCode::InvalidTag);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_copy_object_tags_rejects_duplicate_keys() {
|
||||
let err = parse_copy_object_tags("project=rustfs&project=cli").expect_err("duplicate tag keys must be rejected");
|
||||
|
||||
assert_eq!(*err.code(), S3ErrorCode::InvalidTag);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_copy_object_tags_stops_at_object_tag_limit() {
|
||||
let tagging = (0..11)
|
||||
.map(|index| format!("k{index}=v{index}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("&");
|
||||
let err = parse_copy_object_tags(&tagging).expect_err("the eleventh object tag 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_parse_copy_object_tags_rejects_control_whitespace() {
|
||||
let err = parse_copy_object_tags("project=line%0Abreak").expect_err("control whitespace is not valid in S3 object tags");
|
||||
|
||||
assert_eq!(*err.code(), S3ErrorCode::InvalidTag);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_copy_object_tags_preserves_source_for_default_and_copy() {
|
||||
assert_eq!(
|
||||
resolve_copy_object_tags(None, None).expect("default directive should preserve source tags"),
|
||||
None
|
||||
);
|
||||
let copy = TaggingDirective::from_static(TaggingDirective::COPY);
|
||||
assert_eq!(
|
||||
resolve_copy_object_tags(None, Some(©)).expect("COPY directive should preserve source tags"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_copy_object_tags_replaces_with_exact_requested_set() {
|
||||
let replace = TaggingDirective::from_static(TaggingDirective::REPLACE);
|
||||
assert_eq!(
|
||||
resolve_copy_object_tags(Some("project=rustfs"), Some(&replace))
|
||||
.expect("REPLACE directive should accept a valid tag set"),
|
||||
Some("project=rustfs".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_copy_object_tags(None, Some(&replace)).expect("REPLACE without Tagging should clear tags"),
|
||||
Some(String::new())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_copy_object_tags_rejects_discarded_or_unknown_requests() {
|
||||
let err = resolve_copy_object_tags(Some("project=rustfs"), None)
|
||||
.expect_err("a Tagging header without REPLACE must not be silently discarded");
|
||||
assert_eq!(*err.code(), S3ErrorCode::InvalidRequest);
|
||||
|
||||
let unknown = TaggingDirective::from_static("UNKNOWN");
|
||||
let err = resolve_copy_object_tags(None, Some(&unknown)).expect_err("an unknown tagging directive must fail");
|
||||
assert_eq!(*err.code(), S3ErrorCode::InvalidArgument);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,6 +311,10 @@ pub(crate) mod s3_api_consumer {
|
||||
super::super::to_s3s_etag(etag)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) mod tagging {
|
||||
pub(crate) use super::super::super::s3_api::tagging::resolve_copy_object_tags;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) mod sse_consumer {
|
||||
|
||||
Reference in New Issue
Block a user