Files
rustfs/crates/replication/src/object.rs
T

271 lines
9.7 KiB
Rust

// 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 crate::filemeta::{ReplicationAction, ReplicationType};
use crate::http::{
AMZ_OBJECT_LOCK_LEGAL_HOLD, AMZ_OBJECT_LOCK_MODE, AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE, AMZ_OBJECT_TAGGING,
AMZ_WEBSITE_REDIRECT_LOCATION, CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_ENCODING, CONTENT_LANGUAGE, EXPIRES,
has_prefix_fold, trim_etag,
};
use crate::tagging::ReplicationTagFilter;
use std::collections::HashMap;
use time::OffsetDateTime;
const AMZ_META_PREFIX: &str = "X-Amz-Meta-";
const CONTENT_ENCODING_LOWER: &str = "content-encoding";
const REPLICATION_METADATA_COMPARE_KEYS: [&str; 9] = [
EXPIRES,
CACHE_CONTROL,
CONTENT_LANGUAGE,
CONTENT_DISPOSITION,
AMZ_OBJECT_LOCK_MODE,
AMZ_OBJECT_LOCK_RETAIN_UNTIL_DATE,
AMZ_OBJECT_LOCK_LEGAL_HOLD,
AMZ_WEBSITE_REDIRECT_LOCATION,
AMZ_META_PREFIX,
];
#[derive(Debug, Clone)]
pub struct ReplicationSourceObject<'a> {
pub mod_time: Option<OffsetDateTime>,
pub version_id: Option<String>,
pub etag: Option<&'a str>,
pub actual_size: i64,
pub delete_marker: bool,
pub content_type: Option<&'a str>,
pub content_encoding: Option<&'a str>,
pub user_tags: &'a str,
pub user_defined: &'a HashMap<String, String>,
}
#[derive(Debug, Clone)]
pub struct ReplicationTargetObject<'a> {
pub last_modified: Option<OffsetDateTime>,
pub version_id: Option<&'a str>,
pub etag: Option<&'a str>,
pub content_length: i64,
pub delete_marker: bool,
pub content_type: Option<&'a str>,
pub metadata: Option<&'a HashMap<String, String>>,
pub tag_count: i32,
}
pub fn content_matches_by_etag(source: &ReplicationSourceObject<'_>, target: &ReplicationTargetObject<'_>) -> bool {
replication_etags_match(source.etag, target.etag)
}
pub fn replication_etags_match(source: Option<&str>, target: Option<&str>) -> bool {
let source_etag = source.map(trim_etag);
let target_etag = target.map(trim_etag);
source_etag.is_some() && source_etag == target_etag
}
pub fn target_is_newer_than_source_null_version(
source: &ReplicationSourceObject<'_>,
target: &ReplicationTargetObject<'_>,
) -> bool {
target
.last_modified
.is_some_and(|target_mod_time| target_mod_time > source.mod_time.unwrap_or(OffsetDateTime::UNIX_EPOCH))
&& source.version_id.is_none()
}
pub fn replication_action_for_target(
source: &ReplicationSourceObject<'_>,
target: &ReplicationTargetObject<'_>,
op_type: ReplicationType,
) -> ReplicationAction {
if op_type == ReplicationType::ExistingObject && target_is_newer_than_source_null_version(source, target) {
return ReplicationAction::None;
}
if source.etag.map(trim_etag) != target.etag.map(trim_etag)
|| source.version_id.as_deref() != target.version_id
|| source.actual_size != target.content_length
|| source.delete_marker != target.delete_marker
|| source.mod_time != target.last_modified
{
return ReplicationAction::All;
}
if source.content_type != target.content_type {
return ReplicationAction::Metadata;
}
if content_encoding_differs(source, target) {
return ReplicationAction::Metadata;
}
if tag_metadata_differs(source, target) {
return ReplicationAction::Metadata;
}
if comparable_metadata(Some(source.user_defined)) != comparable_metadata(target.metadata) {
return ReplicationAction::Metadata;
}
ReplicationAction::None
}
fn content_encoding_differs(source: &ReplicationSourceObject<'_>, target: &ReplicationTargetObject<'_>) -> bool {
if let Some(content_encoding) = source.content_encoding {
return target
.metadata
.and_then(|metadata| {
metadata
.get(CONTENT_ENCODING)
.or_else(|| metadata.get(CONTENT_ENCODING_LOWER))
})
.is_none_or(|enc| enc != content_encoding);
}
false
}
fn tag_metadata_differs(source: &ReplicationSourceObject<'_>, target: &ReplicationTargetObject<'_>) -> bool {
let source_tags = ReplicationTagFilter::decode_tags_to_map(source.user_tags);
let target_tagging = target
.metadata
.and_then(|metadata| metadata.get(AMZ_OBJECT_TAGGING).map(String::as_str))
.unwrap_or_default();
let target_tags = ReplicationTagFilter::decode_tags_to_map(target_tagging);
let source_tag_count = i32::try_from(source_tags.len()).unwrap_or(i32::MAX);
(target.tag_count > 0 && source_tags != target_tags) || target.tag_count != source_tag_count
}
fn comparable_metadata(metadata: Option<&HashMap<String, String>>) -> HashMap<String, String> {
let mut comparable = HashMap::new();
for (key, value) in metadata.into_iter().flatten() {
if REPLICATION_METADATA_COMPARE_KEYS
.iter()
.any(|prefix| has_prefix_fold(key, prefix))
{
comparable.insert(key.to_lowercase(), value.clone());
}
}
comparable
}
#[cfg(test)]
mod tests {
use super::{
ReplicationSourceObject, ReplicationTargetObject, content_matches_by_etag, replication_action_for_target,
replication_etags_match, target_is_newer_than_source_null_version,
};
use crate::filemeta::{ReplicationAction, ReplicationType};
use std::collections::HashMap;
use time::{Duration, OffsetDateTime};
fn source_object(user_defined: &HashMap<String, String>) -> ReplicationSourceObject<'_> {
ReplicationSourceObject {
mod_time: Some(OffsetDateTime::UNIX_EPOCH + Duration::seconds(10)),
version_id: Some("source-version".to_string()),
etag: Some("\"abc\""),
actual_size: 10,
delete_marker: false,
content_type: Some("text/plain"),
content_encoding: None,
user_tags: "a=1",
user_defined,
}
}
fn target_object(metadata: &HashMap<String, String>) -> ReplicationTargetObject<'_> {
ReplicationTargetObject {
last_modified: Some(OffsetDateTime::UNIX_EPOCH + Duration::seconds(10)),
version_id: Some("source-version"),
etag: Some("abc"),
content_length: 10,
delete_marker: false,
content_type: Some("text/plain"),
metadata: Some(metadata),
tag_count: 1,
}
}
#[test]
fn content_matches_by_etag_ignores_version_ids() {
let source_metadata = HashMap::new();
let target_metadata = HashMap::new();
let source = ReplicationSourceObject {
version_id: Some("source-version".to_string()),
etag: Some("\"abc\""),
user_defined: &source_metadata,
..source_object(&source_metadata)
};
let target = ReplicationTargetObject {
version_id: Some("different-version"),
etag: Some("abc"),
metadata: Some(&target_metadata),
..target_object(&target_metadata)
};
assert!(content_matches_by_etag(&source, &target));
assert!(replication_etags_match(source.etag, target.etag));
}
#[test]
fn target_newer_null_version_skips_existing_object_replication() {
let source_metadata = HashMap::new();
let target_metadata = HashMap::new();
let source = ReplicationSourceObject {
version_id: None,
..source_object(&source_metadata)
};
let target = ReplicationTargetObject {
last_modified: Some(OffsetDateTime::UNIX_EPOCH + Duration::seconds(20)),
..target_object(&target_metadata)
};
assert!(target_is_newer_than_source_null_version(&source, &target));
assert_eq!(
replication_action_for_target(&source, &target, ReplicationType::ExistingObject),
ReplicationAction::None
);
}
#[test]
fn replication_action_detects_content_and_metadata_differences() {
let mut source_metadata = HashMap::new();
source_metadata.insert("Cache-Control".to_string(), "max-age=1".to_string());
let mut target_metadata = HashMap::new();
target_metadata.insert("X-Amz-Tagging".to_string(), "a=1".to_string());
target_metadata.insert("Cache-Control".to_string(), "max-age=1".to_string());
let source = source_object(&source_metadata);
let target = target_object(&target_metadata);
assert_eq!(
replication_action_for_target(&source, &target, ReplicationType::ExistingObject),
ReplicationAction::None
);
let changed_content = ReplicationTargetObject {
content_length: 11,
..target_object(&target_metadata)
};
assert_eq!(
replication_action_for_target(&source, &changed_content, ReplicationType::ExistingObject),
ReplicationAction::All
);
let mut changed_target_metadata = target_metadata.clone();
changed_target_metadata.insert("Cache-Control".to_string(), "max-age=2".to_string());
let changed_metadata = target_object(&changed_target_metadata);
assert_eq!(
replication_action_for_target(&source, &changed_metadata, ReplicationType::ExistingObject),
ReplicationAction::Metadata
);
}
}