fix(replication): send an integrity header on Object Lock PUTs (#7097)

* fix(replication): send an integrity header on Object Lock replication PUTs

AWS S3, MinIO and most compatible targets reject a PutObject that carries
x-amz-object-lock-* headers unless it also carries Content-MD5 or an
x-amz-checksum-* header. Since rustfs#6895 the replication client sends
plain signed payloads with no SDK checksum, so every replicated object
with a retention period or legal hold failed against such targets.

TargetClient::put_object now decides per request through the pure
rustfs_replication::object_lock_put_integrity: a plaintext single-part
object whose source ETag is its MD5 gets Content-MD5 derived from the
ETag (no body pass, framing unchanged); a multipart-layout ETag, managed
SSE or SSE-C passthrough falls back to an SDK CRC32; a forwarded source
checksum or an unlocked PUT is left alone.

The outbound target matrix flips its two KnownFailing(rustfs#7082) cells
to Completed and every Completed cell now asserts that a locked
PutObject carried an integrity header.

Fixes rustfs#7082.

* test(e2e): keep the matrix expectation table clippy-clean under -D warnings

The CI lint runs cargo clippy --all-targets -- -D warnings. With every cell
green the single-arm match tripped match_single_binding and the unused
KnownFailing variant tripped dead_code, and the target-client tests tripped
field_reassign_with_default. Drive the expectation table from a
KNOWN_FAILING_CELLS constant (so the variant stays live and adding a red
cell is a one-line entry), build the test options as struct literals, and
refresh the e2e-repl-nightly selection digest for the renamed table test.
This commit is contained in:
唐小鸭
2026-09-03 20:26:44 +08:00
committed by GitHub
parent 0f272ddb14
commit 53cabe9274
9 changed files with 321 additions and 38 deletions
+3 -3
View File
@@ -64,9 +64,9 @@ pub use multipart::{
replication_single_put_size_error,
};
pub use object::{
ReplicationSourceObject, ReplicationTargetObject, SsecPassthroughCapability, SsecPassthroughGate, content_matches_by_etag,
is_replication_target_offline_error, replication_action_for_target, replication_etags_match,
single_part_replica_etag_mismatch, ssec_passthrough_evidence_present, ssec_passthrough_gate,
ObjectLockIntegrity, ReplicationSourceObject, ReplicationTargetObject, SsecPassthroughCapability, SsecPassthroughGate,
content_matches_by_etag, is_replication_target_offline_error, object_lock_put_integrity, replication_action_for_target,
replication_etags_match, single_part_replica_etag_mismatch, ssec_passthrough_evidence_present, ssec_passthrough_gate,
target_is_newer_than_source_null_version, version_identity_drifted,
};
pub use operation::{
+79
View File
@@ -75,6 +75,47 @@ fn is_plain_single_part_md5(etag: &str) -> bool {
etag.len() == 32 && etag.bytes().all(|b| b.is_ascii_hexdigit())
}
/// How a replication PutObject that carries Object Lock parameters satisfies
/// the target-side rule that such a request must also carry `Content-MD5` or
/// an `x-amz-checksum-*` header (AWS S3, MinIO and most compatible stores
/// enforce it; rustfs#7082).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ObjectLockIntegrity {
/// No Object Lock parameters, or an integrity header is already present.
NotRequired,
/// Send `Content-MD5` computed from this hex MD5: the source ETag is the
/// MD5 of exactly the bytes going on the wire, so no body pass and no
/// change of payload framing is needed.
ContentMd5Hex(String),
/// The source ETag is not the MD5 of the wire bytes (multipart layout,
/// or an encrypted object whose ETag does not describe the plaintext);
/// let the SDK compute a checksum instead.
SdkChecksum,
}
/// Decide the integrity header for a locked replication PUT.
///
/// `plaintext_end_to_end` is false when the request announces any server-side
/// encryption or carries SSE-C ciphertext passthrough headers: the source
/// ETag then does not describe the bytes on the wire and must not be turned
/// into a `Content-MD5` the target would reject with `BadDigest`.
pub fn object_lock_put_integrity(
lock_params: bool,
has_integrity_header: bool,
plaintext_end_to_end: bool,
source_etag: Option<&str>,
) -> ObjectLockIntegrity {
if !lock_params || has_integrity_header {
return ObjectLockIntegrity::NotRequired;
}
match source_etag.map(trim_etag) {
Some(etag) if plaintext_end_to_end && is_plain_single_part_md5(&etag) => {
ObjectLockIntegrity::ContentMd5Hex(etag.to_ascii_lowercase())
}
_ => ObjectLockIntegrity::SdkChecksum,
}
}
/// Whether the ETag the target returned for a single-part replica proves the
/// stored bytes differ from what the source sent — e.g. a target that does not
/// decode `aws-chunked` framing stores the frames verbatim and returns their
@@ -302,6 +343,8 @@ pub fn ssec_passthrough_evidence_present(sse_customer_algorithm: Option<&str>) -
#[cfg(test)]
mod tests {
use super::{ObjectLockIntegrity, object_lock_put_integrity};
const SOURCE_MD5: &str = "9a0364b9e99bb480dd25e1f0284c8555";
const FRAMED_MD5: &str = "0f343b0931126a20f133d67c2b018a3b";
@@ -556,4 +599,40 @@ mod tests {
ReplicationAction::Metadata
);
}
#[test]
fn locked_put_uses_the_plain_source_md5_as_content_md5() {
assert_eq!(
object_lock_put_integrity(true, false, true, Some("\"9A0364B9E99BB480DD25E1F0284C8555\"")),
ObjectLockIntegrity::ContentMd5Hex("9a0364b9e99bb480dd25e1f0284c8555".to_string())
);
}
#[test]
fn locked_put_without_a_usable_etag_falls_back_to_the_sdk_checksum() {
// Multipart layout: the ETag is not the MD5 of the body.
assert_eq!(
object_lock_put_integrity(true, false, true, Some("9a0364b9e99bb480dd25e1f0284c8555-2")),
ObjectLockIntegrity::SdkChecksum
);
// Encrypted end to end: the ETag does not describe the wire bytes.
assert_eq!(
object_lock_put_integrity(true, false, false, Some("9a0364b9e99bb480dd25e1f0284c8555")),
ObjectLockIntegrity::SdkChecksum
);
assert_eq!(object_lock_put_integrity(true, false, true, None), ObjectLockIntegrity::SdkChecksum);
assert_eq!(object_lock_put_integrity(true, false, true, Some("")), ObjectLockIntegrity::SdkChecksum);
}
#[test]
fn integrity_is_not_added_without_lock_params_or_when_already_present() {
assert_eq!(
object_lock_put_integrity(false, false, true, Some("9a0364b9e99bb480dd25e1f0284c8555")),
ObjectLockIntegrity::NotRequired
);
assert_eq!(
object_lock_put_integrity(true, true, true, Some("9a0364b9e99bb480dd25e1f0284c8555")),
ObjectLockIntegrity::NotRequired
);
}
}