Compare commits

..

2 Commits

Author SHA1 Message Date
唐小鸭 20ef9ebf15 fix(replication): skip site-replication rules in replica delete fan-out (#9138)
* fix(replication): skip site-replication rules in replica delete fan-out

A site that receives a replicated delete forwards it to its own targets
so chained replication keeps propagating deletes. The fan-out used the
whole bucket config, including derived site-replication rules, so in a
site-replication mesh a replicated purge was echoed back to its origin
and duplicated to every other peer. The echo could remove the origin's
pending-purge version before its own delivery state was written back.

Ignore derived site-replication rules when computing the replica delete
fan-out. The origin already delivers and retries every delete to each
peer itself. Operator rules still fan out, so chained replication is
unchanged.

* fix(replication): keep operator rules matching the site-repl shape

Rule ids and target ARN shapes are not reserved for site replication:
an ordinary bucket target ARN carries its target id, so an operator rule
named site-repl-<target id> matched the derived shape and was dropped
from replica delete fan-out, breaking an A -> B -> C chain.

Skip a rule only when its deployment id is a current remote
site-replication peer, reading the peer set only when a rule has the
derived shape. If the state cannot be read, skip nothing.
2026-11-03 12:08:08 +00:00
Boyi (Elliot) Li cc08e0d11b feat(fips): route internode RPC HMAC through aws-lc-rs FIPS (#9137)
* feat(fips): route internode RPC HMAC through aws-lc-rs FIPS

* Revise KMS cryptographic compliance details

Updated the KMS cryptographic compliance documentation to reflect changes in HMAC authentication and added details about the internode RPC HMAC authentication.

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Hauser Zhi <housemecn@gmail.com>

---------

Signed-off-by: Hauser Zhi <housemecn@gmail.com>
Co-authored-by: Hauser Zhi <housemecn@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-11-03 06:56:56 +00:00
12 changed files with 330 additions and 71 deletions
Generated
+40 -39
View File
@@ -40,9 +40,9 @@ dependencies = [
[[package]]
name = "aes"
version = "0.9.4"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fc36d7440d015b45468ba08a21e80e056ebbd014c611c6298bb7503e04bd1"
checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32"
dependencies = [
"cipher 0.5.2",
"cpubits",
@@ -57,7 +57,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f2b8006a0c83f52b62ba44a97b58bf76fe2f70a329e588f67f89691d93d498f"
dependencies = [
"aead",
"aes 0.9.4",
"aes 0.9.3",
"cipher 0.5.2",
"ctr",
"ctutils",
@@ -772,9 +772,9 @@ dependencies = [
[[package]]
name = "async-executor"
version = "1.14.1"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f426a31126c735126297bc5ea7a521263c9faff4f63ca415267ee625651c4f37"
checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
dependencies = [
"async-task",
"concurrent-queue",
@@ -800,9 +800,9 @@ dependencies = [
[[package]]
name = "async-lock"
version = "3.5.0"
version = "3.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0876700b0d007ad25d5508865a4259c3af9af6d296bf663a0d586d0bc3369f6f"
checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
dependencies = [
"event-listener",
"event-listener-strategy",
@@ -2580,9 +2580,9 @@ dependencies = [
[[package]]
name = "crc32c"
version = "0.6.9"
version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f132a176423ee852e8f0a35d43088d1850966b50745be818a88b76f64b1c6cb"
checksum = "3a47af21622d091a8f0fb295b88bc886ac74efcc613efc19f5d0b21de5c89e47"
dependencies = [
"rustc_version",
]
@@ -2766,9 +2766,9 @@ dependencies = [
[[package]]
name = "ctutils"
version = "0.4.8"
version = "0.4.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "261af4dc7a06ea8a527a8717d8cbba3417530d6ea14e1edb3de6b67806be474f"
checksum = "a2376ffd6f96d8a9db59875b4b6b0237d6900b5baf64279714e05493ce64828a"
dependencies = [
"cmov",
"subtle",
@@ -3906,9 +3906,9 @@ dependencies = [
[[package]]
name = "dial9"
version = "0.5.7"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f3298a54fd82af243c9f122595762f5b0e48bd3d1ea8fe3f3eb5e95c130e881"
checksum = "11185c62245c5ff61c753ec8ec3d56341a13fe4195344ecc100ce94f780533cc"
dependencies = [
"bon",
"dial9-core",
@@ -3923,9 +3923,9 @@ dependencies = [
[[package]]
name = "dial9-core"
version = "0.5.7"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc7907b149a394404e3075f57bfbc751c7292ef115027f13f4995ff3a8b360c"
checksum = "54f1dcb5d20bdc85b9beb7c2864540526c1394c00783e9f3544feed1cfe529e8"
dependencies = [
"ahash",
"arc-swap",
@@ -3957,9 +3957,9 @@ dependencies = [
[[package]]
name = "dial9-metrique"
version = "0.5.7"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35f30e80d69214ed7c684d81afd8e52315b4acbfa5be9ebb84ccee02ebd06f44"
checksum = "56645773b4185f51a9528625a5aedcd4bbc5b5919ba68e7a6108cf076f635efc"
dependencies = [
"dial9-core",
"dial9-trace-format",
@@ -3971,9 +3971,9 @@ dependencies = [
[[package]]
name = "dial9-perf-self-profile"
version = "0.5.7"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17f09434b7623ef8b9b107062941a8e471e6044d6c946fa4af899130223b7a28"
checksum = "00c8c96d18b28ec76adde2107f7d2d5f8bb53b0670fba31bc012841703bc49ca"
dependencies = [
"blazesym",
"bon",
@@ -3990,9 +3990,9 @@ dependencies = [
[[package]]
name = "dial9-tokio-telemetry"
version = "0.5.7"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0428cba60fbcf37ab82666f3a7ef3b6d0671e90dfb6a352376813b471319f061"
checksum = "7ccc79f3683219471a8758b8ce459589167c55d72a649d27b89068a1a4cfffe9"
dependencies = [
"bon",
"bytes",
@@ -4015,9 +4015,9 @@ dependencies = [
[[package]]
name = "dial9-trace-format"
version = "0.5.4"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af753ba562fab9b99024b74234fe3a93b01c32d6474db8ae4f2c3bb562958f8f"
checksum = "44026339a4c6cbd4914c3eb85e653e741d5739f1e401695a039127ba49c19731"
dependencies = [
"ahash",
"dial9-trace-format-derive",
@@ -4027,9 +4027,9 @@ dependencies = [
[[package]]
name = "dial9-trace-format-derive"
version = "0.5.4"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e00786284c5d77b60aac60ee96797b8ac020306da8f02fecb0e86c5e82610aae"
checksum = "bbdbf5c76eb6824b0576e6891811caae93fc0bc96c23003556e3f752d069a84d"
dependencies = [
"proc-macro-crate",
"proc-macro2",
@@ -5895,9 +5895,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "jiff"
version = "0.2.39"
version = "0.2.38"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ac703058abac89e5c39b0b879dce2b6b14a4ba36600c7e7e3d0c60a6883ef3c"
checksum = "b2b005715dcbeb0089a3c0dab99f2ff1cc3b2525323552703d648585d342a383"
dependencies = [
"defmt 1.2.0",
"jiff-core",
@@ -5912,9 +5912,9 @@ dependencies = [
[[package]]
name = "jiff-core"
version = "0.1.2"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aee43309eb53bafb8c36d35d6e444769c8f8e6206a9fbf7546f5db9d7d4a0e93"
checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c"
dependencies = [
"defmt 1.2.0",
"log",
@@ -5922,9 +5922,9 @@ dependencies = [
[[package]]
name = "jiff-static"
version = "0.2.39"
version = "0.2.38"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dc32470c67eb7d0e9ccb74303fa925313dc04cecb1aa048beaa6f391e36a93d"
checksum = "2cc9817253cf7c7ee4684451bd327e88d6f3658014e54a29198625590650695c"
dependencies = [
"jiff-core",
"proc-macro2",
@@ -6413,9 +6413,9 @@ dependencies = [
[[package]]
name = "lzma-rust2"
version = "0.24.0"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "490750560fa7851fbbf5d3ab1a8750f09350ed361ed8aed85b1e1e40587812ee"
checksum = "f1c46f3fcd2e888afdb01a07efc071cb21300e07c68d32c3e8b01b5c8cb1fcaa"
dependencies = [
"sha2 0.11.1",
]
@@ -8040,7 +8040,7 @@ version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63d440a804ec8d6fafbb6b84471e013286658d373248927692ab3366686220ca"
dependencies = [
"aes 0.9.4",
"aes 0.9.3",
"aes-gcm",
"cbc 0.2.1",
"der 0.8.3",
@@ -9353,7 +9353,7 @@ version = "0.65.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3bb77a4b259312b2abbc541bca1c13cb4e9b61c560a7fd894068c3b92e0c9dc3"
dependencies = [
"aes 0.9.4",
"aes 0.9.3",
"aws-lc-rs",
"bitflags 2.13.2",
"block-padding 0.4.2",
@@ -11058,6 +11058,7 @@ dependencies = [
name = "rustfs-utils"
version = "1.0.2"
dependencies = [
"aws-lc-rs",
"base64-simd",
"blake2",
"brotli 9.1.1",
@@ -12144,7 +12145,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d801accda99469cde6d73da741422610fdf6508a72d9a69d1b55cb241c720597"
dependencies = [
"aead",
"aes 0.9.4",
"aes 0.9.3",
"aes-gcm",
"chacha20",
"cipher 0.5.2",
@@ -14198,11 +14199,11 @@ dependencies = [
[[package]]
name = "zip"
version = "9.3.0"
version = "9.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eaeb7f16d2605b49aef586996ded29e65642fab7259a3077e537351df6d80426"
checksum = "e408c6cc4699dd017d53dc080cd51c2cea2b5354af57a1479bd265ed42911487"
dependencies = [
"aes 0.9.4",
"aes 0.9.3",
"bzip2",
"constant_time_eq",
"crc32fast",
+4 -3
View File
@@ -212,6 +212,7 @@ serde_urlencoded = "0.7.1"
# releases.
aes-gcm = { version = "0.11.1" }
argon2 = { version = "0.6.0" }
aws-lc-rs = { version = "1.18.0", default-features = false }
blake2 = "0.11.0"
chacha20poly1305 = { version = "0.11.0" }
crc-fast = "1.10.0"
@@ -234,7 +235,7 @@ proptest = "1"
# Time and Date
chrono = { version = "0.4.45" }
humantime = "2.4.0"
jiff = { version = "0.2.39" }
jiff = { version = "0.2.38" }
time = { version = "0.3.55" }
# Database
@@ -356,13 +357,13 @@ winapi-util = "0.1.11"
windows = { version = "0.62.2" }
windows-sys = "0.61.2"
xxhash-rust = { version = "0.8.19" }
zip = "9.3.0"
zip = "9.2.2"
zstd = "0.14.3"
# Observability and Metrics
metrics = "0.24.6"
metrics-util = "0.20"
dial9 = { version = "0.5.7", default-features = false }
dial9 = { version = "0.5.6", default-features = false }
opentelemetry = { version = "0.33.1" }
opentelemetry-appender-tracing = { version = "0.33.1" }
opentelemetry-otlp = { version = "0.33.1" }
+1 -1
View File
@@ -31,7 +31,7 @@ workspace = true
[features]
default = []
fips = ["rustfs-s3-client/fips"]
fips = ["rustfs-s3-client/fips", "rustfs-utils/fips"]
diagnostic-read-version-caller = ["rustfs-io-metrics/diagnostic-read-version-caller"]
# Sampled wall/CPU diagnostics only; does not change the read backend.
diagnostic-local-batch = ["rustix/time"]
+1 -3
View File
@@ -32,7 +32,6 @@ use crate::storage_api_contracts::internode::{
PUT_FILE_AUTH_TRAILER_MAGIC, PUT_FILE_CAPABILITY_VERSION,
};
use hmac::{Hmac, KeyInit, Mac};
use http::uri::Authority;
use http::{HeaderMap, HeaderValue, Method, Uri};
#[cfg(test)]
@@ -47,6 +46,7 @@ use rustfs_io_metrics::internode_metrics::{
global_internode_metrics,
};
use rustfs_object_data_cache::{MemoryBasis, resolve_effective_memory};
use rustfs_utils::crypto::{HmacSha256, KeyInit, Mac};
use rustfs_utils::get_env_bool;
use sha2::Digest as _;
use sha2::Sha256;
@@ -58,8 +58,6 @@ use time::OffsetDateTime;
use tracing::{error, info, warn};
use uuid::Uuid;
type HmacSha256 = Hmac<Sha256>;
const SIGNATURE_HEADER: &str = "x-rustfs-signature";
pub const RPC_BACKGROUND_ADMISSION_HEADER: &str = "x-rustfs-rpc-background-admission";
const RPC_BACKGROUND_ADMISSION_V1: &str = "v1";
+2
View File
@@ -26,6 +26,7 @@ categories = ["web-programming", "development-tools", "cryptography"]
[dependencies]
hotpath.workspace = true
aws-lc-rs = { workspace = true, optional = true }
base64-simd = { workspace = true, optional = true }
blake2 = { workspace = true, optional = true }
brotli = { workspace = true, optional = true }
@@ -89,6 +90,7 @@ path = [] # path manipulation features
compress = ["dep:flate2", "dep:brotli", "dep:snap", "dep:lz4", "dep:zstd"]
string = ["dep:regex"]
crypto = ["dep:base64-simd", "dep:hex-simd", "dep:hmac", "dep:hyper", "dep:sha1", "dep:sha2"]
fips = ["crypto", "dep:aws-lc-rs", "aws-lc-rs/fips"]
hash = ["dep:highway", "dep:md-5", "dep:sha2", "dep:blake2", "dep:serde", "dep:siphasher", "dep:hex-simd", "dep:crc-fast"]
os = ["dep:rustix", "dep:tempfile", "dep:windows"] # operating system utilities
integration = [] # integration test features
+96 -5
View File
@@ -13,12 +13,77 @@
// limitations under the License.
use hex_simd::{AsOut, AsciiCase};
use hmac::{Hmac, KeyInit, Mac};
use hmac::Hmac;
#[cfg(not(feature = "fips"))]
pub use hmac::{KeyInit, Mac};
use hyper::body::Bytes;
#[cfg(not(feature = "fips"))]
use sha1::Sha1;
use sha2::{Digest, Sha256};
use std::mem::MaybeUninit;
#[cfg(not(feature = "fips"))]
pub type HmacSha256 = Hmac<Sha256>;
#[cfg(feature = "fips")]
pub struct HmacSha256Output([u8; 32]);
#[cfg(feature = "fips")]
impl HmacSha256Output {
#[must_use]
pub fn into_bytes(self) -> hmac::digest::Output<Hmac<Sha256>> {
self.0.into()
}
}
#[cfg(feature = "fips")]
pub trait KeyInit: Sized {
fn new_from_slice(key: &[u8]) -> Result<Self, hmac::digest::InvalidLength>;
}
#[cfg(feature = "fips")]
pub trait Mac: Sized {
fn update(&mut self, data: &[u8]);
fn finalize(self) -> HmacSha256Output;
fn verify_slice(self, tag: &[u8]) -> Result<(), hmac::digest::MacError>;
}
#[cfg(feature = "fips")]
#[derive(Clone)]
pub struct HmacSha256(aws_lc_rs::hmac::Context);
#[cfg(feature = "fips")]
impl HmacSha256 {
pub fn new_from_slice(key: &[u8]) -> Result<Self, hmac::digest::InvalidLength> {
let signing_key = aws_lc_rs::hmac::Key::new(aws_lc_rs::hmac::HMAC_SHA256, key);
Ok(Self(aws_lc_rs::hmac::Context::with_key(&signing_key)))
}
}
#[cfg(feature = "fips")]
impl KeyInit for HmacSha256 {
fn new_from_slice(key: &[u8]) -> Result<Self, hmac::digest::InvalidLength> {
Self::new_from_slice(key)
}
}
#[cfg(feature = "fips")]
impl Mac for HmacSha256 {
fn update(&mut self, data: &[u8]) {
self.0.update(data);
}
fn finalize(self) -> HmacSha256Output {
let tag = self.0.sign();
HmacSha256Output(tag.as_ref().try_into().expect("HMAC-SHA256 tag is 32 bytes"))
}
fn verify_slice(self, tag: &[u8]) -> Result<(), hmac::digest::MacError> {
let computed = self.0.sign();
aws_lc_rs::constant_time::verify_slices_are_equal(computed.as_ref(), tag).map_err(|_| hmac::digest::MacError)
}
}
/// Base64 URL safe encoding without padding
/// `base64_encode_url_safe_no_pad(input)`
///
@@ -84,9 +149,18 @@ pub fn is_sha256_checksum(s: &str) -> bool {
/// A 20-byte array containing the HMAC-SHA1 hash of the input data using the provided key
///
pub fn hmac_sha1(key: impl AsRef<[u8]>, data: impl AsRef<[u8]>) -> [u8; 20] {
let mut m = <Hmac<Sha1>>::new_from_slice(key.as_ref()).expect("operation should succeed");
m.update(data.as_ref());
m.finalize().into_bytes().into()
#[cfg(feature = "fips")]
{
let signing_key = aws_lc_rs::hmac::Key::new(aws_lc_rs::hmac::HMAC_SHA1_FOR_LEGACY_USE_ONLY, key.as_ref());
let tag = aws_lc_rs::hmac::sign(&signing_key, data.as_ref());
tag.as_ref().try_into().expect("HMAC-SHA1 tag is 20 bytes")
}
#[cfg(not(feature = "fips"))]
{
let mut m = <Hmac<Sha1>>::new_from_slice(key.as_ref()).expect("operation should succeed");
m.update(data.as_ref());
m.finalize().into_bytes().into()
}
}
/// HMAC-SHA256 hashing
@@ -100,7 +174,7 @@ pub fn hmac_sha1(key: impl AsRef<[u8]>, data: impl AsRef<[u8]>) -> [u8; 20] {
/// A 32-byte array containing the HMAC-SHA256 hash of the input data using the provided key
///
pub fn hmac_sha256(key: impl AsRef<[u8]>, data: impl AsRef<[u8]>) -> [u8; 32] {
let mut m = Hmac::<Sha256>::new_from_slice(key.as_ref()).expect("operation should succeed");
let mut m = HmacSha256::new_from_slice(key.as_ref()).expect("operation should succeed");
m.update(data.as_ref());
m.finalize().into_bytes().into()
}
@@ -167,3 +241,20 @@ fn sha256_checksum_rejects_wrong_length_or_non_lowercase_hex() {
assert!(!is_sha256_checksum("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdeg"));
assert!(!is_sha256_checksum("0123456789ABCDEF0123456789abcdef0123456789abcdef0123456789abcdef"));
}
#[test]
fn test_hmac_sha256_rfc4231() {
#[cfg(feature = "fips")]
aws_lc_rs::try_fips_mode().expect("aws-lc-rs FIPS mode must be active when `rustfs-utils/fips` is enabled");
let key = [0x0b_u8; 20];
let data = b"Hi There";
assert_eq!(hex(hmac_sha1(key, data)), "b617318655057264e28bc0b6fb378c8ef146be00");
let mac = hmac_sha256(key, data);
assert_eq!(hex(mac), "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7");
let mut verifier = <HmacSha256 as KeyInit>::new_from_slice(&key).expect("valid key");
verifier.update(data);
assert!(verifier.clone().verify_slice(&mac).is_ok());
assert!(verifier.verify_slice(&[0_u8; 32]).is_err());
}
@@ -14,6 +14,7 @@ This document records where RustFS stands on cryptographic module validation and
| Layer | Where | Implementation | Primitives | Validated module |
| --- | --- | --- | --- | --- |
| TLS using the process-wide rustls provider | Provider installed or checked by `install_default_crypto_provider` in `rustfs/src/startup_runtime_hooks.rs` | `rustls` with the process-wide provider (AWS-LC is installed when none exists) | TLS 1.2/1.3 suites, `prefer-post-quantum` hybrid key exchange (restricted to FIPS-approved suites and groups when `--features fips` is enabled) | No in the default build (`aws-lc-sys`); opt-in `--features fips` enables `aws-lc-fips-sys` and checks the installed provider at startup; module validation depends on the deployment |
| Internode RPC HMAC authentication | `crates/ecstore/src/cluster/rpc/http_auth.rs` via `rustfs_utils::crypto::HmacSha256` | RustCrypto `hmac` + `sha2` in the default build; `aws-lc-rs` when `fips` is enabled | HMAC-SHA256 (`x-rustfs-signature`, `x-rustfs-rpc-signature-v2`, `x-rustfs-rpc-signature-v3`) | No in the default build; opt-in `--features fips` enables `rustfs-utils/fips` (`aws-lc-fips-sys`) |
| Object data path AEAD (SSE) | `crates/kms/src/encryption/ciphers.rs`, `crates/rio/src/encrypt_reader.rs`, `crates/rio-v2/src/encrypt_reader.rs` | RustCrypto `aes-gcm`, `chacha20poly1305` | AES-256-GCM, ChaCha20-Poly1305 | No |
| DEK wrapping | `crates/kms/src/encryption/dek.rs` | RustCrypto `aes-gcm` | AES-256-GCM | No |
| Local KMS backend master key | `crates/kms/src/backends/local.rs` | RustCrypto `argon2`, `aes-gcm` | Argon2id KDF, AES-256-GCM | No |
+1 -1
View File
@@ -58,7 +58,7 @@ required-features = ["swift"]
[features]
default = ["ftps", "webdav", "gcs"]
fips = ["rustls/fips", "tokio-rustls/fips", "rustfs-tls-runtime/fips", "rustfs-ecstore/fips", "rustfs-protocols/fips", "rustfs-s3-client/fips", "rustfs-targets/fips"]
fips = ["rustls/fips", "tokio-rustls/fips", "rustfs-tls-runtime/fips", "rustfs-ecstore/fips", "rustfs-protocols/fips", "rustfs-s3-client/fips", "rustfs-targets/fips", "rustfs-utils/fips"]
gcs = ["rustfs-ecstore/gcs", "dep:google-cloud-auth"]
metrics-gpu = ["rustfs-obs/gpu"]
ftps = ["rustfs-protocols/ftps"]
+119 -13
View File
@@ -295,16 +295,32 @@ fn enrich_delete_replication_state_if_needed(
}
}
fn should_schedule_replica_delete_replication(
/// The current peer set is read only when a rule has the derived shape. If it
/// cannot be read, no rule is skipped: a redundant echo is safer than a chained
/// target missing the delete.
async fn replica_delete_fanout_state(
snapshot: &DeleteReplicationConfigSnapshot,
bucket: &str,
replication_source: &ObjectInfo,
version_id: Option<Uuid>,
) -> bool {
let Some(config) = snapshot.replication_config() else {
return false;
) -> Option<ReplicationState> {
let config = snapshot.replication_config()?;
let site_peer_deployment_ids = if has_site_replication_shaped_rule(config) {
match crate::site_replication::site_replication_remote_peer_deployment_ids().await {
Ok(peers) => peers,
Err(error) => {
warn!(
bucket = %bucket,
error = %error,
"failed to load site replication peers; forwarding replica delete along every rule"
);
HashSet::new()
}
}
} else {
HashSet::new()
};
delete_replication_state_from_config(config, replication_source, version_id, true).is_some()
replica_delete_fanout_state_from_config(config, replication_source, version_id, &site_peer_deployment_ids)
}
fn validate_undo_delete_version(expected: Option<&str>, requested: Option<&str>) -> S3Result<()> {
@@ -1347,12 +1363,21 @@ impl DefaultObjectUsecase {
let deleted_delete_marker_version = deleted_replication_info.is_some_and(|info| info.delete_marker);
let delete_replication_version_id = delete_replication_version_id(deleted_object_source, deleted_delete_marker_version);
let schedule_delete_replication = if opts.replication_request && replica {
should_schedule_replica_delete_replication(
&delete_config_snapshot,
replication_state_source,
delete_replication_version_id,
let replica_fanout_state = if opts.replication_request && replica {
Some(
replica_delete_fanout_state(
&delete_config_snapshot,
&bucket,
replication_state_source,
delete_replication_version_id,
)
.await,
)
} else {
None
};
let schedule_delete_replication = if let Some(fanout_state) = &replica_fanout_state {
fanout_state.is_some()
} else {
should_schedule_delete_replication(
&opts,
@@ -1381,8 +1406,12 @@ impl DefaultObjectUsecase {
replication_state: None,
..Default::default()
};
set_deleted_object_replication_state(&mut deleted_object, &replication_state_source.replication_state());
enrich_delete_replication_state_if_needed(&delete_config_snapshot, &mut deleted_object, replication_state_source);
if let Some(Some(fanout_state)) = &replica_fanout_state {
set_deleted_object_replication_state(&mut deleted_object, fanout_state);
} else {
set_deleted_object_replication_state(&mut deleted_object, &replication_state_source.replication_state());
enrich_delete_replication_state_if_needed(&delete_config_snapshot, &mut deleted_object, replication_state_source);
}
schedule_replication_delete(deleted_object, bucket.clone(), REPLICATE_INCOMING_DELETE.to_string()).await;
}
@@ -3583,6 +3612,83 @@ mod tests {
assert!(state.purge_targets.contains_key(&arn));
}
#[test]
fn replica_delete_fanout_skips_site_replication_peers_but_keeps_chain_targets() {
let peer_arn = "arn:rustfs:replication::peer-dep:bucket";
let chain_arn = "arn:aws:s3:::downstream-bucket".to_string();
let site_rule = crate::site_replication::build_site_replication_rule(peer_arn, 10, "site-repl-peer-dep");
let mut chain_rule = site_rule.clone();
chain_rule.id = Some("chain".to_string());
chain_rule.priority = Some(1);
chain_rule.destination.bucket = chain_arn.clone();
let site_only = ReplicationConfiguration {
role: String::new(),
rules: vec![site_rule.clone()],
};
let site_and_chain = ReplicationConfiguration {
role: String::new(),
rules: vec![site_rule, chain_rule],
};
let marker = ObjectInfo {
bucket: "bucket".to_string(),
name: "object".to_string(),
delete_marker: true,
replication_status: ReplicationStatusType::Replica,
..Default::default()
};
let version = ObjectInfo {
delete_marker: false,
..marker.clone()
};
// An ordinary bucket target ARN carries its target id, so an operator
// rule named `site-repl-<target id>` has the derived shape.
let shaped_operator_arn = "arn:minio:replication:us-east-1:target-1:downstream".to_string();
let mut shaped_operator_rule =
crate::site_replication::build_site_replication_rule(&shaped_operator_arn, 1, "site-repl-target-1");
shaped_operator_rule.priority = Some(1);
let shaped_operator_only = ReplicationConfiguration {
role: String::new(),
rules: vec![shaped_operator_rule],
};
assert!(has_site_replication_shaped_rule(&shaped_operator_only));
let site_peers = HashSet::from(["peer-dep".to_string()]);
let no_site_peers = HashSet::new();
let removed_peer_only = HashSet::from(["other-dep".to_string()]);
let purged_version = Some(Uuid::new_v4());
let fanout_targets = |state: ReplicationState, version_id: Option<Uuid>| {
if version_id.is_some() {
state.purge_targets.keys().cloned().collect::<Vec<_>>()
} else {
state.targets.keys().cloned().collect::<Vec<_>>()
}
};
for (source, version_id) in [(&marker, None), (&marker, purged_version), (&version, purged_version)] {
assert!(
delete_replication_state_from_config(&site_only, source, version_id, true).is_some(),
"the site-replication rule alone would forward the replica delete"
);
assert!(
replica_delete_fanout_state_from_config(&site_only, source, version_id, &site_peers).is_none(),
"a replica delete must not echo back across the site-replication mesh"
);
let state = replica_delete_fanout_state_from_config(&site_and_chain, source, version_id, &site_peers)
.expect("a replica delete must still follow a chained operator rule");
assert!(!state.replicate_decision_str.contains(peer_arn));
assert_eq!(fanout_targets(state, version_id), vec![chain_arn.clone()]);
for peers in [&no_site_peers, &removed_peer_only, &site_peers] {
let state = replica_delete_fanout_state_from_config(&shaped_operator_only, source, version_id, peers)
.expect("an operator rule naming no current site peer must keep forwarding replica deletes");
assert_eq!(fanout_targets(state, version_id), vec![shaped_operator_arn.clone()]);
}
let state = replica_delete_fanout_state_from_config(&site_only, source, version_id, &no_site_peers)
.expect("with site replication disabled a site-repl rule is an operator rule");
assert_eq!(fanout_targets(state, version_id), vec![peer_arn.to_string()]);
}
}
#[test]
fn replica_delete_enrichment_must_not_reuse_upstream_targets() {
let upstream_state = ReplicationState {
+7 -5
View File
@@ -29,8 +29,9 @@ use super::storage_api::object_usecase::access::{
#[cfg(test)]
use super::storage_api::object_usecase::bucket::quota::BucketQuota;
use super::storage_api::object_usecase::bucket::quota::checker::QuotaChecker;
use super::storage_api::object_usecase::bucket::replication::ReplicationState;
#[cfg(test)]
use super::storage_api::object_usecase::bucket::replication::{ReplicationState, replication_statuses_map};
use super::storage_api::object_usecase::bucket::replication::replication_statuses_map;
use super::storage_api::object_usecase::bucket::{
VersioningConfigExt as _,
lifecycle::{
@@ -49,10 +50,11 @@ use super::storage_api::object_usecase::bucket::{
replication::{
DeleteReplicationConfigSnapshot, REPLICATE_INCOMING_DELETE, ReplicationStatusType, commit_force_delete_intent,
delete_replication_state_from_config, delete_replication_version_id, deleted_object_has_pending_replication_delete,
force_delete_target_set, get_read_proxy_targets, has_active_delete_rule, load_delete_config_snapshot,
must_replicate_object, persist_force_delete_intent, record_replication_proxy, schedule_object_replication,
schedule_replication_delete, schedule_replication_deletes, set_deleted_object_replication_state,
should_schedule_delete_replication, should_use_existing_delete_replication_info,
force_delete_target_set, get_read_proxy_targets, has_active_delete_rule, has_site_replication_shaped_rule,
load_delete_config_snapshot, must_replicate_object, persist_force_delete_intent, record_replication_proxy,
replica_delete_fanout_state_from_config, schedule_object_replication, schedule_replication_delete,
schedule_replication_deletes, set_deleted_object_replication_state, should_schedule_delete_replication,
should_use_existing_delete_replication_info,
},
tagging::decode_tags,
validate_restore_request,
+46 -1
View File
@@ -708,7 +708,6 @@ pub(crate) mod bucket {
pub(crate) type DeleteReplicationConfigSnapshot =
crate::storage::storage_api::ecstore_bucket::replication::DeleteReplicationConfigSnapshot;
pub(crate) type ReplicateDecision = replication_contracts::ReplicateDecision;
#[cfg(test)]
pub(crate) type ReplicationState = replication_contracts::ReplicationState;
pub(crate) type ReplicationStatusType = replication_contracts::ReplicationStatusType;
pub(crate) type ReplicationTargetValidationError = replication_contracts::ReplicationTargetValidationError;
@@ -959,6 +958,52 @@ pub(crate) mod bucket {
replication_contracts::delete_replication_state_from_config(config, &source)
}
/// Replication state for forwarding a replica delete (an inbound
/// replication request) to this site's own targets.
///
/// Operator rules with ReplicaModifications keep forwarding, so chained
/// replication A -> B -> C propagates deletes (rustfs#2609). Derived
/// site-replication rules never do: site replication is a full mesh
/// where the origin delivers, and MRF-retries, every delete to each
/// peer itself. Forwarding along a `site-repl-<deploymentId>` rule only
/// echoes the delete back to the origin, where it removes the origin's
/// pending-purge version before its own delivery state is written back,
/// and duplicates it to every other peer (rustfs#9121). MinIO never
/// re-replicates a replication request at all.
///
/// Rule ids and target ARN shapes are not reserved: an ordinary bucket
/// target ARN also carries a target id, so an operator rule can match
/// the derived shape. Only a rule naming a current remote peer in
/// `site_peer_deployment_ids` is skipped; with site replication
/// disabled the set is empty and every rule forwards.
pub(crate) fn replica_delete_fanout_state_from_config(
config: &s3s::dto::ReplicationConfiguration,
obj_info: &crate::storage::storage_api::StorageObjectInfo,
version_id: Option<Uuid>,
site_peer_deployment_ids: &HashSet<String>,
) -> Option<replication_contracts::ReplicationState> {
let is_site_peer_rule = |rule: &s3s::dto::ReplicationRule| {
replication_contracts::site_replication_rule_deployment_id(rule)
.is_some_and(|deployment_id| site_peer_deployment_ids.contains(deployment_id))
};
if !config.rules.iter().any(is_site_peer_rule) {
return delete_replication_state_from_config(config, obj_info, version_id, true);
}
let mut operator_config = config.clone();
operator_config.rules.retain(|rule| !is_site_peer_rule(rule));
delete_replication_state_from_config(&operator_config, obj_info, version_id, true)
}
/// Whether `config` has a rule with the derived site-replication shape,
/// so the replica-delete fan-out needs the current peer set.
pub(crate) fn has_site_replication_shaped_rule(config: &s3s::dto::ReplicationConfiguration) -> bool {
config
.rules
.iter()
.any(|rule| replication_contracts::site_replication_rule_deployment_id(rule).is_some())
}
pub(crate) fn replication_target_arns(config: &s3s::dto::ReplicationConfiguration) -> HashSet<String> {
replication_contracts::replication_target_arns(config)
}
+12
View File
@@ -368,6 +368,18 @@ pub(crate) async fn site_replication_deployment_ids() -> S3Result<HashSet<String
Ok(state.peers.values().map(|peer| peer.deployment_id.clone()).collect())
}
/// Deployment ids of the remote peers this site's reconciler derives a
/// `site-repl-<id>` rule for; empty when site replication is not enabled.
/// Read on the replica-delete path so only rules naming a current peer are
/// treated as reconciler-owned.
pub(crate) async fn site_replication_remote_peer_deployment_ids() -> S3Result<HashSet<String>> {
let state = load_site_replication_state().await?;
if !state.enabled() {
return Ok(HashSet::new());
}
Ok(remote_peer_deployment_ids(&state, &current_local_runtime_peer(&state)))
}
pub(crate) async fn load_site_replication_state_no_lock(store: Arc<ECStore>) -> S3Result<SiteReplicationState> {
match read_config_no_lock(store, SITE_REPLICATION_STATE_PATH).await {
Ok(data) => parse_site_replication_state(&data),