Skip to main content

rc_core/admin/
site.rs

1//! Service control and site replication admin types.
2//!
3//! Wire formats mirror the RustFS server's `crates/madmin` definitions
4//! (MinIO-admin-compatible JSON field names).
5
6use 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
16/// Maximum successful site replication response accepted from the server.
17pub const MAX_SITE_REPLICATION_SUCCESS_RESPONSE_BYTES: usize = 8 * 1024 * 1024;
18/// Maximum site replication error response accepted from the server.
19pub const MAX_SITE_REPLICATION_ERROR_RESPONSE_BYTES: usize = 64 * 1024;
20/// Maximum serialized site replication edit request.
21pub const MAX_SITE_REPLICATION_REQUEST_BYTES: usize = 1024 * 1024;
22/// Maximum custom CA bundle accepted by the CLI.
23pub const MAX_SITE_REPLICATION_CA_CERT_BYTES: usize = 256 * 1024;
24/// Stable capability name for the durable site-replication repair lifecycle.
25pub const SITE_REPLICATION_REPAIR_CAPABILITY: &str = "admin.site-replication.repair";
26/// Maximum preflight/operation response accepted from the repair routes.
27pub const MAX_SITE_REPLICATION_REPAIR_RESPONSE_BYTES: usize = 8 * 1024 * 1024;
28
29/// Validate the opaque HMAC-SHA256 v1 preflight token without inspecting its contents.
30pub 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
44/// Validate the canonical UUID syntax required for durable operation identifiers.
45pub 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/// Capability contract advertised by `/rustfs/admin/v4/runtime/capabilities`.
65#[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    /// Reject incomplete or incompatible server advertisements before using a repair route.
80    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/// Request body for dry-run and execute repair operations.
106#[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/// One durable task checkpoint.
117#[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/// Per-family task counts and checkpoints.
127#[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/// Per-site repair plan or operation status.
142#[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/// Dry-run response containing the server-issued preflight token.
151#[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/// Durable execute/status snapshot.
187#[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    /// Terminal snapshots that must cause a non-zero CLI exit.
237    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/// Durable site-replication repair transport.
324#[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
341/// Validate a bounded certificate-only PEM bundle for site replication edits.
342pub 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/// A peer site definition for `site-replication/add`.
413///
414/// Field names follow the MinIO admin wire format (note: the endpoint
415/// serializes as `endpoints`).
416#[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/// Complete editable peer snapshot returned by `site-replication/info`.
451///
452/// The original object is retained so omitted fields and future server fields
453/// survive a read-modify-write edit. Typed accessors are the only supported way
454/// to inspect or mutate known fields. Custom debug output never prints the CA or
455/// arbitrary field values.
456#[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/// Typed site replication information with server credentials discarded.
576#[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    /// Resolve an exact deployment ID, then a unique exact site name.
632    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/// Operation accepted by `site-replication/resync/op`.
660#[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    /// Exact query value expected by the RustFS admin route.
670    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    /// Whether this operation changes server-side resync state.
679    pub const fn is_mutation(self) -> bool {
680        matches!(self, Self::Start | Self::Cancel)
681    }
682}
683
684/// Per-bucket result returned by a site replication resync operation.
685#[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    /// Detect a failed bucket even when the server's status summary is stale.
699    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/// Typed response from `site-replication/resync/op`.
717#[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    /// Freeze protocol semantics before credentials are redacted for output.
739    ///
740    /// Once captured, the semantic result intentionally remains stable even if
741    /// the public wire fields are replaced by their safe output projections.
742    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    /// Detect overall and partial failures without trusting a success summary.
748    pub fn has_failure(&self) -> bool {
749        self.semantic_failure
750            .unwrap_or_else(|| self.compute_failure())
751    }
752
753    /// Detect the wire-level no-snapshot marker after output sanitization.
754    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/// Typed response from `site-replication/edit`.
802#[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/// Response from `POST /service?action=...`.
830#[derive(Debug, Clone, Serialize, Deserialize, Default)]
831pub struct ServiceActionResult {
832    #[serde(default)]
833    pub action: String,
834    #[serde(default)]
835    pub accepted: bool,
836    /// Whether the action takes real effect on this build (vs advisory only).
837    #[serde(default)]
838    pub effective: bool,
839    #[serde(default)]
840    pub message: String,
841}
842
843/// Options for `site-replication/status`.
844#[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/// Request body for `site-replication/remove`.
856#[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}