1use std::collections::BTreeMap;
7use std::fmt;
8
9use rustls_pki_types::{CertificateDer, pem::PemObject};
10use serde::{Deserialize, Deserializer, Serialize, Serializer};
11use serde_json::{Map, Value};
12use x509_parser::parse_x509_certificate;
13
14use crate::error::{Error, Result};
15
16pub const MAX_SITE_REPLICATION_SUCCESS_RESPONSE_BYTES: usize = 8 * 1024 * 1024;
18pub const MAX_SITE_REPLICATION_ERROR_RESPONSE_BYTES: usize = 64 * 1024;
20pub const MAX_SITE_REPLICATION_REQUEST_BYTES: usize = 1024 * 1024;
22pub const MAX_SITE_REPLICATION_CA_CERT_BYTES: usize = 256 * 1024;
24pub const SITE_REPLICATION_REPAIR_CAPABILITY: &str = "admin.site-replication.repair";
26pub const MAX_SITE_REPLICATION_REPAIR_RESPONSE_BYTES: usize = 8 * 1024 * 1024;
28
29pub fn validate_site_replication_repair_token(token: &str) -> Result<()> {
31 if token.len() == 43
32 && token
33 .bytes()
34 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
35 {
36 Ok(())
37 } else {
38 Err(Error::InvalidPath(
39 "Preflight token must be the complete 43-character server-issued token".to_string(),
40 ))
41 }
42}
43
44pub fn validate_site_replication_repair_operation_id(operation_id: &str) -> Result<()> {
46 let bytes = operation_id.as_bytes();
47 let valid = bytes.len() == 36
48 && bytes.iter().enumerate().all(|(index, byte)| {
49 if matches!(index, 8 | 13 | 18 | 23) {
50 *byte == b'-'
51 } else {
52 byte.is_ascii_hexdigit()
53 }
54 });
55 if valid {
56 Ok(())
57 } else {
58 Err(Error::InvalidPath(
59 "Operation ID must be a canonical UUID".to_string(),
60 ))
61 }
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
66pub struct SiteReplicationRepairCapabilityContract {
67 pub contract_version: u32,
68 pub status: super::RuntimeCapabilityStatus,
69 pub modes: Vec<String>,
70 pub execute_route: String,
71 pub status_route: String,
72 pub preflight_token_contract: String,
73 pub operation_id_format: String,
74 pub max_retained_successful_operations: usize,
75 pub disabled_by_default: bool,
76}
77
78impl SiteReplicationRepairCapabilityContract {
79 pub fn validate(&self) -> Result<()> {
81 let supported = self.status.state == super::RuntimeCapabilityState::Supported;
82 let modes_ok = self.modes.len() == 2
83 && ["dry-run", "execute"]
84 .iter()
85 .all(|required| self.modes.iter().any(|mode| mode == required));
86 if self.contract_version != 1
87 || !supported
88 || !modes_ok
89 || self.execute_route != "/rustfs/admin/v3/site-replication/repair"
90 || self.status_route != "/rustfs/admin/v3/site-replication/repair/status"
91 || self.preflight_token_contract != "hmac-sha256-v1"
92 || self.operation_id_format != "uuid"
93 || self.max_retained_successful_operations == 0
94 || self.disabled_by_default
95 {
96 return Err(Error::UnsupportedFeature(
97 "RustFS did not advertise the supported durable site-replication repair contract"
98 .to_string(),
99 ));
100 }
101 Ok(())
102 }
103}
104
105#[derive(Clone, Serialize, PartialEq, Eq)]
107#[serde(rename_all = "camelCase")]
108pub struct SiteReplicationRepairRequest {
109 pub mode: String,
110 #[serde(skip_serializing_if = "Option::is_none")]
111 pub preflight_token: Option<String>,
112 #[serde(skip_serializing_if = "Option::is_none")]
113 pub operation_id: Option<String>,
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
118#[serde(rename_all = "camelCase", deny_unknown_fields)]
119pub struct SiteReplicationRepairTaskStatus {
120 pub task_id: String,
121 pub status: String,
122 #[serde(default, skip_serializing_if = "Option::is_none")]
123 pub error: Option<String>,
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
128#[serde(rename_all = "camelCase", deny_unknown_fields)]
129pub struct SiteReplicationRepairFamilyStatus {
130 pub planned: usize,
131 pub succeeded: usize,
132 pub failed: usize,
133 #[serde(default)]
134 pub retry_events: usize,
135 #[serde(default)]
136 pub tasks: Vec<SiteReplicationRepairTaskStatus>,
137 #[serde(default)]
138 pub errors: Vec<String>,
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
143#[serde(rename_all = "camelCase", deny_unknown_fields)]
144pub struct SiteReplicationRepairSiteStatus {
145 pub deployment_id: String,
146 pub name: String,
147 pub families: BTreeMap<String, SiteReplicationRepairFamilyStatus>,
148}
149
150#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
152#[serde(rename_all = "camelCase", deny_unknown_fields)]
153pub struct SiteReplicationRepairPreflight {
154 pub mode: String,
155 pub status: String,
156 pub preflight_token: String,
157 pub retry_events: usize,
158 pub sites: BTreeMap<String, SiteReplicationRepairSiteStatus>,
159}
160
161impl fmt::Debug for SiteReplicationRepairPreflight {
162 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
163 formatter
164 .debug_struct("SiteReplicationRepairPreflight")
165 .field("mode", &self.mode)
166 .field("status", &self.status)
167 .field("has_preflight_token", &!self.preflight_token.is_empty())
168 .field("retry_events", &self.retry_events)
169 .field("site_count", &self.sites.len())
170 .finish()
171 }
172}
173
174impl SiteReplicationRepairPreflight {
175 pub fn validate(&self) -> Result<()> {
176 if self.mode != "dry-run" || self.status != "planned" {
177 return Err(Error::General(
178 "RustFS returned an inconsistent site-replication repair preflight".to_string(),
179 ));
180 }
181 validate_site_replication_repair_token(&self.preflight_token)?;
182 validate_repair_sites(&self.sites)
183 }
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
188#[serde(rename_all = "camelCase", deny_unknown_fields)]
189pub struct SiteReplicationRepairOperationStatus {
190 pub mode: String,
191 pub operation_id: String,
192 pub status: String,
193 pub sites: BTreeMap<String, SiteReplicationRepairSiteStatus>,
194 #[serde(default)]
195 pub created_at: Option<String>,
196 #[serde(default)]
197 pub updated_at: Option<String>,
198 #[serde(default)]
199 pub completed_at: Option<String>,
200}
201
202impl SiteReplicationRepairOperationStatus {
203 pub fn validate(&self, expected_operation_id: &str) -> Result<()> {
204 validate_site_replication_repair_operation_id(&self.operation_id)?;
205 if self.mode != "execute"
206 || !self
207 .operation_id
208 .eq_ignore_ascii_case(expected_operation_id)
209 || !matches!(
210 self.status.as_str(),
211 "running" | "success" | "partial" | "failed"
212 )
213 {
214 return Err(Error::General(
215 "RustFS returned an inconsistent site-replication repair operation".to_string(),
216 ));
217 }
218 for timestamp in [&self.created_at, &self.updated_at, &self.completed_at]
219 .into_iter()
220 .flatten()
221 {
222 timestamp.parse::<jiff::Timestamp>().map_err(|_| {
223 Error::General(
224 "RustFS returned an invalid site-replication repair timestamp".to_string(),
225 )
226 })?;
227 }
228 if self.created_at.is_none() || self.updated_at.is_none() {
229 return Err(Error::General(
230 "RustFS returned an incomplete site-replication repair timestamp".to_string(),
231 ));
232 }
233 validate_repair_sites(&self.sites)
234 }
235
236 pub fn has_failure(&self) -> bool {
238 matches!(
239 self.status.trim().to_ascii_lowercase().as_str(),
240 "partial" | "failed" | "failure"
241 ) || self
242 .sites
243 .values()
244 .flat_map(|site| site.families.values())
245 .any(|family| {
246 family.failed > 0
247 || family.tasks.iter().any(|task| {
248 matches!(
249 task.status.trim().to_ascii_lowercase().as_str(),
250 "failed" | "failure"
251 )
252 })
253 })
254 }
255}
256
257fn validate_repair_sites(sites: &BTreeMap<String, SiteReplicationRepairSiteStatus>) -> Result<()> {
258 for (deployment_id, site) in sites {
259 if deployment_id.is_empty()
260 || site.deployment_id != *deployment_id
261 || site.name.trim().is_empty()
262 || site.families.is_empty()
263 {
264 return Err(Error::General(
265 "RustFS returned an incomplete site-replication repair site".to_string(),
266 ));
267 }
268 for (family_name, family) in &site.families {
269 let successful = family
270 .tasks
271 .iter()
272 .filter(|task| matches!(task.status.as_str(), "succeeded" | "skipped"))
273 .count();
274 let failed = family
275 .tasks
276 .iter()
277 .filter(|task| task.status == "failed")
278 .count();
279 let mut task_ids = std::collections::BTreeSet::new();
280 if family_name.is_empty()
281 || family.succeeded.saturating_add(family.failed) > family.planned
282 || family.tasks.len() != family.planned
283 || successful != family.succeeded
284 || failed != family.failed
285 || family
286 .errors
287 .iter()
288 .any(|error| !repair_error_is_safe(error))
289 || family.tasks.iter().any(|task| {
290 validate_site_replication_repair_token(&task.task_id).is_err()
291 || !task_ids.insert(task.task_id.as_str())
292 || !matches!(
293 task.status.as_str(),
294 "planned" | "running" | "succeeded" | "failed" | "skipped"
295 )
296 || task
297 .error
298 .as_deref()
299 .is_some_and(|error| !repair_error_is_safe(error))
300 })
301 {
302 return Err(Error::General(
303 "RustFS returned inconsistent site-replication repair checkpoints".to_string(),
304 ));
305 }
306 }
307 }
308 Ok(())
309}
310
311fn repair_error_is_safe(error: &str) -> bool {
312 matches!(
313 error,
314 "authorization-failed"
315 | "remote-timeout"
316 | "remote-dns-failed"
317 | "remote-tls-failed"
318 | "remote-connect-failed"
319 | "remote-operation-failed"
320 )
321}
322
323#[async_trait::async_trait]
325pub trait SiteReplicationRepairApi: Send + Sync {
326 async fn site_replication_repair_capability(
327 &self,
328 ) -> Result<SiteReplicationRepairCapabilityContract>;
329 async fn site_replication_repair_dry_run(&self) -> Result<SiteReplicationRepairPreflight>;
330 async fn site_replication_repair_execute(
331 &self,
332 preflight_token: &str,
333 operation_id: &str,
334 ) -> Result<SiteReplicationRepairOperationStatus>;
335 async fn site_replication_repair_status(
336 &self,
337 operation_id: &str,
338 ) -> Result<SiteReplicationRepairOperationStatus>;
339}
340
341pub fn validate_site_replication_ca_bundle(pem: &[u8]) -> Result<()> {
343 if pem.is_empty() {
344 return Err(Error::InvalidPath(
345 "CA certificate bundle must not be empty".to_string(),
346 ));
347 }
348 if pem.len() > MAX_SITE_REPLICATION_CA_CERT_BYTES {
349 return Err(Error::InvalidPath(format!(
350 "CA certificate bundle exceeds the {MAX_SITE_REPLICATION_CA_CERT_BYTES} byte limit"
351 )));
352 }
353
354 let text = std::str::from_utf8(pem)
355 .map_err(|_| Error::InvalidPath("CA certificate bundle must be UTF-8 PEM".to_string()))?;
356 let mut inside_certificate = false;
357 let mut certificates = 0_usize;
358 for line in text.lines() {
359 let line = line.trim();
360 if line == "-----BEGIN CERTIFICATE-----" {
361 if inside_certificate {
362 return Err(Error::InvalidPath(
363 "CA certificate bundle contains nested PEM blocks".to_string(),
364 ));
365 }
366 inside_certificate = true;
367 certificates += 1;
368 } else if line == "-----END CERTIFICATE-----" {
369 if !inside_certificate {
370 return Err(Error::InvalidPath(
371 "CA certificate bundle contains an unmatched PEM end marker".to_string(),
372 ));
373 }
374 inside_certificate = false;
375 } else if line.starts_with("-----BEGIN ") || line.starts_with("-----END ") {
376 return Err(Error::InvalidPath(
377 "CA bundle may contain CERTIFICATE PEM blocks only".to_string(),
378 ));
379 } else if !inside_certificate && !line.is_empty() {
380 return Err(Error::InvalidPath(
381 "CA bundle contains data outside CERTIFICATE PEM blocks".to_string(),
382 ));
383 }
384 }
385 if inside_certificate || certificates == 0 {
386 return Err(Error::InvalidPath(
387 "CA bundle must contain complete CERTIFICATE PEM blocks".to_string(),
388 ));
389 }
390
391 let decoded = CertificateDer::pem_slice_iter(pem)
392 .collect::<std::result::Result<Vec<_>, _>>()
393 .map_err(|_| Error::InvalidPath("CA certificate bundle is malformed".to_string()))?;
394 if decoded.len() != certificates {
395 return Err(Error::InvalidPath(
396 "CA certificate bundle contains malformed certificate data".to_string(),
397 ));
398 }
399 for certificate in decoded {
400 let (remaining, _) = parse_x509_certificate(certificate.as_ref()).map_err(|_| {
401 Error::InvalidPath("CA certificate bundle contains invalid X.509 DER".to_string())
402 })?;
403 if !remaining.is_empty() {
404 return Err(Error::InvalidPath(
405 "CA certificate bundle contains trailing DER data".to_string(),
406 ));
407 }
408 }
409 Ok(())
410}
411
412#[derive(Clone, Serialize, Deserialize, Default)]
417pub struct PeerSiteSpec {
418 #[serde(default)]
419 pub name: String,
420 #[serde(rename = "endpoints", default)]
421 pub endpoint: String,
422 #[serde(rename = "accessKey", default)]
423 pub access_key: String,
424 #[serde(rename = "secretKey", default)]
425 pub secret_key: String,
426 #[serde(rename = "skipTlsVerify", default)]
427 pub skip_tls_verify: bool,
428 #[serde(
429 rename = "caCertPem",
430 default,
431 skip_serializing_if = "String::is_empty"
432 )]
433 pub ca_cert_pem: String,
434}
435
436impl fmt::Debug for PeerSiteSpec {
437 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
438 formatter
439 .debug_struct("PeerSiteSpec")
440 .field("name", &self.name)
441 .field("endpoint", &self.endpoint)
442 .field("has_access_key", &!self.access_key.is_empty())
443 .field("has_secret_key", &!self.secret_key.is_empty())
444 .field("skip_tls_verify", &self.skip_tls_verify)
445 .field("has_custom_ca", &!self.ca_cert_pem.is_empty())
446 .finish()
447 }
448}
449
450#[derive(Clone, PartialEq, Default)]
457pub struct SiteReplicationPeer {
458 document: Map<String, Value>,
459}
460
461impl<'de> Deserialize<'de> for SiteReplicationPeer {
462 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
463 where
464 D: Deserializer<'de>,
465 {
466 let value = Value::deserialize(deserializer)?;
467 let document = value
468 .as_object()
469 .cloned()
470 .ok_or_else(|| serde::de::Error::custom("site replication peer must be an object"))?;
471 Ok(Self { document })
472 }
473}
474
475impl Serialize for SiteReplicationPeer {
476 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
477 where
478 S: Serializer,
479 {
480 self.document.serialize(serializer)
481 }
482}
483
484impl SiteReplicationPeer {
485 pub fn endpoint(&self) -> Option<&str> {
486 self.string_field("endpoint")
487 .filter(|endpoint| !endpoint.is_empty())
488 .or_else(|| {
489 self.string_field("endpoints")
490 .filter(|endpoint| !endpoint.is_empty())
491 })
492 }
493
494 pub fn name(&self) -> Option<&str> {
495 self.string_field("name")
496 }
497
498 pub fn deployment_id(&self) -> Option<&str> {
499 self.string_field("deploymentID")
500 }
501
502 pub fn sync(&self) -> Option<&str> {
503 self.string_field("sync")
504 }
505
506 pub fn default_bandwidth(&self) -> Option<&Value> {
507 self.document.get("defaultbandwidth")
508 }
509
510 pub fn replicate_ilm_expiry(&self) -> Option<bool> {
511 self.document
512 .get("replicate-ilm-expiry")
513 .and_then(Value::as_bool)
514 }
515
516 pub fn object_naming_mode(&self) -> Option<&str> {
517 self.string_field("objectNamingMode")
518 }
519
520 pub fn skip_tls_verify(&self) -> Option<bool> {
521 self.document.get("skipTlsVerify").and_then(Value::as_bool)
522 }
523
524 pub fn ca_cert_pem(&self) -> Option<&str> {
525 self.string_field("caCertPem")
526 }
527
528 pub fn api_version(&self) -> Option<&str> {
529 self.string_field("apiVersion")
530 }
531
532 pub fn has_custom_ca(&self) -> bool {
533 self.ca_cert_pem().is_some_and(|pem| !pem.is_empty())
534 }
535
536 pub fn set_endpoint(&mut self, endpoint: String) {
537 self.document
538 .insert("endpoint".to_string(), Value::String(endpoint));
539 }
540
541 pub fn set_name(&mut self, name: String) {
542 self.document
543 .insert("name".to_string(), Value::String(name));
544 }
545
546 pub fn set_skip_tls_verify(&mut self, skip_tls_verify: bool) {
547 self.document
548 .insert("skipTlsVerify".to_string(), Value::Bool(skip_tls_verify));
549 }
550
551 pub fn set_ca_cert_pem(&mut self, ca_cert_pem: String) {
552 self.document
553 .insert("caCertPem".to_string(), Value::String(ca_cert_pem));
554 }
555
556 fn string_field(&self, name: &str) -> Option<&str> {
557 self.document.get(name).and_then(Value::as_str)
558 }
559}
560
561impl fmt::Debug for SiteReplicationPeer {
562 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
563 formatter
564 .debug_struct("SiteReplicationPeer")
565 .field("has_endpoint", &self.endpoint().is_some())
566 .field("has_name", &self.name().is_some())
567 .field("has_deployment_id", &self.deployment_id().is_some())
568 .field("skip_tls_verify", &self.skip_tls_verify())
569 .field("has_custom_ca", &self.has_custom_ca())
570 .field("field_count", &self.document.len())
571 .finish()
572 }
573}
574
575#[derive(Clone, PartialEq, Default)]
577pub struct SiteReplicationInfo {
578 pub enabled: bool,
579 pub name: String,
580 pub sites: Vec<SiteReplicationPeer>,
581 pub api_version: String,
582 pub extensions: BTreeMap<String, Value>,
583}
584
585impl<'de> Deserialize<'de> for SiteReplicationInfo {
586 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
587 where
588 D: Deserializer<'de>,
589 {
590 #[derive(Deserialize)]
591 struct Wire {
592 #[serde(default)]
593 enabled: bool,
594 #[serde(default)]
595 name: String,
596 #[serde(default)]
597 sites: Vec<SiteReplicationPeer>,
598 #[serde(rename = "serviceAccountAccessKey", default)]
599 _service_account_access_key: Option<serde::de::IgnoredAny>,
600 #[serde(rename = "apiVersion", default)]
601 api_version: String,
602 #[serde(flatten)]
603 extensions: BTreeMap<String, Value>,
604 }
605
606 let wire = Wire::deserialize(deserializer)?;
607 Ok(Self {
608 enabled: wire.enabled,
609 name: wire.name,
610 sites: wire.sites,
611 api_version: wire.api_version,
612 extensions: wire.extensions,
613 })
614 }
615}
616
617impl fmt::Debug for SiteReplicationInfo {
618 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
619 formatter
620 .debug_struct("SiteReplicationInfo")
621 .field("enabled", &self.enabled)
622 .field("has_name", &!self.name.is_empty())
623 .field("sites", &self.sites)
624 .field("has_api_version", &!self.api_version.is_empty())
625 .field("extension_count", &self.extensions.len())
626 .finish()
627 }
628}
629
630impl SiteReplicationInfo {
631 pub fn resolve_peer(&self, selector: &str) -> Result<&SiteReplicationPeer> {
633 if let Some(peer) = self
634 .sites
635 .iter()
636 .find(|peer| peer.deployment_id() == Some(selector))
637 {
638 return Ok(peer);
639 }
640
641 let mut matching_names = self
642 .sites
643 .iter()
644 .filter(|peer| peer.name() == Some(selector));
645 let peer = matching_names.next().ok_or_else(|| {
646 Error::NotFound(format!(
647 "site '{selector}' does not match an exact deployment ID or site name"
648 ))
649 })?;
650 if matching_names.next().is_some() {
651 return Err(Error::Conflict(format!(
652 "site name '{selector}' matches multiple deployments; use a deployment ID"
653 )));
654 }
655 Ok(peer)
656 }
657}
658
659#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
661#[serde(rename_all = "lowercase")]
662pub enum SiteReplicationResyncOperation {
663 Start,
664 Status,
665 Cancel,
666}
667
668impl SiteReplicationResyncOperation {
669 pub const fn as_str(self) -> &'static str {
671 match self {
672 Self::Start => "start",
673 Self::Status => "status",
674 Self::Cancel => "cancel",
675 }
676 }
677
678 pub const fn is_mutation(self) -> bool {
680 matches!(self, Self::Start | Self::Cancel)
681 }
682}
683
684#[derive(Clone, Serialize, Deserialize, PartialEq, Default)]
686pub struct SiteReplicationResyncBucketStatus {
687 #[serde(default)]
688 pub bucket: String,
689 #[serde(default)]
690 pub status: String,
691 #[serde(rename = "errorDetail", default)]
692 pub error_detail: String,
693 #[serde(flatten)]
694 pub extensions: BTreeMap<String, Value>,
695}
696
697impl SiteReplicationResyncBucketStatus {
698 pub fn has_failure(&self) -> bool {
700 status_is_failed(&self.status) || !self.error_detail.trim().is_empty()
701 }
702}
703
704impl fmt::Debug for SiteReplicationResyncBucketStatus {
705 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
706 formatter
707 .debug_struct("SiteReplicationResyncBucketStatus")
708 .field("has_bucket", &!self.bucket.is_empty())
709 .field("has_status", &!self.status.is_empty())
710 .field("has_error_detail", &!self.error_detail.is_empty())
711 .field("extension_count", &self.extensions.len())
712 .finish()
713 }
714}
715
716#[derive(Clone, Serialize, Deserialize, Default)]
718pub struct SiteReplicationResyncStatus {
719 #[serde(rename = "op", default)]
720 pub operation: String,
721 #[serde(rename = "id", default)]
722 pub resync_id: String,
723 #[serde(default)]
724 pub status: String,
725 #[serde(default)]
726 pub buckets: Vec<SiteReplicationResyncBucketStatus>,
727 #[serde(rename = "errorDetail", default)]
728 pub error_detail: String,
729 #[serde(flatten)]
730 pub extensions: BTreeMap<String, Value>,
731 #[serde(skip)]
732 semantic_failure: Option<bool>,
733 #[serde(skip)]
734 semantic_not_found: bool,
735}
736
737impl SiteReplicationResyncStatus {
738 pub fn capture_semantics(&mut self) {
743 self.semantic_failure = Some(self.compute_failure());
744 self.semantic_not_found = self.status.trim().eq_ignore_ascii_case("not-found");
745 }
746
747 pub fn has_failure(&self) -> bool {
749 self.semantic_failure
750 .unwrap_or_else(|| self.compute_failure())
751 }
752
753 pub fn is_not_found(&self) -> bool {
755 self.semantic_not_found || self.status.trim().eq_ignore_ascii_case("not-found")
756 }
757
758 fn compute_failure(&self) -> bool {
759 !status_is_success(&self.status)
760 || !self.error_detail.trim().is_empty()
761 || self
762 .buckets
763 .iter()
764 .any(SiteReplicationResyncBucketStatus::has_failure)
765 }
766}
767
768impl PartialEq for SiteReplicationResyncStatus {
769 fn eq(&self, other: &Self) -> bool {
770 self.operation == other.operation
771 && self.resync_id == other.resync_id
772 && self.status == other.status
773 && self.buckets == other.buckets
774 && self.error_detail == other.error_detail
775 && self.extensions == other.extensions
776 }
777}
778
779impl fmt::Debug for SiteReplicationResyncStatus {
780 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
781 formatter
782 .debug_struct("SiteReplicationResyncStatus")
783 .field("has_operation", &!self.operation.is_empty())
784 .field("has_resync_id", &!self.resync_id.is_empty())
785 .field("has_status", &!self.status.is_empty())
786 .field("bucket_count", &self.buckets.len())
787 .field("has_error_detail", &!self.error_detail.is_empty())
788 .field("extension_count", &self.extensions.len())
789 .finish()
790 }
791}
792
793fn status_is_failed(status: &str) -> bool {
794 status.trim().eq_ignore_ascii_case("failed")
795}
796
797fn status_is_success(status: &str) -> bool {
798 status.trim().eq_ignore_ascii_case("success")
799}
800
801#[derive(Clone, Serialize, Deserialize, PartialEq, Default)]
803pub struct ReplicateEditStatus {
804 #[serde(default)]
805 pub success: bool,
806 #[serde(default)]
807 pub status: String,
808 #[serde(rename = "errorDetail", default)]
809 pub error_detail: String,
810 #[serde(rename = "apiVersion", default)]
811 pub api_version: String,
812 #[serde(flatten)]
813 pub extensions: BTreeMap<String, Value>,
814}
815
816impl fmt::Debug for ReplicateEditStatus {
817 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
818 formatter
819 .debug_struct("ReplicateEditStatus")
820 .field("success", &self.success)
821 .field("has_status", &!self.status.is_empty())
822 .field("has_error_detail", &!self.error_detail.is_empty())
823 .field("has_api_version", &!self.api_version.is_empty())
824 .field("extension_count", &self.extensions.len())
825 .finish()
826 }
827}
828
829#[derive(Debug, Clone, Serialize, Deserialize, Default)]
831pub struct ServiceActionResult {
832 #[serde(default)]
833 pub action: String,
834 #[serde(default)]
835 pub accepted: bool,
836 #[serde(default)]
838 pub effective: bool,
839 #[serde(default)]
840 pub message: String,
841}
842
843#[derive(Debug, Clone, Default)]
845pub struct SiteStatusOptions {
846 pub buckets: bool,
847 pub users: bool,
848 pub groups: bool,
849 pub policies: bool,
850 pub metrics: bool,
851 pub peer_state: bool,
852 pub ilm_expiry_rules: bool,
853}
854
855#[derive(Debug, Clone, Serialize, Deserialize, Default)]
857pub struct SiteRemoveSpec {
858 #[serde(rename = "sites", default, skip_serializing_if = "Vec::is_empty")]
859 pub site_names: Vec<String>,
860 #[serde(rename = "all", default)]
861 pub remove_all: bool,
862}
863
864#[cfg(test)]
865mod tests {
866 use super::*;
867
868 const SITE_INFO: &str = r#"{
869 "enabled": true,
870 "name": "primary",
871 "sites": [{
872 "endpoint": "https://secondary.example.test",
873 "name": "secondary",
874 "deploymentID": "deployment-2",
875 "sync": "enable",
876 "defaultbandwidth": {
877 "bandwidthLimitPerBucket": 1048576,
878 "set": true,
879 "updatedAt": "2026-07-22T00:00:00Z",
880 "futureBandwidth": "preserved"
881 },
882 "replicate-ilm-expiry": true,
883 "objectNamingMode": "path",
884 "skipTlsVerify": false,
885 "caCertPem": "-----BEGIN CERTIFICATE-----\nSAFE-TO-SEND-NOT-SAFE-TO-PRINT\n-----END CERTIFICATE-----\n",
886 "apiVersion": "v1",
887 "futurePeer": {
888 "mode": "preserved",
889 "accessToken": "OPAQUE-TOKEN-MUST-NOT-PRINT",
890 "nested": [{"safe": "preserved", "sessionToken": "OPAQUE-TOKEN-MUST-NOT-PRINT"}]
891 }
892 }],
893 "serviceAccountAccessKey": "SHOULD-NEVER-BE-EXPOSED",
894 "apiVersion": "v1",
895 "futureInfo": "preserved",
896 "secretFuture": "OPAQUE-TOKEN-MUST-NOT-PRINT"
897 }"#;
898
899 #[test]
900 fn site_info_discards_wrapper_key_but_preserves_opaque_future_fields() {
901 let info: SiteReplicationInfo =
902 serde_json::from_str(SITE_INFO).expect("valid site information fixture");
903
904 let debug = format!("{info:?}");
905
906 assert!(!debug.contains("SHOULD-NEVER-BE-EXPOSED"));
907 assert!(!debug.contains("SAFE-TO-SEND-NOT-SAFE-TO-PRINT"));
908 assert!(!debug.contains("secretFuture"));
909 assert!(!debug.contains("accessToken"));
910 assert_eq!(info.extensions["futureInfo"], "preserved");
911 assert_eq!(
912 info.extensions["secretFuture"],
913 "OPAQUE-TOKEN-MUST-NOT-PRINT"
914 );
915 let wire = serde_json::to_value(&info.sites[0]).expect("peer is serializable");
916 assert_eq!(wire["futurePeer"]["mode"], "preserved");
917 assert_eq!(wire["futurePeer"]["nested"][0]["safe"], "preserved");
918 assert_eq!(
919 wire["futurePeer"]["nested"][0]["sessionToken"],
920 "OPAQUE-TOKEN-MUST-NOT-PRINT"
921 );
922 }
923
924 #[test]
925 fn peer_round_trip_preserves_edit_fields_and_opaque_future_fields() {
926 let info: SiteReplicationInfo =
927 serde_json::from_str(SITE_INFO).expect("valid site information fixture");
928 let peer = &info.sites[0];
929 let wire = serde_json::to_value(peer).expect("peer is serializable");
930
931 assert_eq!(wire["deploymentID"], "deployment-2");
932 assert_eq!(wire["sync"], "enable");
933 assert_eq!(wire["defaultbandwidth"]["bandwidthLimitPerBucket"], 1048576);
934 assert_eq!(wire["defaultbandwidth"]["futureBandwidth"], "preserved");
935 assert_eq!(wire["replicate-ilm-expiry"], true);
936 assert_eq!(wire["objectNamingMode"], "path");
937 assert_eq!(wire["caCertPem"], peer.ca_cert_pem().expect("CA field"));
938 assert_eq!(wire["futurePeer"]["mode"], "preserved");
939 assert_eq!(
940 wire["futurePeer"]["accessToken"],
941 "OPAQUE-TOKEN-MUST-NOT-PRINT"
942 );
943 assert_eq!(
944 wire["futurePeer"]["nested"][0]["sessionToken"],
945 "OPAQUE-TOKEN-MUST-NOT-PRINT"
946 );
947 }
948
949 #[test]
950 fn peer_edit_preserves_omitted_fields_and_exact_future_values() {
951 let mut peer: SiteReplicationPeer = serde_json::from_str(
952 r#"{
953 "endpoint":"https://old.example.test",
954 "deploymentID":"deployment-2",
955 "sync":"future-sync-mode",
956 "defaultbandwidth":{"futureShape":[1,{"safe":true}]},
957 "futurePeer":{"nested":[1,2,3]}
958 }"#,
959 )
960 .expect("valid peer fixture");
961
962 peer.set_endpoint("https://new.example.test".into());
963 let wire = serde_json::to_value(peer).expect("peer is serializable");
964
965 assert_eq!(wire["endpoint"], "https://new.example.test");
966 assert_eq!(wire["sync"], "future-sync-mode");
967 assert_eq!(wire["defaultbandwidth"]["futureShape"][1]["safe"], true);
968 assert_eq!(wire["futurePeer"]["nested"], serde_json::json!([1, 2, 3]));
969 for absent in [
970 "name",
971 "replicate-ilm-expiry",
972 "objectNamingMode",
973 "skipTlsVerify",
974 "caCertPem",
975 "apiVersion",
976 ] {
977 assert!(wire.get(absent).is_none(), "{absent} must remain omitted");
978 }
979 }
980
981 #[test]
982 fn peer_endpoint_falls_back_to_legacy_string_without_overriding_singular_endpoint() {
983 let legacy: SiteReplicationPeer = serde_json::from_value(serde_json::json!({
984 "endpoints": "https://legacy.example.test"
985 }))
986 .expect("legacy peer fixture");
987 let both: SiteReplicationPeer = serde_json::from_value(serde_json::json!({
988 "endpoint": "https://current.example.test",
989 "endpoints": "https://legacy.example.test"
990 }))
991 .expect("current peer fixture");
992 let empty_current: SiteReplicationPeer = serde_json::from_value(serde_json::json!({
993 "endpoint": "",
994 "endpoints": "https://legacy.example.test"
995 }))
996 .expect("empty current endpoint fixture");
997 let malformed_legacy: SiteReplicationPeer = serde_json::from_value(serde_json::json!({
998 "endpoints": ["https://legacy.example.test"]
999 }))
1000 .expect("opaque legacy peer fixture");
1001
1002 assert_eq!(legacy.endpoint(), Some("https://legacy.example.test"));
1003 assert_eq!(both.endpoint(), Some("https://current.example.test"));
1004 assert_eq!(
1005 empty_current.endpoint(),
1006 Some("https://legacy.example.test")
1007 );
1008 assert_eq!(malformed_legacy.endpoint(), None);
1009 }
1010
1011 #[test]
1012 fn peer_debug_reports_ca_presence_without_exposing_certificate() {
1013 let info: SiteReplicationInfo =
1014 serde_json::from_str(SITE_INFO).expect("valid site information fixture");
1015 let debug = format!("{:?}", info.sites[0]);
1016
1017 assert!(debug.contains("has_custom_ca: true"));
1018 assert!(!debug.contains("SAFE-TO-SEND-NOT-SAFE-TO-PRINT"));
1019 assert!(!debug.contains("https://secondary.example.test"));
1020 assert!(!debug.contains("sessionToken"));
1021 }
1022
1023 #[test]
1024 fn peer_site_spec_debug_redacts_credentials_and_certificate() {
1025 let spec = PeerSiteSpec {
1026 name: "secondary".into(),
1027 endpoint: "https://secondary.example.test".into(),
1028 access_key: "ACCESS-SECRET".into(),
1029 secret_key: "SECRET-SECRET".into(),
1030 skip_tls_verify: false,
1031 ca_cert_pem: "CERTIFICATE-SECRET".into(),
1032 };
1033
1034 let debug = format!("{spec:?}");
1035 assert!(!debug.contains("ACCESS-SECRET"));
1036 assert!(!debug.contains("SECRET-SECRET"));
1037 assert!(!debug.contains("CERTIFICATE-SECRET"));
1038 assert!(debug.contains("has_access_key: true"));
1039 assert!(debug.contains("has_custom_ca: true"));
1040 }
1041
1042 #[test]
1043 fn resolve_peer_prefers_exact_deployment_id_over_name() {
1044 let mut info: SiteReplicationInfo =
1045 serde_json::from_str(SITE_INFO).expect("valid site information fixture");
1046 let mut other = info.sites[0].clone();
1047 other
1048 .document
1049 .insert("name".into(), Value::String("deployment-2".into()));
1050 other
1051 .document
1052 .insert("deploymentID".into(), Value::String("deployment-3".into()));
1053 info.sites.push(other);
1054
1055 let resolved = info
1056 .resolve_peer("deployment-2")
1057 .expect("deployment ID takes precedence");
1058 assert_eq!(resolved.deployment_id(), Some("deployment-2"));
1059 assert_eq!(resolved.name(), Some("secondary"));
1060 }
1061
1062 #[test]
1063 fn resolve_peer_requires_unique_exact_name() {
1064 let mut info: SiteReplicationInfo =
1065 serde_json::from_str(SITE_INFO).expect("valid site information fixture");
1066 let mut duplicate = info.sites[0].clone();
1067 duplicate
1068 .document
1069 .insert("deploymentID".into(), Value::String("deployment-3".into()));
1070 info.sites.push(duplicate);
1071
1072 let error = info
1073 .resolve_peer("secondary")
1074 .expect_err("duplicate names are ambiguous");
1075 assert!(matches!(error, crate::Error::Conflict(_)));
1076 }
1077
1078 #[test]
1079 fn resolve_peer_does_not_guess_partial_names() {
1080 let info: SiteReplicationInfo =
1081 serde_json::from_str(SITE_INFO).expect("valid site information fixture");
1082
1083 let error = info
1084 .resolve_peer("second")
1085 .expect_err("partial names must not match");
1086 assert!(matches!(error, crate::Error::NotFound(_)));
1087 }
1088
1089 #[test]
1090 fn edit_status_uses_exact_server_field_names() {
1091 let status: ReplicateEditStatus = serde_json::from_str(
1092 r#"{"success":true,"status":"updated","errorDetail":"","apiVersion":"v1","future":"preserved"}"#,
1093 )
1094 .expect("valid edit status fixture");
1095
1096 assert!(status.success);
1097 assert_eq!(status.error_detail, "");
1098 assert_eq!(status.extensions["future"], "preserved");
1099 assert_eq!(
1100 serde_json::to_value(&status).expect("status is serializable")["errorDetail"],
1101 ""
1102 );
1103 let debug = format!("{status:?}");
1104 assert!(!debug.contains("updated"));
1105 assert!(!debug.contains("preserved"));
1106 }
1107
1108 #[test]
1109 fn resync_operations_use_exact_wire_values_and_classify_mutations() {
1110 for (operation, wire, mutation) in [
1111 (SiteReplicationResyncOperation::Start, "start", true),
1112 (SiteReplicationResyncOperation::Status, "status", false),
1113 (SiteReplicationResyncOperation::Cancel, "cancel", true),
1114 ] {
1115 assert_eq!(operation.as_str(), wire);
1116 assert_eq!(operation.is_mutation(), mutation);
1117 assert_eq!(
1118 serde_json::to_value(operation).expect("operation is serializable"),
1119 wire
1120 );
1121 assert_eq!(
1122 serde_json::from_value::<SiteReplicationResyncOperation>(Value::String(
1123 wire.to_string()
1124 ))
1125 .expect("operation is deserializable"),
1126 operation
1127 );
1128 }
1129 }
1130
1131 #[test]
1132 fn resync_status_preserves_exact_fields_and_unknown_extensions() {
1133 let status: SiteReplicationResyncStatus = serde_json::from_value(serde_json::json!({
1134 "op": "start",
1135 "id": "resync-id",
1136 "status": "success",
1137 "buckets": [{
1138 "bucket": "photos",
1139 "status": "started",
1140 "errorDetail": "",
1141 "futureBucket": {"attempt": 2}
1142 }],
1143 "errorDetail": "",
1144 "futureResponse": {"revision": 7}
1145 }))
1146 .expect("valid site resync status");
1147
1148 assert_eq!(status.operation, "start");
1149 assert_eq!(status.resync_id, "resync-id");
1150 assert_eq!(status.status, "success");
1151 assert_eq!(status.buckets[0].bucket, "photos");
1152 assert_eq!(status.buckets[0].status, "started");
1153 assert_eq!(status.buckets[0].extensions["futureBucket"]["attempt"], 2);
1154 assert_eq!(status.extensions["futureResponse"]["revision"], 7);
1155
1156 let wire = serde_json::to_value(status).expect("status is serializable");
1157 assert_eq!(wire["op"], "start");
1158 assert_eq!(wire["id"], "resync-id");
1159 assert_eq!(wire["buckets"][0]["errorDetail"], "");
1160 assert_eq!(wire["futureResponse"]["revision"], 7);
1161 }
1162
1163 #[test]
1164 fn resync_status_wire_equality_ignores_frozen_output_semantics() {
1165 let mut status: SiteReplicationResyncStatus = serde_json::from_value(serde_json::json!({
1166 "op": "status",
1167 "status": "not-found"
1168 }))
1169 .expect("valid site resync status");
1170 status.capture_semantics();
1171 status.status = "[REDACTED]".to_string();
1172
1173 assert!(status.is_not_found());
1174 assert!(status.has_failure());
1175 let wire = serde_json::to_vec(&status).expect("status is serializable");
1176 let round_trip: SiteReplicationResyncStatus =
1177 serde_json::from_slice(&wire).expect("status is deserializable");
1178 assert_eq!(status, round_trip);
1179 }
1180
1181 #[test]
1182 fn resync_status_debug_never_prints_arbitrary_server_strings() {
1183 let status: SiteReplicationResyncStatus = serde_json::from_value(serde_json::json!({
1184 "op": "SECRET-OPERATION",
1185 "id": "SECRET-RESYNC-ID",
1186 "status": "SECRET-STATUS",
1187 "buckets": [{
1188 "bucket": "SECRET-BUCKET",
1189 "status": "SECRET-BUCKET-STATUS",
1190 "errorDetail": "SECRET-BUCKET-ERROR",
1191 "SECRET-BUCKET-EXTENSION": "SECRET-BUCKET-VALUE"
1192 }],
1193 "errorDetail": "SECRET-ERROR",
1194 "SECRET-EXTENSION": "SECRET-VALUE"
1195 }))
1196 .expect("valid sensitive status fixture");
1197
1198 let status_debug = format!("{status:?}");
1199 let bucket_debug = format!("{:?}", status.buckets[0]);
1200 for secret in [
1201 "SECRET-OPERATION",
1202 "SECRET-RESYNC-ID",
1203 "SECRET-STATUS",
1204 "SECRET-BUCKET",
1205 "SECRET-BUCKET-STATUS",
1206 "SECRET-BUCKET-ERROR",
1207 "SECRET-BUCKET-EXTENSION",
1208 "SECRET-BUCKET-VALUE",
1209 "SECRET-ERROR",
1210 "SECRET-EXTENSION",
1211 "SECRET-VALUE",
1212 ] {
1213 assert!(!status_debug.contains(secret));
1214 assert!(!bucket_debug.contains(secret));
1215 }
1216 assert!(status_debug.contains("bucket_count: 1"));
1217 assert!(bucket_debug.contains("has_error_detail: true"));
1218 }
1219
1220 #[test]
1221 fn resync_status_detects_overall_and_partial_bucket_failures() {
1222 let success_bucket = SiteReplicationResyncBucketStatus {
1223 bucket: "photos".into(),
1224 status: "started".into(),
1225 ..Default::default()
1226 };
1227 let success = SiteReplicationResyncStatus {
1228 status: "success".into(),
1229 buckets: vec![success_bucket.clone()],
1230 ..Default::default()
1231 };
1232 assert!(!success.has_failure());
1233
1234 let overall_failed = SiteReplicationResyncStatus {
1235 status: "FAILED".into(),
1236 ..Default::default()
1237 };
1238 assert!(overall_failed.has_failure());
1239
1240 let partial_overall = SiteReplicationResyncStatus {
1241 status: "partial".into(),
1242 ..Default::default()
1243 };
1244 assert!(partial_overall.has_failure());
1245
1246 let overall_error = SiteReplicationResyncStatus {
1247 status: "success".into(),
1248 error_detail: "partial failure".into(),
1249 ..Default::default()
1250 };
1251 assert!(overall_error.has_failure());
1252
1253 let failed_bucket = SiteReplicationResyncStatus {
1254 status: "success".into(),
1255 buckets: vec![SiteReplicationResyncBucketStatus {
1256 status: "failed".into(),
1257 ..Default::default()
1258 }],
1259 ..Default::default()
1260 };
1261 assert!(failed_bucket.has_failure());
1262
1263 let bucket_error = SiteReplicationResyncStatus {
1264 status: "success".into(),
1265 buckets: vec![SiteReplicationResyncBucketStatus {
1266 status: "started".into(),
1267 error_detail: "target rejected bucket".into(),
1268 ..Default::default()
1269 }],
1270 ..Default::default()
1271 };
1272 assert!(bucket_error.has_failure());
1273 assert!(bucket_error.buckets[0].has_failure());
1274 assert!(!success_bucket.has_failure());
1275 }
1276
1277 #[test]
1278 fn ca_bundle_requires_valid_x509_der() {
1279 assert!(validate_site_replication_ca_bundle(include_bytes!("test_ca.pem")).is_ok());
1280 assert!(
1281 validate_site_replication_ca_bundle(
1282 b"-----BEGIN CERTIFICATE-----\nAA==\n-----END CERTIFICATE-----\n"
1283 )
1284 .is_err()
1285 );
1286 }
1287
1288 #[test]
1289 fn ca_bundle_accepts_exact_limit_and_rejects_limit_plus_one() {
1290 let certificate = include_bytes!("test_ca.pem");
1291 let mut exact = certificate.to_vec();
1292 exact.push(b'\n');
1293 exact.resize(MAX_SITE_REPLICATION_CA_CERT_BYTES, b' ');
1294 assert_eq!(exact.len(), MAX_SITE_REPLICATION_CA_CERT_BYTES);
1295 assert!(validate_site_replication_ca_bundle(&exact).is_ok());
1296
1297 exact.push(b' ');
1298 assert!(validate_site_replication_ca_bundle(&exact).is_err());
1299 }
1300
1301 #[test]
1302 fn repair_identifiers_are_strictly_validated() {
1303 assert!(
1304 validate_site_replication_repair_token("abcdefghijklmnopqrstuvwxyzABCDEFGH012345678")
1305 .is_ok()
1306 );
1307 assert!(validate_site_replication_repair_token("short").is_err());
1308 assert!(
1309 validate_site_replication_repair_token("abcdefghijklmnopqrstuvwxyzABCDEFGH01234567=")
1310 .is_err()
1311 );
1312
1313 assert!(
1314 validate_site_replication_repair_operation_id("550e8400-e29b-41d4-a716-446655440000")
1315 .is_ok()
1316 );
1317 assert!(
1318 validate_site_replication_repair_operation_id("550e8400e29b41d4a716446655440000")
1319 .is_err()
1320 );
1321
1322 let preflight: SiteReplicationRepairPreflight = serde_json::from_value(serde_json::json!({
1323 "mode": "dry-run",
1324 "status": "planned",
1325 "preflightToken": "abcdefghijklmnopqrstuvwxyzABCDEFGH012345678",
1326 "retryEvents": 0,
1327 "sites": {}
1328 }))
1329 .expect("valid empty preflight");
1330 assert!(!format!("{preflight:?}").contains(&preflight.preflight_token));
1331 }
1332
1333 #[test]
1334 fn repair_snapshot_rejects_inconsistent_counts_and_detects_partial() {
1335 let json = serde_json::json!({
1336 "mode": "execute",
1337 "operationId": "550e8400-e29b-41d4-a716-446655440000",
1338 "status": "partial",
1339 "createdAt": "2026-07-25T00:00:00Z",
1340 "updatedAt": "2026-07-25T00:01:00Z",
1341 "sites": {
1342 "dep-2": {
1343 "deploymentId": "dep-2",
1344 "name": "secondary",
1345 "families": {
1346 "iam": {
1347 "planned": 1,
1348 "succeeded": 0,
1349 "failed": 1,
1350 "retryEvents": 1,
1351 "tasks": [{
1352 "taskId": "abcdefghijklmnopqrstuvwxyzABCDEFGH012345678",
1353 "status": "failed",
1354 "error": "remote-operation-failed"
1355 }],
1356 "errors": ["remote-operation-failed"]
1357 }
1358 }
1359 }
1360 }
1361 });
1362 let status: SiteReplicationRepairOperationStatus =
1363 serde_json::from_value(json.clone()).expect("valid repair response");
1364 assert!(
1365 status
1366 .validate("550e8400-e29b-41d4-a716-446655440000")
1367 .is_ok()
1368 );
1369 assert!(status.has_failure());
1370
1371 let mut invalid = json;
1372 invalid["sites"]["dep-2"]["families"]["iam"]["planned"] = Value::from(0);
1373 let status: SiteReplicationRepairOperationStatus =
1374 serde_json::from_value(invalid).expect("syntactically valid repair response");
1375 assert!(
1376 status
1377 .validate("550e8400-e29b-41d4-a716-446655440000")
1378 .is_err()
1379 );
1380 }
1381}