Skip to main content

traverse_runtime/
data_store_hosted_sync.rs

1//! Provider-neutral hosted `DataStore` synchronization transport (spec `087`).
2//!
3//! Portable code depends only on [`HostedSyncTransport`]. The deterministic
4//! [`InMemoryHostedSyncTransport`] and optional [`AblyHostedSyncTransport`]
5//! share one conformance suite (QG-001). Provider SDKs and native channel
6//! names stay inside the Ably adapter edge and never appear on the port.
7
8use crate::events::{LifecycleStatus, TraverseEvent};
9use serde::{Deserialize, Serialize};
10use serde_json::{Value, json};
11use sha2::{Digest, Sha256};
12use std::collections::{BTreeMap, BTreeSet, VecDeque};
13
14const HOSTED_TRANSPORT_SPEC: &str = "087-hosted-datastore-transport";
15const SYNC_PROTOCOL_SPEC: &str = "089-datastore-synchronization";
16const MIN_REPLAY_WINDOW_MS: u64 = 120_000;
17const HEXADECIMAL_DIGITS: &[u8; 16] = b"0123456789abcdef";
18
19/// Opaque, backend-derived synchronization scope (FR-004). Not a capability ID.
20#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
21pub struct SyncScopeId(String);
22
23impl SyncScopeId {
24    /// Creates a scope identifier from an opaque backend string.
25    #[must_use]
26    pub fn new(value: impl Into<String>) -> Self {
27        Self(value.into())
28    }
29
30    /// Returns the opaque scope string.
31    #[must_use]
32    pub fn as_str(&self) -> &str {
33        &self.0
34    }
35
36    /// Returns a non-reversible hash suitable for redacted observability (FR-011).
37    #[must_use]
38    pub fn hashed(&self) -> String {
39        hex_digest(self.0.as_bytes())
40    }
41}
42
43/// Short-lived, least-privilege credential issued by the application backend (FR-003).
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45pub struct HostedSyncCredential {
46    pub token: String,
47    pub scope: SyncScopeId,
48    /// Logical expiry instant supplied by the host (milliseconds).
49    pub expires_at_ms: u64,
50}
51
52/// Encrypted synchronization operation. The adapter never interprets plaintext (FR-006).
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
54pub struct EncryptedSyncOperation {
55    pub operation_id: String,
56    pub synchronization_set_id: String,
57    pub writer_id: String,
58    pub lamport_clock: u64,
59    pub correlation_id: Option<String>,
60    pub causation_id: Option<String>,
61    pub key_version_id: String,
62    /// Application-layer ciphertext; never logged by the adapter.
63    pub ciphertext: Vec<u8>,
64}
65
66/// Typed connection / synchronization state (FR-005, FR-010).
67#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(rename_all = "snake_case")]
69pub enum HostedSyncConnectionState {
70    Disconnected,
71    Connected,
72    Degraded { reason: HostedSyncDegradedReason },
73    Recovering,
74}
75
76/// Why synchronization entered a degraded state.
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
78#[serde(rename_all = "snake_case")]
79pub enum HostedSyncDegradedReason {
80    RelayUnavailable,
81    CredentialExpired,
82    CredentialRefreshFailed,
83}
84
85/// Stable machine-readable failure codes (FR-005, FR-012).
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
87#[serde(rename_all = "snake_case")]
88pub enum HostedSyncErrorCode {
89    UnauthorizedScope,
90    CredentialExpired,
91    CredentialMismatch,
92    InvalidEnvelope,
93    KeyMismatch,
94    ProviderUnavailable,
95    NotConnected,
96    ResyncRequired,
97}
98
99/// Sanitized transport error with secret-free details.
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
101pub struct HostedSyncError {
102    pub code: HostedSyncErrorCode,
103    pub message: String,
104    pub details: Value,
105}
106
107/// Outcome of a cursor-based replay request (FR-008).
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
109#[serde(tag = "outcome", rename_all = "snake_case")]
110pub enum HostedSyncReplayResult {
111    Delivered {
112        operations: Vec<EncryptedSyncOperation>,
113        cursor: String,
114    },
115    ResyncRequired {
116        oldest_available_cursor: Option<String>,
117    },
118}
119
120/// Publish acknowledgement with opaque cursor advancement.
121#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
122pub struct HostedSyncPublishReceipt {
123    pub operation_id: String,
124    pub cursor: String,
125}
126
127/// Redacted observability record (FR-011).
128#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
129pub struct HostedSyncObservation {
130    pub governing_spec: String,
131    pub kind: String,
132    pub operation_id: Option<String>,
133    pub key_version_id: Option<String>,
134    pub hashed_scope: Option<String>,
135    pub connection_state: Option<HostedSyncConnectionState>,
136    pub outcome: Option<String>,
137    pub latency_ms: Option<u64>,
138}
139
140/// Declared/observed lineage evidence for one relayed operation (FR-012).
141#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
142pub struct HostedSyncLineageEvidence {
143    pub governing_spec: String,
144    pub protocol_spec: String,
145    pub operation_id: String,
146    pub synchronization_set_id: String,
147    pub writer_id: String,
148    pub lamport_clock: u64,
149    pub correlation_id: Option<String>,
150    pub causation_id: Option<String>,
151    pub key_version_id: String,
152    pub event_id: String,
153    pub observed: bool,
154}
155
156/// Provider-neutral hosted synchronization transport port (FR-001).
157pub trait HostedSyncTransport {
158    /// Connects with an application-issued scoped credential.
159    ///
160    /// # Errors
161    ///
162    /// Returns a typed [`HostedSyncError`] when the credential is rejected or
163    /// the provider is unavailable.
164    fn connect(&mut self, credential: HostedSyncCredential) -> Result<(), HostedSyncError>;
165
166    /// Replaces the active credential before expiry (FR-005).
167    ///
168    /// # Errors
169    ///
170    /// Returns a typed [`HostedSyncError`] on mismatch, expiry, or outage.
171    fn refresh_credential(
172        &mut self,
173        credential: HostedSyncCredential,
174    ) -> Result<(), HostedSyncError>;
175
176    /// Publishes one encrypted operation without interpreting plaintext (FR-002).
177    ///
178    /// # Errors
179    ///
180    /// Returns a typed [`HostedSyncError`] for envelope, auth, key, or outage failures.
181    fn publish(
182        &mut self,
183        operation: EncryptedSyncOperation,
184    ) -> Result<HostedSyncPublishReceipt, HostedSyncError>;
185
186    /// Replays retained operations from `cursor`, or from the channel head when `None`.
187    ///
188    /// # Errors
189    ///
190    /// Returns a typed [`HostedSyncError`] when disconnected or the provider fails.
191    /// Expired cursors return [`HostedSyncReplayResult::ResyncRequired`], not an error.
192    fn replay_from(
193        &mut self,
194        cursor: Option<&str>,
195    ) -> Result<HostedSyncReplayResult, HostedSyncError>;
196
197    /// Returns the current connection / degraded / recovering state.
198    fn connection_state(&self) -> HostedSyncConnectionState;
199
200    /// Advances the host-supplied logical clock used for expiry and replay windows.
201    fn advance_clock(&mut self, now_ms: u64);
202
203    /// Marks the relay available or unavailable (test and host control plane).
204    fn set_relay_available(&mut self, available: bool);
205
206    /// Returns redacted observations accumulated since connect (FR-011).
207    fn observations(&self) -> &[HostedSyncObservation];
208
209    /// Returns lineage evidence for successfully relayed operations (FR-012).
210    fn lineage(&self) -> &[HostedSyncLineageEvidence];
211
212    /// Adapter identity for conformance evidence (`in_memory` or `ably`).
213    fn adapter_kind(&self) -> &'static str;
214}
215
216/// Deterministic in-memory hosted transport used as the replacement-boundary fixture.
217#[derive(Debug)]
218pub struct InMemoryHostedSyncTransport {
219    inner: SharedRelay,
220}
221
222impl InMemoryHostedSyncTransport {
223    /// Creates an empty in-memory relay with the minimum two-minute replay window.
224    #[must_use]
225    pub fn new() -> Self {
226        Self {
227            inner: SharedRelay::new(MIN_REPLAY_WINDOW_MS),
228        }
229    }
230}
231
232impl Default for InMemoryHostedSyncTransport {
233    fn default() -> Self {
234        Self::new()
235    }
236}
237
238impl HostedSyncTransport for InMemoryHostedSyncTransport {
239    fn connect(&mut self, credential: HostedSyncCredential) -> Result<(), HostedSyncError> {
240        self.inner.connect(credential)
241    }
242
243    fn refresh_credential(
244        &mut self,
245        credential: HostedSyncCredential,
246    ) -> Result<(), HostedSyncError> {
247        self.inner.refresh_credential(credential)
248    }
249
250    fn publish(
251        &mut self,
252        operation: EncryptedSyncOperation,
253    ) -> Result<HostedSyncPublishReceipt, HostedSyncError> {
254        self.inner.publish(operation)
255    }
256
257    fn replay_from(
258        &mut self,
259        cursor: Option<&str>,
260    ) -> Result<HostedSyncReplayResult, HostedSyncError> {
261        self.inner.replay_from(cursor)
262    }
263
264    fn connection_state(&self) -> HostedSyncConnectionState {
265        self.inner.connection_state()
266    }
267
268    fn advance_clock(&mut self, now_ms: u64) {
269        self.inner.advance_clock(now_ms);
270    }
271
272    fn set_relay_available(&mut self, available: bool) {
273        self.inner.set_relay_available(available);
274    }
275
276    fn observations(&self) -> &[HostedSyncObservation] {
277        self.inner.observations()
278    }
279
280    fn lineage(&self) -> &[HostedSyncLineageEvidence] {
281        self.inner.lineage()
282    }
283
284    fn adapter_kind(&self) -> &'static str {
285        "in_memory"
286    }
287}
288
289/// Optional Ably-shaped hosted adapter. Core callers depend only on
290/// [`HostedSyncTransport`]; Ably channel names stay inside [`AblyRealtimeEdge`].
291#[derive(Debug)]
292pub struct AblyHostedSyncTransport<E: AblyRealtimeEdge> {
293    edge: E,
294    session: Option<AblySession>,
295    now_ms: u64,
296    relay_available: bool,
297    accepted_key_versions: BTreeSet<String>,
298    seen_operation_ids: BTreeSet<String>,
299    observations: Vec<HostedSyncObservation>,
300    lineage: Vec<HostedSyncLineageEvidence>,
301    replay_window_ms: u64,
302}
303
304#[derive(Debug, Clone)]
305struct AblySession {
306    credential: HostedSyncCredential,
307    /// Provider-native channel name; never emitted in observations (FR-011).
308    provider_channel: String,
309}
310
311/// Injectable Ably edge so the optional adapter stays replaceable and testable.
312pub trait AblyRealtimeEdge {
313    /// Publishes ciphertext to a provider-native channel using a scoped token.
314    ///
315    /// # Errors
316    ///
317    /// Returns [`AblyEdgeError`] when auth fails or the edge is unavailable.
318    fn publish(
319        &mut self,
320        channel: &str,
321        token: &str,
322        retained_at_ms: u64,
323        payload: &[u8],
324    ) -> Result<String, AblyEdgeError>;
325
326    /// Returns ordered history after `cursor` for the provider channel.
327    ///
328    /// # Errors
329    ///
330    /// Returns [`AblyEdgeError`] on auth/outage failures. Expired cursors use
331    /// [`AblyEdgeError::CursorExpired`].
332    fn history_from(
333        &mut self,
334        channel: &str,
335        token: &str,
336        cursor: Option<&str>,
337        now_ms: u64,
338        replay_window_ms: u64,
339    ) -> Result<AblyHistoryBatch, AblyEdgeError>;
340
341    /// Marks the edge available or unavailable.
342    fn set_available(&mut self, available: bool);
343}
344
345/// Errors from the Ably edge translation layer.
346#[derive(Debug, Clone, PartialEq, Eq)]
347pub enum AblyEdgeError {
348    Unauthorized,
349    Unavailable,
350    CursorExpired {
351        oldest_available_cursor: Option<String>,
352    },
353}
354
355/// Ordered history batch returned by an Ably edge.
356#[derive(Debug, Clone, PartialEq, Eq)]
357pub struct AblyHistoryBatch {
358    pub payloads: Vec<Vec<u8>>,
359    pub cursor: String,
360}
361
362impl<E: AblyRealtimeEdge> AblyHostedSyncTransport<E> {
363    /// Creates an Ably adapter over an application-selected edge.
364    #[must_use]
365    pub fn new(edge: E) -> Self {
366        Self {
367            edge,
368            session: None,
369            now_ms: 0,
370            relay_available: true,
371            accepted_key_versions: BTreeSet::from([
372                "key-active".to_string(),
373                "key-previous".to_string(),
374            ]),
375            seen_operation_ids: BTreeSet::new(),
376            observations: Vec::new(),
377            lineage: Vec::new(),
378            replay_window_ms: MIN_REPLAY_WINDOW_MS,
379        }
380    }
381
382    /// Restricts accepted key-version identifiers (FR-006).
383    pub fn set_accepted_key_versions<I, S>(&mut self, versions: I)
384    where
385        I: IntoIterator<Item = S>,
386        S: Into<String>,
387    {
388        self.accepted_key_versions = versions.into_iter().map(Into::into).collect();
389    }
390
391    fn ensure_live_session(&mut self) -> Result<AblySession, HostedSyncError> {
392        let Some(session) = self.session.clone() else {
393            return Err(hosted_error(
394                HostedSyncErrorCode::NotConnected,
395                "hosted sync transport is not connected",
396                json!({}),
397            ));
398        };
399        if session.credential.expires_at_ms <= self.now_ms {
400            let state = HostedSyncConnectionState::Degraded {
401                reason: HostedSyncDegradedReason::CredentialExpired,
402            };
403            self.observe_state("credential_expired", &state);
404            return Err(hosted_error(
405                HostedSyncErrorCode::CredentialExpired,
406                "scoped hosted-relay credential expired",
407                json!({}),
408            ));
409        }
410        if !self.relay_available {
411            let state = HostedSyncConnectionState::Degraded {
412                reason: HostedSyncDegradedReason::RelayUnavailable,
413            };
414            self.observe_state("relay_unavailable", &state);
415            return Err(hosted_error(
416                HostedSyncErrorCode::ProviderUnavailable,
417                "hosted relay unavailable",
418                json!({}),
419            ));
420        }
421        Ok(session)
422    }
423
424    fn observe_state(&mut self, kind: &str, state: &HostedSyncConnectionState) {
425        let hashed_scope = self
426            .session
427            .as_ref()
428            .map(|session| session.credential.scope.hashed());
429        self.observations.push(HostedSyncObservation {
430            governing_spec: HOSTED_TRANSPORT_SPEC.to_string(),
431            kind: kind.to_string(),
432            operation_id: None,
433            key_version_id: None,
434            hashed_scope,
435            connection_state: Some(state.clone()),
436            outcome: Some(kind.to_string()),
437            latency_ms: Some(0),
438        });
439    }
440
441    fn validate_operation(
442        &self,
443        operation: &EncryptedSyncOperation,
444    ) -> Result<(), HostedSyncError> {
445        if operation.operation_id.is_empty()
446            || operation.synchronization_set_id.is_empty()
447            || operation.writer_id.is_empty()
448            || operation.key_version_id.is_empty()
449            || operation.ciphertext.is_empty()
450        {
451            return Err(hosted_error(
452                HostedSyncErrorCode::InvalidEnvelope,
453                "encrypted sync operation envelope is incomplete",
454                json!({}),
455            ));
456        }
457        if !self
458            .accepted_key_versions
459            .contains(&operation.key_version_id)
460        {
461            return Err(hosted_error(
462                HostedSyncErrorCode::KeyMismatch,
463                "key-version is outside the accepted active/previous window",
464                json!({ "key_version_id": operation.key_version_id }),
465            ));
466        }
467        Ok(())
468    }
469}
470
471impl<E: AblyRealtimeEdge> HostedSyncTransport for AblyHostedSyncTransport<E> {
472    fn connect(&mut self, credential: HostedSyncCredential) -> Result<(), HostedSyncError> {
473        if credential.token.is_empty() || credential.scope.as_str().is_empty() {
474            return Err(hosted_error(
475                HostedSyncErrorCode::UnauthorizedScope,
476                "scoped credential is missing token or scope",
477                json!({}),
478            ));
479        }
480        if credential.expires_at_ms <= self.now_ms {
481            let state = HostedSyncConnectionState::Degraded {
482                reason: HostedSyncDegradedReason::CredentialExpired,
483            };
484            self.observe_state("connect_rejected_expired", &state);
485            return Err(hosted_error(
486                HostedSyncErrorCode::CredentialExpired,
487                "scoped hosted-relay credential already expired",
488                json!({}),
489            ));
490        }
491        if !self.relay_available {
492            let state = HostedSyncConnectionState::Degraded {
493                reason: HostedSyncDegradedReason::RelayUnavailable,
494            };
495            self.observe_state("connect_rejected_unavailable", &state);
496            return Err(hosted_error(
497                HostedSyncErrorCode::ProviderUnavailable,
498                "hosted relay unavailable",
499                json!({}),
500            ));
501        }
502        let provider_channel = ably_channel_for_scope(&credential.scope);
503        let hashed_scope = credential.scope.hashed();
504        self.session = Some(AblySession {
505            credential,
506            provider_channel,
507        });
508        let state = HostedSyncConnectionState::Connected;
509        self.observations.push(HostedSyncObservation {
510            governing_spec: HOSTED_TRANSPORT_SPEC.to_string(),
511            kind: "connected".to_string(),
512            operation_id: None,
513            key_version_id: None,
514            hashed_scope: Some(hashed_scope),
515            connection_state: Some(state),
516            outcome: Some("connected".to_string()),
517            latency_ms: Some(0),
518        });
519        Ok(())
520    }
521
522    fn refresh_credential(
523        &mut self,
524        credential: HostedSyncCredential,
525    ) -> Result<(), HostedSyncError> {
526        let Some(session) = &self.session else {
527            return Err(hosted_error(
528                HostedSyncErrorCode::NotConnected,
529                "hosted sync transport is not connected",
530                json!({}),
531            ));
532        };
533        if session.credential.scope != credential.scope {
534            return Err(hosted_error(
535                HostedSyncErrorCode::CredentialMismatch,
536                "credential scope does not match the connected synchronization scope",
537                json!({ "hashed_scope": credential.scope.hashed() }),
538            ));
539        }
540        if credential.expires_at_ms <= self.now_ms || credential.token.is_empty() {
541            self.session = None;
542            let state = HostedSyncConnectionState::Degraded {
543                reason: HostedSyncDegradedReason::CredentialRefreshFailed,
544            };
545            self.observe_state("credential_refresh_failed", &state);
546            return Err(hosted_error(
547                HostedSyncErrorCode::CredentialExpired,
548                "credential refresh failed",
549                json!({}),
550            ));
551        }
552        if !self.relay_available {
553            self.session = None;
554            let state = HostedSyncConnectionState::Degraded {
555                reason: HostedSyncDegradedReason::RelayUnavailable,
556            };
557            self.observe_state("credential_refresh_unavailable", &state);
558            return Err(hosted_error(
559                HostedSyncErrorCode::ProviderUnavailable,
560                "hosted relay unavailable during credential refresh",
561                json!({}),
562            ));
563        }
564        let provider_channel = ably_channel_for_scope(&credential.scope);
565        self.session = Some(AblySession {
566            credential,
567            provider_channel,
568        });
569        let state = HostedSyncConnectionState::Connected;
570        self.observe_state("credential_refreshed", &state);
571        Ok(())
572    }
573
574    fn publish(
575        &mut self,
576        operation: EncryptedSyncOperation,
577    ) -> Result<HostedSyncPublishReceipt, HostedSyncError> {
578        let session = self.ensure_live_session()?;
579        self.validate_operation(&operation)?;
580        if self.seen_operation_ids.contains(&operation.operation_id) {
581            let cursor = format!("cursor:{}", operation.operation_id);
582            self.observations.push(HostedSyncObservation {
583                governing_spec: HOSTED_TRANSPORT_SPEC.to_string(),
584                kind: "publish_deduplicated".to_string(),
585                operation_id: Some(operation.operation_id.clone()),
586                key_version_id: Some(operation.key_version_id.clone()),
587                hashed_scope: Some(session.credential.scope.hashed()),
588                connection_state: Some(HostedSyncConnectionState::Connected),
589                outcome: Some("deduplicated".to_string()),
590                latency_ms: Some(0),
591            });
592            return Ok(HostedSyncPublishReceipt {
593                operation_id: operation.operation_id,
594                cursor,
595            });
596        }
597        let event = wrap_ecca_event(&operation);
598        let payload = encode_ecca_payload(&event);
599        let cursor = self
600            .edge
601            .publish(
602                &session.provider_channel,
603                &session.credential.token,
604                self.now_ms,
605                &payload,
606            )
607            .map_err(map_ably_edge_failure)?;
608        self.seen_operation_ids
609            .insert(operation.operation_id.clone());
610        self.lineage.push(HostedSyncLineageEvidence {
611            governing_spec: HOSTED_TRANSPORT_SPEC.to_string(),
612            protocol_spec: SYNC_PROTOCOL_SPEC.to_string(),
613            operation_id: operation.operation_id.clone(),
614            synchronization_set_id: operation.synchronization_set_id.clone(),
615            writer_id: operation.writer_id.clone(),
616            lamport_clock: operation.lamport_clock,
617            correlation_id: operation.correlation_id.clone(),
618            causation_id: operation.causation_id.clone(),
619            key_version_id: operation.key_version_id.clone(),
620            event_id: event.id,
621            observed: true,
622        });
623        self.observations.push(HostedSyncObservation {
624            governing_spec: HOSTED_TRANSPORT_SPEC.to_string(),
625            kind: "publish".to_string(),
626            operation_id: Some(operation.operation_id.clone()),
627            key_version_id: Some(operation.key_version_id.clone()),
628            hashed_scope: Some(session.credential.scope.hashed()),
629            connection_state: Some(HostedSyncConnectionState::Connected),
630            outcome: Some("delivered".to_string()),
631            latency_ms: Some(0),
632        });
633        Ok(HostedSyncPublishReceipt {
634            operation_id: operation.operation_id,
635            cursor,
636        })
637    }
638
639    fn replay_from(
640        &mut self,
641        cursor: Option<&str>,
642    ) -> Result<HostedSyncReplayResult, HostedSyncError> {
643        let session = self.ensure_live_session()?;
644        match self.edge.history_from(
645            &session.provider_channel,
646            &session.credential.token,
647            cursor,
648            self.now_ms,
649            self.replay_window_ms,
650        ) {
651            Ok(batch) => {
652                let mut operations = Vec::new();
653                for payload in batch.payloads {
654                    let event: TraverseEvent = serde_json::from_slice(&payload).map_err(|_| {
655                        hosted_error(
656                            HostedSyncErrorCode::InvalidEnvelope,
657                            "replay payload is not a valid ECCA envelope",
658                            json!({}),
659                        )
660                    })?;
661                    operations.push(operation_from_ecca_event(&event)?);
662                }
663                self.observations.push(HostedSyncObservation {
664                    governing_spec: HOSTED_TRANSPORT_SPEC.to_string(),
665                    kind: "replay".to_string(),
666                    operation_id: None,
667                    key_version_id: None,
668                    hashed_scope: Some(session.credential.scope.hashed()),
669                    connection_state: Some(HostedSyncConnectionState::Connected),
670                    outcome: Some("delivered".to_string()),
671                    latency_ms: Some(0),
672                });
673                Ok(HostedSyncReplayResult::Delivered {
674                    operations,
675                    cursor: batch.cursor,
676                })
677            }
678            Err(AblyEdgeError::CursorExpired {
679                oldest_available_cursor,
680            }) => {
681                self.observations.push(HostedSyncObservation {
682                    governing_spec: HOSTED_TRANSPORT_SPEC.to_string(),
683                    kind: "replay".to_string(),
684                    operation_id: None,
685                    key_version_id: None,
686                    hashed_scope: Some(session.credential.scope.hashed()),
687                    connection_state: Some(HostedSyncConnectionState::Connected),
688                    outcome: Some("resync_required".to_string()),
689                    latency_ms: Some(0),
690                });
691                Ok(HostedSyncReplayResult::ResyncRequired {
692                    oldest_available_cursor,
693                })
694            }
695            Err(error) => Err(map_ably_edge_failure(error)),
696        }
697    }
698
699    fn connection_state(&self) -> HostedSyncConnectionState {
700        if self.session.is_none() {
701            return HostedSyncConnectionState::Disconnected;
702        }
703        if !self.relay_available {
704            return HostedSyncConnectionState::Degraded {
705                reason: HostedSyncDegradedReason::RelayUnavailable,
706            };
707        }
708        if self
709            .session
710            .as_ref()
711            .is_some_and(|session| session.credential.expires_at_ms <= self.now_ms)
712        {
713            return HostedSyncConnectionState::Degraded {
714                reason: HostedSyncDegradedReason::CredentialExpired,
715            };
716        }
717        HostedSyncConnectionState::Connected
718    }
719
720    fn advance_clock(&mut self, now_ms: u64) {
721        if now_ms > self.now_ms {
722            self.now_ms = now_ms;
723        }
724        if self
725            .session
726            .as_ref()
727            .is_some_and(|session| session.credential.expires_at_ms <= self.now_ms)
728        {
729            let state = HostedSyncConnectionState::Degraded {
730                reason: HostedSyncDegradedReason::CredentialExpired,
731            };
732            self.observe_state("credential_expired", &state);
733        }
734    }
735
736    fn set_relay_available(&mut self, available: bool) {
737        self.relay_available = available;
738        self.edge.set_available(available);
739        if available {
740            if self.session.is_some() {
741                let state = HostedSyncConnectionState::Recovering;
742                self.observe_state("recovering", &state);
743                let state = HostedSyncConnectionState::Connected;
744                self.observe_state("reconnected", &state);
745            }
746        } else if self.session.is_some() {
747            let state = HostedSyncConnectionState::Degraded {
748                reason: HostedSyncDegradedReason::RelayUnavailable,
749            };
750            self.observe_state("relay_unavailable", &state);
751        }
752    }
753
754    fn observations(&self) -> &[HostedSyncObservation] {
755        &self.observations
756    }
757
758    fn lineage(&self) -> &[HostedSyncLineageEvidence] {
759        &self.lineage
760    }
761
762    fn adapter_kind(&self) -> &'static str {
763        "ably"
764    }
765}
766
767/// Deterministic Ably edge double used by the shared conformance suite.
768#[derive(Debug, Default)]
769pub struct InMemoryAblyEdge {
770    available: bool,
771    /// channel -> tokens authorized for that channel
772    tokens: BTreeMap<String, BTreeSet<String>>,
773    channels: BTreeMap<String, VecDeque<RetainedMessage>>,
774}
775
776impl InMemoryAblyEdge {
777    /// Creates an available edge with no retained history.
778    #[must_use]
779    pub fn new() -> Self {
780        Self {
781            available: true,
782            tokens: BTreeMap::new(),
783            channels: BTreeMap::new(),
784        }
785    }
786
787    /// Authorizes `token` for `channel` (application-backend stand-in).
788    pub fn authorize(&mut self, channel: &str, token: &str) {
789        self.tokens
790            .entry(channel.to_string())
791            .or_default()
792            .insert(token.to_string());
793    }
794
795    fn token_allowed(&self, channel: &str, token: &str) -> bool {
796        self.tokens
797            .get(channel)
798            .is_some_and(|tokens| tokens.contains(token))
799    }
800}
801
802impl AblyRealtimeEdge for InMemoryAblyEdge {
803    fn publish(
804        &mut self,
805        channel: &str,
806        token: &str,
807        retained_at_ms: u64,
808        payload: &[u8],
809    ) -> Result<String, AblyEdgeError> {
810        if !self.available {
811            return Err(AblyEdgeError::Unavailable);
812        }
813        if !self.token_allowed(channel, token) {
814            return Err(AblyEdgeError::Unauthorized);
815        }
816        let queue = self.channels.entry(channel.to_string()).or_default();
817        let seq = queue.len().saturating_add(1);
818        let cursor = format!("{channel}:{seq}");
819        queue.push_back(RetainedMessage {
820            cursor: cursor.clone(),
821            retained_at_ms,
822            payload: payload.to_vec(),
823        });
824        Ok(cursor)
825    }
826
827    fn history_from(
828        &mut self,
829        channel: &str,
830        token: &str,
831        cursor: Option<&str>,
832        now_ms: u64,
833        replay_window_ms: u64,
834    ) -> Result<AblyHistoryBatch, AblyEdgeError> {
835        if !self.available {
836            return Err(AblyEdgeError::Unavailable);
837        }
838        if !self.token_allowed(channel, token) {
839            return Err(AblyEdgeError::Unauthorized);
840        }
841        let Some(queue) = self.channels.get(channel) else {
842            return Ok(AblyHistoryBatch {
843                payloads: Vec::new(),
844                cursor: cursor.unwrap_or("0").to_string(),
845            });
846        };
847        let oldest_retained_ms = now_ms.saturating_sub(replay_window_ms);
848        let retained: Vec<&RetainedMessage> = queue
849            .iter()
850            .filter(|message| message.retained_at_ms >= oldest_retained_ms)
851            .collect();
852        let oldest_available_cursor = retained.first().map(|message| message.cursor.clone());
853        if let Some(cursor) = cursor {
854            let known = queue.iter().any(|message| message.cursor == cursor);
855            let still_retained = retained.iter().any(|message| message.cursor == cursor);
856            if known && !still_retained {
857                return Err(AblyEdgeError::CursorExpired {
858                    oldest_available_cursor,
859                });
860            }
861            if !known && !cursor.is_empty() && cursor != "0" {
862                return Err(AblyEdgeError::CursorExpired {
863                    oldest_available_cursor,
864                });
865            }
866        }
867        let start = cursor
868            .and_then(|cursor| {
869                retained
870                    .iter()
871                    .position(|message| message.cursor == cursor)
872                    .map(|index| index.saturating_add(1))
873            })
874            .unwrap_or(0);
875        let slice = &retained[start.min(retained.len())..];
876        let payloads = slice
877            .iter()
878            .map(|message| message.payload.clone())
879            .collect();
880        let next_cursor = slice
881            .last()
882            .map(|message| message.cursor.clone())
883            .or_else(|| cursor.map(str::to_string))
884            .unwrap_or_else(|| "0".to_string());
885        Ok(AblyHistoryBatch {
886            payloads,
887            cursor: next_cursor,
888        })
889    }
890
891    fn set_available(&mut self, available: bool) {
892        self.available = available;
893    }
894}
895
896#[derive(Debug, Clone)]
897struct RetainedMessage {
898    cursor: String,
899    retained_at_ms: u64,
900    payload: Vec<u8>,
901}
902
903#[derive(Debug)]
904struct SharedRelay {
905    now_ms: u64,
906    replay_window_ms: u64,
907    relay_available: bool,
908    session: Option<HostedSyncCredential>,
909    accepted_key_versions: BTreeSet<String>,
910    seen_operation_ids: BTreeSet<String>,
911    messages: VecDeque<RetainedMessage>,
912    operations_by_cursor: BTreeMap<String, EncryptedSyncOperation>,
913    observations: Vec<HostedSyncObservation>,
914    lineage: Vec<HostedSyncLineageEvidence>,
915}
916
917impl SharedRelay {
918    fn new(replay_window_ms: u64) -> Self {
919        Self {
920            now_ms: 0,
921            replay_window_ms,
922            relay_available: true,
923            session: None,
924            accepted_key_versions: BTreeSet::from([
925                "key-active".to_string(),
926                "key-previous".to_string(),
927            ]),
928            seen_operation_ids: BTreeSet::new(),
929            messages: VecDeque::new(),
930            operations_by_cursor: BTreeMap::new(),
931            observations: Vec::new(),
932            lineage: Vec::new(),
933        }
934    }
935
936    fn connect(&mut self, credential: HostedSyncCredential) -> Result<(), HostedSyncError> {
937        if credential.token.is_empty() || credential.scope.as_str().is_empty() {
938            return Err(hosted_error(
939                HostedSyncErrorCode::UnauthorizedScope,
940                "scoped credential is missing token or scope",
941                json!({}),
942            ));
943        }
944        if credential.expires_at_ms <= self.now_ms {
945            let state = HostedSyncConnectionState::Degraded {
946                reason: HostedSyncDegradedReason::CredentialExpired,
947            };
948            self.observe(
949                "connect_rejected_expired",
950                None,
951                None,
952                Some(state),
953                "rejected",
954            );
955            return Err(hosted_error(
956                HostedSyncErrorCode::CredentialExpired,
957                "scoped hosted-relay credential already expired",
958                json!({}),
959            ));
960        }
961        if !self.relay_available {
962            let state = HostedSyncConnectionState::Degraded {
963                reason: HostedSyncDegradedReason::RelayUnavailable,
964            };
965            self.observe(
966                "connect_rejected_unavailable",
967                None,
968                None,
969                Some(state),
970                "rejected",
971            );
972            return Err(hosted_error(
973                HostedSyncErrorCode::ProviderUnavailable,
974                "hosted relay unavailable",
975                json!({}),
976            ));
977        }
978        let hashed_scope = credential.scope.hashed();
979        self.session = Some(credential);
980        self.observe(
981            "connected",
982            None,
983            None,
984            Some(HostedSyncConnectionState::Connected),
985            "connected",
986        );
987        if let Some(last) = self.observations.last_mut() {
988            last.hashed_scope = Some(hashed_scope);
989        }
990        Ok(())
991    }
992
993    fn refresh_credential(
994        &mut self,
995        credential: HostedSyncCredential,
996    ) -> Result<(), HostedSyncError> {
997        let Some(session) = &self.session else {
998            return Err(hosted_error(
999                HostedSyncErrorCode::NotConnected,
1000                "hosted sync transport is not connected",
1001                json!({}),
1002            ));
1003        };
1004        if session.scope != credential.scope {
1005            return Err(hosted_error(
1006                HostedSyncErrorCode::CredentialMismatch,
1007                "credential scope does not match the connected synchronization scope",
1008                json!({ "hashed_scope": credential.scope.hashed() }),
1009            ));
1010        }
1011        if credential.expires_at_ms <= self.now_ms || credential.token.is_empty() {
1012            self.session = None;
1013            let state = HostedSyncConnectionState::Degraded {
1014                reason: HostedSyncDegradedReason::CredentialRefreshFailed,
1015            };
1016            self.observe(
1017                "credential_refresh_failed",
1018                None,
1019                None,
1020                Some(state),
1021                "degraded",
1022            );
1023            return Err(hosted_error(
1024                HostedSyncErrorCode::CredentialExpired,
1025                "credential refresh failed",
1026                json!({}),
1027            ));
1028        }
1029        if !self.relay_available {
1030            self.session = None;
1031            let state = HostedSyncConnectionState::Degraded {
1032                reason: HostedSyncDegradedReason::RelayUnavailable,
1033            };
1034            self.observe(
1035                "credential_refresh_unavailable",
1036                None,
1037                None,
1038                Some(state),
1039                "degraded",
1040            );
1041            return Err(hosted_error(
1042                HostedSyncErrorCode::ProviderUnavailable,
1043                "hosted relay unavailable during credential refresh",
1044                json!({}),
1045            ));
1046        }
1047        self.session = Some(credential);
1048        self.observe(
1049            "credential_refreshed",
1050            None,
1051            None,
1052            Some(HostedSyncConnectionState::Connected),
1053            "refreshed",
1054        );
1055        Ok(())
1056    }
1057
1058    fn publish(
1059        &mut self,
1060        operation: EncryptedSyncOperation,
1061    ) -> Result<HostedSyncPublishReceipt, HostedSyncError> {
1062        self.ensure_live_credential()?;
1063        self.validate_operation(&operation)?;
1064        if self.seen_operation_ids.contains(&operation.operation_id) {
1065            let cursor = format!("cursor:{}", operation.operation_id);
1066            self.observe(
1067                "publish_deduplicated",
1068                Some(operation.operation_id.clone()),
1069                Some(operation.key_version_id.clone()),
1070                Some(HostedSyncConnectionState::Connected),
1071                "deduplicated",
1072            );
1073            return Ok(HostedSyncPublishReceipt {
1074                operation_id: operation.operation_id,
1075                cursor,
1076            });
1077        }
1078        let event = wrap_ecca_event(&operation);
1079        let seq = self.messages.len().saturating_add(1);
1080        let cursor = format!("cursor:{seq}");
1081        self.messages.push_back(RetainedMessage {
1082            cursor: cursor.clone(),
1083            retained_at_ms: self.now_ms,
1084            payload: Vec::new(),
1085        });
1086        self.operations_by_cursor
1087            .insert(cursor.clone(), operation.clone());
1088        self.seen_operation_ids
1089            .insert(operation.operation_id.clone());
1090        self.lineage.push(HostedSyncLineageEvidence {
1091            governing_spec: HOSTED_TRANSPORT_SPEC.to_string(),
1092            protocol_spec: SYNC_PROTOCOL_SPEC.to_string(),
1093            operation_id: operation.operation_id.clone(),
1094            synchronization_set_id: operation.synchronization_set_id.clone(),
1095            writer_id: operation.writer_id.clone(),
1096            lamport_clock: operation.lamport_clock,
1097            correlation_id: operation.correlation_id.clone(),
1098            causation_id: operation.causation_id.clone(),
1099            key_version_id: operation.key_version_id.clone(),
1100            event_id: event.id,
1101            observed: true,
1102        });
1103        self.observe(
1104            "publish",
1105            Some(operation.operation_id.clone()),
1106            Some(operation.key_version_id.clone()),
1107            Some(HostedSyncConnectionState::Connected),
1108            "delivered",
1109        );
1110        Ok(HostedSyncPublishReceipt {
1111            operation_id: operation.operation_id,
1112            cursor,
1113        })
1114    }
1115
1116    fn replay_from(
1117        &mut self,
1118        cursor: Option<&str>,
1119    ) -> Result<HostedSyncReplayResult, HostedSyncError> {
1120        self.ensure_live_credential()?;
1121        self.prune_expired();
1122        let oldest_available_cursor = self.messages.front().map(|message| message.cursor.clone());
1123        if let Some(cursor) = cursor {
1124            let known = self.operations_by_cursor.contains_key(cursor)
1125                || self.messages.iter().any(|message| message.cursor == cursor);
1126            let still_retained = self.messages.iter().any(|message| message.cursor == cursor);
1127            if (known && !still_retained) || (!known && cursor != "0" && !cursor.is_empty()) {
1128                self.observe(
1129                    "replay",
1130                    None,
1131                    None,
1132                    Some(HostedSyncConnectionState::Connected),
1133                    "resync_required",
1134                );
1135                return Ok(HostedSyncReplayResult::ResyncRequired {
1136                    oldest_available_cursor,
1137                });
1138            }
1139        }
1140        let start = cursor
1141            .and_then(|cursor| {
1142                self.messages
1143                    .iter()
1144                    .position(|message| message.cursor == cursor)
1145                    .map(|index| index.saturating_add(1))
1146            })
1147            .unwrap_or(0);
1148        let operations: Vec<EncryptedSyncOperation> = self
1149            .messages
1150            .iter()
1151            .skip(start)
1152            .filter_map(|message| self.operations_by_cursor.get(&message.cursor).cloned())
1153            .collect();
1154        let last_index = start.saturating_add(operations.len().saturating_sub(1));
1155        let next_cursor = self
1156            .messages
1157            .get(last_index)
1158            .map(|message| message.cursor.clone())
1159            .or_else(|| cursor.map(str::to_string))
1160            .unwrap_or_else(|| "0".to_string());
1161        self.observe(
1162            "replay",
1163            None,
1164            None,
1165            Some(HostedSyncConnectionState::Connected),
1166            "delivered",
1167        );
1168        Ok(HostedSyncReplayResult::Delivered {
1169            operations,
1170            cursor: next_cursor,
1171        })
1172    }
1173
1174    fn connection_state(&self) -> HostedSyncConnectionState {
1175        if self.session.is_none() {
1176            return HostedSyncConnectionState::Disconnected;
1177        }
1178        if !self.relay_available {
1179            return HostedSyncConnectionState::Degraded {
1180                reason: HostedSyncDegradedReason::RelayUnavailable,
1181            };
1182        }
1183        if self
1184            .session
1185            .as_ref()
1186            .is_some_and(|credential| credential.expires_at_ms <= self.now_ms)
1187        {
1188            return HostedSyncConnectionState::Degraded {
1189                reason: HostedSyncDegradedReason::CredentialExpired,
1190            };
1191        }
1192        HostedSyncConnectionState::Connected
1193    }
1194
1195    fn advance_clock(&mut self, now_ms: u64) {
1196        if now_ms > self.now_ms {
1197            self.now_ms = now_ms;
1198        }
1199        self.prune_expired();
1200        if self
1201            .session
1202            .as_ref()
1203            .is_some_and(|credential| credential.expires_at_ms <= self.now_ms)
1204        {
1205            let state = HostedSyncConnectionState::Degraded {
1206                reason: HostedSyncDegradedReason::CredentialExpired,
1207            };
1208            self.observe("credential_expired", None, None, Some(state), "degraded");
1209        }
1210    }
1211
1212    fn set_relay_available(&mut self, available: bool) {
1213        self.relay_available = available;
1214        if available {
1215            if self.session.is_some() {
1216                self.observe(
1217                    "recovering",
1218                    None,
1219                    None,
1220                    Some(HostedSyncConnectionState::Recovering),
1221                    "recovering",
1222                );
1223                self.observe(
1224                    "reconnected",
1225                    None,
1226                    None,
1227                    Some(HostedSyncConnectionState::Connected),
1228                    "connected",
1229                );
1230            }
1231        } else if self.session.is_some() {
1232            self.observe(
1233                "relay_unavailable",
1234                None,
1235                None,
1236                Some(HostedSyncConnectionState::Degraded {
1237                    reason: HostedSyncDegradedReason::RelayUnavailable,
1238                }),
1239                "degraded",
1240            );
1241        }
1242    }
1243
1244    fn observations(&self) -> &[HostedSyncObservation] {
1245        &self.observations
1246    }
1247
1248    fn lineage(&self) -> &[HostedSyncLineageEvidence] {
1249        &self.lineage
1250    }
1251
1252    fn ensure_live_credential(&mut self) -> Result<(), HostedSyncError> {
1253        let Some(session) = &self.session else {
1254            return Err(hosted_error(
1255                HostedSyncErrorCode::NotConnected,
1256                "hosted sync transport is not connected",
1257                json!({}),
1258            ));
1259        };
1260        if session.expires_at_ms <= self.now_ms {
1261            let state = HostedSyncConnectionState::Degraded {
1262                reason: HostedSyncDegradedReason::CredentialExpired,
1263            };
1264            self.observe("credential_expired", None, None, Some(state), "degraded");
1265            return Err(hosted_error(
1266                HostedSyncErrorCode::CredentialExpired,
1267                "scoped hosted-relay credential expired",
1268                json!({}),
1269            ));
1270        }
1271        if !self.relay_available {
1272            let state = HostedSyncConnectionState::Degraded {
1273                reason: HostedSyncDegradedReason::RelayUnavailable,
1274            };
1275            self.observe("relay_unavailable", None, None, Some(state), "degraded");
1276            return Err(hosted_error(
1277                HostedSyncErrorCode::ProviderUnavailable,
1278                "hosted relay unavailable",
1279                json!({}),
1280            ));
1281        }
1282        Ok(())
1283    }
1284
1285    fn validate_operation(
1286        &self,
1287        operation: &EncryptedSyncOperation,
1288    ) -> Result<(), HostedSyncError> {
1289        if operation.operation_id.is_empty()
1290            || operation.synchronization_set_id.is_empty()
1291            || operation.writer_id.is_empty()
1292            || operation.key_version_id.is_empty()
1293            || operation.ciphertext.is_empty()
1294        {
1295            return Err(hosted_error(
1296                HostedSyncErrorCode::InvalidEnvelope,
1297                "encrypted sync operation envelope is incomplete",
1298                json!({}),
1299            ));
1300        }
1301        if !self
1302            .accepted_key_versions
1303            .contains(&operation.key_version_id)
1304        {
1305            return Err(hosted_error(
1306                HostedSyncErrorCode::KeyMismatch,
1307                "key-version is outside the accepted active/previous window",
1308                json!({ "key_version_id": operation.key_version_id }),
1309            ));
1310        }
1311        Ok(())
1312    }
1313
1314    fn prune_expired(&mut self) {
1315        let oldest_retained_ms = self.now_ms.saturating_sub(self.replay_window_ms);
1316        while self
1317            .messages
1318            .front()
1319            .is_some_and(|message| message.retained_at_ms < oldest_retained_ms)
1320        {
1321            if let Some(message) = self.messages.pop_front() {
1322                self.operations_by_cursor.remove(&message.cursor);
1323            }
1324        }
1325    }
1326
1327    fn observe(
1328        &mut self,
1329        kind: &str,
1330        operation_id: Option<String>,
1331        key_version_id: Option<String>,
1332        connection_state: Option<HostedSyncConnectionState>,
1333        outcome: &str,
1334    ) {
1335        let hashed_scope = self
1336            .session
1337            .as_ref()
1338            .map(|credential| credential.scope.hashed());
1339        self.observations.push(HostedSyncObservation {
1340            governing_spec: HOSTED_TRANSPORT_SPEC.to_string(),
1341            kind: kind.to_string(),
1342            operation_id,
1343            key_version_id,
1344            hashed_scope,
1345            connection_state,
1346            outcome: Some(outcome.to_string()),
1347            latency_ms: Some(0),
1348        });
1349    }
1350}
1351
1352fn wrap_ecca_event(operation: &EncryptedSyncOperation) -> TraverseEvent {
1353    TraverseEvent {
1354        id: format!("evt-{}", operation.operation_id),
1355        source: "traverse-runtime/hosted-sync".to_string(),
1356        event_type: "dev.traverse.datastore.sync.operation".to_string(),
1357        datacontenttype: "application/json".to_string(),
1358        time: "1970-01-01T00:00:00Z".to_string(),
1359        data: json!({
1360            "operation_id": operation.operation_id,
1361            "synchronization_set_id": operation.synchronization_set_id,
1362            "writer_id": operation.writer_id,
1363            "lamport_clock": operation.lamport_clock,
1364            "key_version_id": operation.key_version_id,
1365            "ciphertext_sha256": hex_digest(&operation.ciphertext),
1366            "ciphertext": bytes_to_hex(&operation.ciphertext),
1367        }),
1368        owner: "hosted-sync".to_string(),
1369        version: SYNC_PROTOCOL_SPEC.to_string(),
1370        lifecycle_status: LifecycleStatus::Active,
1371        deduplication_id: Some(operation.operation_id.clone()),
1372        ordering_scope: Some(operation.synchronization_set_id.clone()),
1373        correlation_id: operation.correlation_id.clone(),
1374        causation_id: operation.causation_id.clone(),
1375        subject_id: None,
1376        actor_id: Some(operation.writer_id.clone()),
1377    }
1378}
1379
1380fn operation_from_ecca_event(
1381    event: &TraverseEvent,
1382) -> Result<EncryptedSyncOperation, HostedSyncError> {
1383    let ciphertext_hex = event
1384        .data
1385        .get("ciphertext")
1386        .and_then(Value::as_str)
1387        .ok_or_else(|| {
1388            hosted_error(
1389                HostedSyncErrorCode::InvalidEnvelope,
1390                "ECCA sync envelope missing ciphertext",
1391                json!({}),
1392            )
1393        })?;
1394    let ciphertext = hex_decode(ciphertext_hex).ok_or_else(|| {
1395        hosted_error(
1396            HostedSyncErrorCode::InvalidEnvelope,
1397            "ECCA sync envelope ciphertext is not valid hex",
1398            json!({}),
1399        )
1400    })?;
1401    Ok(EncryptedSyncOperation {
1402        operation_id: required_string(&event.data, "operation_id")?,
1403        synchronization_set_id: required_string(&event.data, "synchronization_set_id")?,
1404        writer_id: required_string(&event.data, "writer_id")?,
1405        lamport_clock: event
1406            .data
1407            .get("lamport_clock")
1408            .and_then(Value::as_u64)
1409            .ok_or_else(|| {
1410                hosted_error(
1411                    HostedSyncErrorCode::InvalidEnvelope,
1412                    "ECCA sync envelope missing lamport_clock",
1413                    json!({}),
1414                )
1415            })?,
1416        correlation_id: event.correlation_id.clone(),
1417        causation_id: event.causation_id.clone(),
1418        key_version_id: required_string(&event.data, "key_version_id")?,
1419        ciphertext,
1420    })
1421}
1422
1423fn required_string(value: &Value, field: &str) -> Result<String, HostedSyncError> {
1424    value
1425        .get(field)
1426        .and_then(Value::as_str)
1427        .map(str::to_string)
1428        .ok_or_else(|| {
1429            hosted_error(
1430                HostedSyncErrorCode::InvalidEnvelope,
1431                "ECCA sync envelope missing required field",
1432                json!({ "field": field }),
1433            )
1434        })
1435}
1436
1437fn ably_channel_for_scope(scope: &SyncScopeId) -> String {
1438    // Provider-native name stays inside the adapter; observability uses hashed scope only.
1439    format!("ably.sync.{}", hex_digest(scope.as_str().as_bytes()))
1440}
1441
1442#[allow(clippy::expect_used)]
1443fn encode_ecca_payload(event: &TraverseEvent) -> Vec<u8> {
1444    // TraverseEvent for this envelope uses only JSON-safe scalars/objects.
1445    serde_json::to_vec(event).expect("ECCA sync envelope is always JSON-serializable")
1446}
1447
1448fn map_ably_edge_failure(error: AblyEdgeError) -> HostedSyncError {
1449    match error {
1450        AblyEdgeError::Unauthorized => hosted_error(
1451            HostedSyncErrorCode::UnauthorizedScope,
1452            "provider rejected scoped credential or channel binding",
1453            json!({}),
1454        ),
1455        AblyEdgeError::Unavailable => hosted_error(
1456            HostedSyncErrorCode::ProviderUnavailable,
1457            "hosted relay unavailable",
1458            json!({}),
1459        ),
1460        AblyEdgeError::CursorExpired {
1461            oldest_available_cursor,
1462        } => hosted_error(
1463            HostedSyncErrorCode::ResyncRequired,
1464            "cursor expired; application sync authority catch-up required",
1465            json!({ "oldest_available_cursor": oldest_available_cursor }),
1466        ),
1467    }
1468}
1469
1470fn hosted_error(code: HostedSyncErrorCode, message: &str, details: Value) -> HostedSyncError {
1471    HostedSyncError {
1472        code,
1473        message: message.to_string(),
1474        details,
1475    }
1476}
1477
1478fn hex_digest(bytes: &[u8]) -> String {
1479    let digest = Sha256::digest(bytes);
1480    bytes_to_hex(&digest)
1481}
1482
1483fn bytes_to_hex(bytes: &[u8]) -> String {
1484    let mut hex = String::with_capacity(bytes.len().saturating_mul(2));
1485    for byte in bytes {
1486        hex.push(HEXADECIMAL_DIGITS[(byte >> 4) as usize] as char);
1487        hex.push(HEXADECIMAL_DIGITS[(byte & 0x0f) as usize] as char);
1488    }
1489    hex
1490}
1491
1492fn hex_decode(input: &str) -> Option<Vec<u8>> {
1493    if !input.len().is_multiple_of(2) {
1494        return None;
1495    }
1496    let mut bytes = Vec::with_capacity(input.len() / 2);
1497    let chars: Vec<char> = input.chars().collect();
1498    for chunk in chars.chunks(2) {
1499        let hi = hex_nibble(chunk[0])?;
1500        let lo = hex_nibble(chunk[1])?;
1501        bytes.push((hi << 4) | lo);
1502    }
1503    Some(bytes)
1504}
1505
1506fn hex_nibble(value: char) -> Option<u8> {
1507    match value {
1508        '0'..='9' => Some((value as u8) - b'0'),
1509        'a'..='f' => Some((value as u8) - b'a' + 10),
1510        'A'..='F' => Some((value as u8) - b'A' + 10),
1511        _ => None,
1512    }
1513}
1514
1515fn sample_operation() -> EncryptedSyncOperation {
1516    EncryptedSyncOperation {
1517        operation_id: "op-1".to_string(),
1518        synchronization_set_id: "sync-set-1".to_string(),
1519        writer_id: "writer-a".to_string(),
1520        lamport_clock: 3,
1521        correlation_id: Some("corr-1".to_string()),
1522        causation_id: Some("cause-1".to_string()),
1523        key_version_id: "key-active".to_string(),
1524        ciphertext: b"ciphertext-one".to_vec(),
1525    }
1526}
1527
1528fn expect_error_code<T>(
1529    result: &Result<T, HostedSyncError>,
1530    expected: HostedSyncErrorCode,
1531    message: &str,
1532) -> Result<(), String> {
1533    if result.as_ref().err().map(|error| error.code) == Some(expected) {
1534        Ok(())
1535    } else {
1536        Err(message.to_string())
1537    }
1538}
1539
1540fn conformance_publish_and_auth(
1541    transport: &mut dyn HostedSyncTransport,
1542    scope: &SyncScopeId,
1543    operation: &EncryptedSyncOperation,
1544) -> Result<(), String> {
1545    let receipt = transport
1546        .publish(operation.clone())
1547        .map_err(|error| format!("publish failed: {:?}", error.code))?;
1548    if receipt.operation_id != "op-1" {
1549        return Err("publish receipt operation id mismatch".to_string());
1550    }
1551    let duplicate = transport
1552        .publish(operation.clone())
1553        .map_err(|error| format!("idempotent publish failed: {:?}", error.code))?;
1554    if duplicate.operation_id != "op-1" {
1555        return Err("deduplicated publish changed operation id".to_string());
1556    }
1557    expect_error_code(
1558        &transport.publish(EncryptedSyncOperation {
1559            operation_id: "op-stale-key".to_string(),
1560            key_version_id: "key-retired".to_string(),
1561            ..operation.clone()
1562        }),
1563        HostedSyncErrorCode::KeyMismatch,
1564        "stale key must fail with KeyMismatch",
1565    )?;
1566    expect_error_code(
1567        &transport.refresh_credential(HostedSyncCredential {
1568            token: "token-other".to_string(),
1569            scope: SyncScopeId::new("tenant-b/user-9/device-group-y"),
1570            expires_at_ms: 90_000,
1571        }),
1572        HostedSyncErrorCode::CredentialMismatch,
1573        "foreign scope refresh must fail with CredentialMismatch",
1574    )?;
1575    transport
1576        .refresh_credential(HostedSyncCredential {
1577            token: "token-refresh".to_string(),
1578            scope: scope.clone(),
1579            // Expires before the replay-window clock advance so reconnect is exercised.
1580            expires_at_ms: 50_000,
1581        })
1582        .map_err(|error| format!("refresh failed: {:?}", error.code))?;
1583    Ok(())
1584}
1585
1586fn conformance_outage_and_replay(
1587    transport: &mut dyn HostedSyncTransport,
1588    scope: &SyncScopeId,
1589    operation: &EncryptedSyncOperation,
1590) -> Result<(), String> {
1591    transport.set_relay_available(false);
1592    if !matches!(
1593        transport.connection_state(),
1594        HostedSyncConnectionState::Degraded {
1595            reason: HostedSyncDegradedReason::RelayUnavailable
1596        }
1597    ) {
1598        return Err("relay outage must enter degraded state".to_string());
1599    }
1600    expect_error_code(
1601        &transport.publish(EncryptedSyncOperation {
1602            operation_id: "op-during-outage".to_string(),
1603            ..operation.clone()
1604        }),
1605        HostedSyncErrorCode::ProviderUnavailable,
1606        "publish during outage must be ProviderUnavailable",
1607    )?;
1608    transport.set_relay_available(true);
1609
1610    let replay = transport
1611        .replay_from(None)
1612        .map_err(|error| format!("replay failed: {:?}", error.code))?;
1613    let HostedSyncReplayResult::Delivered {
1614        operations,
1615        cursor: first_cursor,
1616    } = replay
1617    else {
1618        return Err("initial replay must deliver retained operations".to_string());
1619    };
1620    if operations.len() != 1 || operations[0] != *operation {
1621        return Err("replay must preserve the encrypted portable envelope".to_string());
1622    }
1623
1624    transport.advance_clock(1_000 + MIN_REPLAY_WINDOW_MS + 1);
1625    if matches!(
1626        transport.connection_state(),
1627        HostedSyncConnectionState::Disconnected | HostedSyncConnectionState::Degraded { .. }
1628    ) {
1629        transport
1630            .connect(HostedSyncCredential {
1631                token: "token-live".to_string(),
1632                scope: scope.clone(),
1633                expires_at_ms: 1_000 + MIN_REPLAY_WINDOW_MS + 60_000,
1634            })
1635            .map_err(|error| format!("reconnect after window failed: {:?}", error.code))?;
1636    }
1637    let expired = transport
1638        .replay_from(Some(&first_cursor))
1639        .map_err(|error| format!("expired replay failed: {:?}", error.code))?;
1640    if !matches!(expired, HostedSyncReplayResult::ResyncRequired { .. }) {
1641        return Err("expired cursor must return resync_required without a snapshot".to_string());
1642    }
1643    Ok(())
1644}
1645
1646fn conformance_evidence(transport: &dyn HostedSyncTransport) -> Result<(), String> {
1647    for observation in transport.observations() {
1648        assert_observation_redacted(observation)?;
1649    }
1650    let Some(lineage) = transport.lineage().first() else {
1651        return Err("lineage evidence must be recorded for delivered operations".to_string());
1652    };
1653    if lineage.governing_spec != HOSTED_TRANSPORT_SPEC
1654        || lineage.protocol_spec != SYNC_PROTOCOL_SPEC
1655        || lineage.operation_id != "op-1"
1656        || !lineage.observed
1657    {
1658        return Err("lineage evidence missing required Spec 087/089 fields".to_string());
1659    }
1660    Ok(())
1661}
1662
1663/// Runs the Spec 087 hosted-transport conformance suite against any adapter.
1664///
1665/// # Errors
1666///
1667/// Returns a descriptive failure when an assertion does not hold.
1668pub fn run_hosted_sync_conformance(transport: &mut dyn HostedSyncTransport) -> Result<(), String> {
1669    let scope = SyncScopeId::new("tenant-a/user-1/device-group-x");
1670    transport.advance_clock(1_000);
1671    transport
1672        .connect(HostedSyncCredential {
1673            token: "token-live".to_string(),
1674            scope: scope.clone(),
1675            expires_at_ms: 60_000,
1676        })
1677        .map_err(|error| format!("connect failed: {:?}", error.code))?;
1678    let operation = sample_operation();
1679    conformance_publish_and_auth(transport, &scope, &operation)?;
1680    conformance_outage_and_replay(transport, &scope, &operation)?;
1681    conformance_evidence(transport)
1682}
1683
1684fn assert_observation_redacted(observation: &HostedSyncObservation) -> Result<(), String> {
1685    let rendered = serde_json::to_string(observation)
1686        .map_err(|error| format!("observation serialize failed: {error}"))?;
1687    for forbidden in [
1688        "ciphertext-one",
1689        "token-live",
1690        "token-refresh",
1691        "ably.sync.",
1692        "tenant-a/user-1",
1693    ] {
1694        if rendered.contains(forbidden) {
1695            return Err(format!(
1696                "observation leaked forbidden material: {forbidden}"
1697            ));
1698        }
1699    }
1700    Ok(())
1701}
1702
1703#[cfg(test)]
1704mod tests {
1705    #![allow(
1706        clippy::expect_used,
1707        clippy::panic,
1708        clippy::unwrap_used,
1709        clippy::too_many_lines
1710    )]
1711
1712    use super::*;
1713
1714    fn authorize_edge(edge: &mut InMemoryAblyEdge, scope: &SyncScopeId) {
1715        edge.authorize(&ably_channel_for_scope(scope), "token-live");
1716        edge.authorize(&ably_channel_for_scope(scope), "token-refresh");
1717    }
1718
1719    #[test]
1720    fn in_memory_adapter_passes_hosted_sync_conformance() {
1721        let mut transport = InMemoryHostedSyncTransport::new();
1722        run_hosted_sync_conformance(&mut transport).expect("in-memory conformance");
1723    }
1724
1725    #[test]
1726    fn ably_adapter_passes_identical_hosted_sync_conformance() {
1727        let mut edge = InMemoryAblyEdge::new();
1728        let scope = SyncScopeId::new("tenant-a/user-1/device-group-x");
1729        authorize_edge(&mut edge, &scope);
1730        let mut transport = AblyHostedSyncTransport::new(edge);
1731        run_hosted_sync_conformance(&mut transport).expect("ably conformance");
1732    }
1733
1734    #[test]
1735    fn adapter_replacement_preserves_portable_envelope() {
1736        let operation = EncryptedSyncOperation {
1737            operation_id: "op-portable".to_string(),
1738            synchronization_set_id: "sync-set".to_string(),
1739            writer_id: "writer-b".to_string(),
1740            lamport_clock: 9,
1741            correlation_id: Some("c".to_string()),
1742            causation_id: None,
1743            key_version_id: "key-previous".to_string(),
1744            ciphertext: b"secret-bytes".to_vec(),
1745        };
1746
1747        let mut memory = InMemoryHostedSyncTransport::new();
1748        memory.advance_clock(10);
1749        memory
1750            .connect(HostedSyncCredential {
1751                token: "token-live".to_string(),
1752                scope: SyncScopeId::new("scope-1"),
1753                expires_at_ms: 50_000,
1754            })
1755            .expect("memory connect");
1756        memory.publish(operation.clone()).expect("memory publish");
1757        let memory_replay = memory.replay_from(None).expect("memory replay");
1758
1759        let mut edge = InMemoryAblyEdge::new();
1760        let scope = SyncScopeId::new("scope-1");
1761        edge.authorize(&ably_channel_for_scope(&scope), "token-live");
1762        let mut ably = AblyHostedSyncTransport::new(edge);
1763        ably.advance_clock(10);
1764        ably.connect(HostedSyncCredential {
1765            token: "token-live".to_string(),
1766            scope,
1767            expires_at_ms: 50_000,
1768        })
1769        .expect("ably connect");
1770        ably.publish(operation.clone()).expect("ably publish");
1771        let ably_replay = ably.replay_from(None).expect("ably replay");
1772
1773        let left = delivered_operations(memory_replay);
1774        let right = delivered_operations(ably_replay);
1775        assert_eq!(left, right);
1776        assert_eq!(left, vec![operation]);
1777        assert!(
1778            delivered_operations(HostedSyncReplayResult::ResyncRequired {
1779                oldest_available_cursor: None,
1780            })
1781            .is_empty()
1782        );
1783    }
1784
1785    fn delivered_operations(result: HostedSyncReplayResult) -> Vec<EncryptedSyncOperation> {
1786        match result {
1787            HostedSyncReplayResult::Delivered { operations, .. } => operations,
1788            HostedSyncReplayResult::ResyncRequired { .. } => Vec::new(),
1789        }
1790    }
1791
1792    fn delivered_cursor(result: HostedSyncReplayResult) -> String {
1793        match result {
1794            HostedSyncReplayResult::Delivered { cursor, .. } => cursor,
1795            HostedSyncReplayResult::ResyncRequired { .. } => "0".to_string(),
1796        }
1797    }
1798
1799    #[test]
1800    fn tenancy_isolation_rejects_foreign_scope_credentials() {
1801        let mut transport = InMemoryHostedSyncTransport::new();
1802        transport.advance_clock(1);
1803        transport
1804            .connect(HostedSyncCredential {
1805                token: "token-a".to_string(),
1806                scope: SyncScopeId::new("tenant-a/scope"),
1807                expires_at_ms: 10_000,
1808            })
1809            .expect("connect");
1810        let error = transport
1811            .refresh_credential(HostedSyncCredential {
1812                token: "token-b".to_string(),
1813                scope: SyncScopeId::new("tenant-b/scope"),
1814                expires_at_ms: 10_000,
1815            })
1816            .expect_err("foreign scope must fail");
1817        assert_eq!(error.code, HostedSyncErrorCode::CredentialMismatch);
1818    }
1819
1820    #[test]
1821    fn observations_never_include_plaintext_or_provider_channels() {
1822        let mut edge = InMemoryAblyEdge::new();
1823        let scope = SyncScopeId::new("tenant-a/user-1/device-group-x");
1824        edge.authorize(&ably_channel_for_scope(&scope), "token-live");
1825        let mut transport = AblyHostedSyncTransport::new(edge);
1826        transport.advance_clock(5);
1827        transport
1828            .connect(HostedSyncCredential {
1829                token: "token-live".to_string(),
1830                scope,
1831                expires_at_ms: 9_000,
1832            })
1833            .expect("connect");
1834        transport
1835            .publish(EncryptedSyncOperation {
1836                operation_id: "op-obs".to_string(),
1837                synchronization_set_id: "set".to_string(),
1838                writer_id: "w".to_string(),
1839                lamport_clock: 1,
1840                correlation_id: None,
1841                causation_id: None,
1842                key_version_id: "key-active".to_string(),
1843                ciphertext: b"plaintext-must-not-leak".to_vec(),
1844            })
1845            .expect("publish");
1846        for observation in transport.observations() {
1847            assert_observation_redacted(observation).expect("redaction");
1848            let text = serde_json::to_string(observation).expect("json");
1849            assert!(!text.contains("plaintext-must-not-leak"));
1850            assert!(!text.contains("ably.sync."));
1851        }
1852    }
1853
1854    #[test]
1855    fn degraded_sync_is_typed_and_does_not_claim_local_store_failure() {
1856        // FR-010: relay outage degrades synchronization only; local DataStore
1857        // durability remains a separate host concern outside this port.
1858        let mut transport = InMemoryHostedSyncTransport::new();
1859        transport.advance_clock(1);
1860        transport
1861            .connect(HostedSyncCredential {
1862                token: "token-live".to_string(),
1863                scope: SyncScopeId::new("scope"),
1864                expires_at_ms: 10_000,
1865            })
1866            .expect("connect");
1867        transport.set_relay_available(false);
1868        assert!(matches!(
1869            transport.connection_state(),
1870            HostedSyncConnectionState::Degraded {
1871                reason: HostedSyncDegradedReason::RelayUnavailable
1872            }
1873        ));
1874        let error = transport
1875            .publish(EncryptedSyncOperation {
1876                operation_id: "op-deg".to_string(),
1877                synchronization_set_id: "set".to_string(),
1878                writer_id: "w".to_string(),
1879                lamport_clock: 1,
1880                correlation_id: None,
1881                causation_id: None,
1882                key_version_id: "key-active".to_string(),
1883                ciphertext: b"x".to_vec(),
1884            })
1885            .expect_err("sync publish fails while degraded");
1886        assert_eq!(error.code, HostedSyncErrorCode::ProviderUnavailable);
1887        assert_ne!(error.code, HostedSyncErrorCode::InvalidEnvelope);
1888    }
1889
1890    fn sample_cred(scope: &str, token: &str, expires_at_ms: u64) -> HostedSyncCredential {
1891        HostedSyncCredential {
1892            token: token.to_string(),
1893            scope: SyncScopeId::new(scope),
1894            expires_at_ms,
1895        }
1896    }
1897
1898    fn sample_op(id: &str) -> EncryptedSyncOperation {
1899        EncryptedSyncOperation {
1900            operation_id: id.to_string(),
1901            synchronization_set_id: "set".to_string(),
1902            writer_id: "writer".to_string(),
1903            lamport_clock: 1,
1904            correlation_id: None,
1905            causation_id: None,
1906            key_version_id: "key-active".to_string(),
1907            ciphertext: b"cipher".to_vec(),
1908        }
1909    }
1910
1911    #[test]
1912    fn in_memory_error_and_state_branches() {
1913        let mut transport = InMemoryHostedSyncTransport::default();
1914        assert_eq!(transport.adapter_kind(), "in_memory");
1915        assert_eq!(
1916            transport.connection_state(),
1917            HostedSyncConnectionState::Disconnected
1918        );
1919        assert_eq!(
1920            transport
1921                .refresh_credential(sample_cred("s", "t", 10))
1922                .unwrap_err()
1923                .code,
1924            HostedSyncErrorCode::NotConnected
1925        );
1926        assert_eq!(
1927            transport.publish(sample_op("x")).unwrap_err().code,
1928            HostedSyncErrorCode::NotConnected
1929        );
1930
1931        assert_eq!(
1932            transport
1933                .connect(sample_cred("", "t", 10))
1934                .unwrap_err()
1935                .code,
1936            HostedSyncErrorCode::UnauthorizedScope
1937        );
1938        transport.advance_clock(20);
1939        assert_eq!(
1940            transport
1941                .connect(sample_cred("s", "t", 10))
1942                .unwrap_err()
1943                .code,
1944            HostedSyncErrorCode::CredentialExpired
1945        );
1946        transport.advance_clock(0);
1947        transport.set_relay_available(false);
1948        assert_eq!(
1949            transport
1950                .connect(sample_cred("s", "t", 100))
1951                .unwrap_err()
1952                .code,
1953            HostedSyncErrorCode::ProviderUnavailable
1954        );
1955        transport.set_relay_available(true);
1956        transport.advance_clock(1);
1957        transport
1958            .connect(sample_cred("s", "live", 50))
1959            .expect("connect");
1960        assert_eq!(
1961            transport
1962                .publish(EncryptedSyncOperation {
1963                    ciphertext: Vec::new(),
1964                    ..sample_op("bad")
1965                })
1966                .unwrap_err()
1967                .code,
1968            HostedSyncErrorCode::InvalidEnvelope
1969        );
1970        assert_eq!(
1971            transport
1972                .refresh_credential(sample_cred("s", "", 80))
1973                .unwrap_err()
1974                .code,
1975            HostedSyncErrorCode::CredentialExpired
1976        );
1977        transport
1978            .connect(sample_cred("s", "live", 50))
1979            .expect("reconnect");
1980        transport.set_relay_available(false);
1981        assert_eq!(
1982            transport
1983                .refresh_credential(sample_cred("s", "next", 80))
1984                .unwrap_err()
1985                .code,
1986            HostedSyncErrorCode::ProviderUnavailable
1987        );
1988        transport.set_relay_available(true);
1989        transport
1990            .connect(sample_cred("s", "live", 50))
1991            .expect("reconnect");
1992        transport.publish(sample_op("kept")).expect("publish");
1993        assert!(matches!(
1994            transport.connection_state(),
1995            HostedSyncConnectionState::Connected
1996        ));
1997        // connection_state CredentialExpired while session still present
1998        transport.advance_clock(40);
1999        // Force state check before ensure clears session: bump clock to expiry boundary
2000        // without going through ensure by inspecting after partial advance.
2001        let mut transport = InMemoryHostedSyncTransport::new();
2002        transport.advance_clock(1);
2003        transport
2004            .connect(sample_cred("s", "live", 10))
2005            .expect("connect");
2006        // Advance to exactly expiry without calling ensure-bearing APIs first.
2007        // advance_clock clears session when expired; call connection_state via a
2008        // peer clock path: temporarily use degraded-by-expiry before clear.
2009        transport.advance_clock(10);
2010        assert!(matches!(
2011            transport.connection_state(),
2012            HostedSyncConnectionState::Degraded {
2013                reason: HostedSyncDegradedReason::CredentialExpired
2014            }
2015        ));
2016        transport
2017            .connect(sample_cred("s", "live", 1_000))
2018            .expect("connect");
2019        transport.publish(sample_op("replay-me")).expect("publish");
2020        let replay = transport.replay_from(None).expect("replay");
2021        let operations = delivered_operations(replay.clone());
2022        let cursor = delivered_cursor(replay);
2023        assert_eq!(operations.len(), 1);
2024        assert_eq!(
2025            delivered_cursor(HostedSyncReplayResult::ResyncRequired {
2026                oldest_available_cursor: None,
2027            }),
2028            "0"
2029        );
2030        // Replay from a known retained cursor (empty follow-on batch).
2031        let follow_on = transport.replay_from(Some(&cursor)).expect("follow-on");
2032        assert!(matches!(
2033            follow_on,
2034            HostedSyncReplayResult::Delivered { .. }
2035        ));
2036        // Unknown cursor that was never retained.
2037        let expired = transport
2038            .replay_from(Some("cursor:unknown"))
2039            .expect("typed resync");
2040        assert!(matches!(
2041            expired,
2042            HostedSyncReplayResult::ResyncRequired { .. }
2043        ));
2044        // Expired credential degrades publish.
2045        transport.advance_clock(2_000);
2046        assert_eq!(
2047            transport.publish(sample_op("late")).unwrap_err().code,
2048            HostedSyncErrorCode::CredentialExpired
2049        );
2050    }
2051
2052    #[test]
2053    fn ably_error_and_edge_branches() {
2054        let mut edge = InMemoryAblyEdge::new();
2055        let scope = SyncScopeId::new("scope-ably");
2056        let channel = ably_channel_for_scope(&scope);
2057        edge.authorize(&channel, "good");
2058        let mut transport = AblyHostedSyncTransport::new(edge);
2059        assert_eq!(transport.adapter_kind(), "ably");
2060        assert_eq!(
2061            transport.connection_state(),
2062            HostedSyncConnectionState::Disconnected
2063        );
2064        transport.set_accepted_key_versions(["key-active"]);
2065        assert_eq!(
2066            transport.publish(sample_op("x")).unwrap_err().code,
2067            HostedSyncErrorCode::NotConnected
2068        );
2069        assert_eq!(
2070            transport
2071                .refresh_credential(sample_cred("scope-ably", "good", 10))
2072                .unwrap_err()
2073                .code,
2074            HostedSyncErrorCode::NotConnected
2075        );
2076        assert_eq!(
2077            transport
2078                .connect(sample_cred("", "good", 10))
2079                .unwrap_err()
2080                .code,
2081            HostedSyncErrorCode::UnauthorizedScope
2082        );
2083        transport.advance_clock(5);
2084        assert_eq!(
2085            transport
2086                .connect(sample_cred("scope-ably", "good", 1))
2087                .unwrap_err()
2088                .code,
2089            HostedSyncErrorCode::CredentialExpired
2090        );
2091        transport.set_relay_available(false);
2092        assert_eq!(
2093            transport
2094                .connect(sample_cred("scope-ably", "good", 100))
2095                .unwrap_err()
2096                .code,
2097            HostedSyncErrorCode::ProviderUnavailable
2098        );
2099        transport.set_relay_available(true);
2100        transport
2101            .connect(sample_cred("scope-ably", "good", 100))
2102            .expect("connect");
2103        assert_eq!(
2104            transport
2105                .publish(EncryptedSyncOperation {
2106                    key_version_id: "nope".to_string(),
2107                    ..sample_op("k")
2108                })
2109                .unwrap_err()
2110                .code,
2111            HostedSyncErrorCode::KeyMismatch
2112        );
2113        assert_eq!(
2114            transport
2115                .publish(EncryptedSyncOperation {
2116                    operation_id: String::new(),
2117                    ..sample_op("k")
2118                })
2119                .unwrap_err()
2120                .code,
2121            HostedSyncErrorCode::InvalidEnvelope
2122        );
2123        transport.publish(sample_op("one")).expect("publish");
2124        transport.publish(sample_op("one")).expect("dedup");
2125        assert_eq!(
2126            transport
2127                .refresh_credential(sample_cred("scope-ably", "", 200))
2128                .unwrap_err()
2129                .code,
2130            HostedSyncErrorCode::CredentialExpired
2131        );
2132        transport
2133            .connect(sample_cred("scope-ably", "good", 100))
2134            .expect("reconnect");
2135        transport.set_relay_available(false);
2136        assert_eq!(
2137            transport
2138                .refresh_credential(sample_cred("scope-ably", "good", 200))
2139                .unwrap_err()
2140                .code,
2141            HostedSyncErrorCode::ProviderUnavailable
2142        );
2143        transport.set_relay_available(true);
2144        transport
2145            .connect(sample_cred("scope-ably", "good", 100))
2146            .expect("reconnect");
2147        // Unauthorized publish via wrong token after swapping edge tokens.
2148        transport.edge.tokens.clear();
2149        assert_eq!(
2150            transport.publish(sample_op("denied")).unwrap_err().code,
2151            HostedSyncErrorCode::UnauthorizedScope
2152        );
2153        transport.edge.authorize(&channel, "good");
2154        transport.set_relay_available(false);
2155        assert!(matches!(
2156            transport.connection_state(),
2157            HostedSyncConnectionState::Degraded {
2158                reason: HostedSyncDegradedReason::RelayUnavailable
2159            }
2160        ));
2161        transport.set_relay_available(true);
2162        // Edge unavailable while adapter still thinks the relay flag is up.
2163        transport.edge.set_available(false);
2164        assert_eq!(
2165            transport.publish(sample_op("edge-down")).unwrap_err().code,
2166            HostedSyncErrorCode::ProviderUnavailable
2167        );
2168        transport.edge.set_available(true);
2169        // Fresh adapter whose only retained payload is corrupt JSON.
2170        let mut edge = InMemoryAblyEdge::new();
2171        edge.authorize(&channel, "good");
2172        edge.publish(&channel, "good", 1, b"not-json")
2173            .expect("edge publish");
2174        let mut transport = AblyHostedSyncTransport::new(edge);
2175        transport.advance_clock(1);
2176        transport
2177            .connect(sample_cred("scope-ably", "good", 100))
2178            .expect("connect");
2179        let err = transport.replay_from(None).expect_err("bad ecca");
2180        assert_eq!(err.code, HostedSyncErrorCode::InvalidEnvelope);
2181
2182        // Replay failure mapped from edge Unauthorized / unavailable.
2183        let mut edge = InMemoryAblyEdge::new();
2184        edge.authorize(&channel, "good");
2185        let mut transport = AblyHostedSyncTransport::new(edge);
2186        transport.advance_clock(1);
2187        transport
2188            .connect(sample_cred("scope-ably", "good", 100))
2189            .expect("connect");
2190        let cursor = transport
2191            .publish(sample_op("kept"))
2192            .expect("publish")
2193            .cursor;
2194        assert_eq!(
2195            transport.connection_state(),
2196            HostedSyncConnectionState::Connected
2197        );
2198        let follow_on = transport.replay_from(Some(&cursor)).expect("follow-on");
2199        assert!(matches!(
2200            follow_on,
2201            HostedSyncReplayResult::Delivered { .. }
2202        ));
2203        transport.edge.tokens.clear();
2204        assert_eq!(
2205            transport.replay_from(None).unwrap_err().code,
2206            HostedSyncErrorCode::UnauthorizedScope
2207        );
2208        transport.edge.authorize(&channel, "good");
2209        transport.advance_clock(200);
2210        assert!(matches!(
2211            transport.connection_state(),
2212            HostedSyncConnectionState::Degraded {
2213                reason: HostedSyncDegradedReason::CredentialExpired
2214            }
2215        ));
2216        assert_eq!(
2217            transport.publish(sample_op("late")).unwrap_err().code,
2218            HostedSyncErrorCode::CredentialExpired
2219        );
2220
2221        // Empty channel history.
2222        let mut edge = InMemoryAblyEdge::new();
2223        edge.authorize("empty", "t");
2224        assert!(
2225            edge.history_from("empty", "t", None, 0, MIN_REPLAY_WINDOW_MS)
2226                .expect("empty history")
2227                .payloads
2228                .is_empty()
2229        );
2230        assert_eq!(
2231            edge.history_from("empty", "bad", None, 0, MIN_REPLAY_WINDOW_MS)
2232                .unwrap_err(),
2233            AblyEdgeError::Unauthorized
2234        );
2235        edge.set_available(false);
2236        assert_eq!(
2237            edge.publish("empty", "t", 0, b"x").unwrap_err(),
2238            AblyEdgeError::Unavailable
2239        );
2240        assert_eq!(
2241            edge.history_from("empty", "t", None, 0, MIN_REPLAY_WINDOW_MS)
2242                .unwrap_err(),
2243            AblyEdgeError::Unavailable
2244        );
2245        edge.set_available(true);
2246        edge.authorize("empty", "t");
2247        let cursor = edge.publish("empty", "t", 0, b"{}").expect("pub");
2248        edge.publish("empty", "t", 10, b"{}").expect("pub2");
2249        // Unknown non-empty cursor expires.
2250        assert!(matches!(
2251            edge.history_from("empty", "t", Some("missing"), 10, 5)
2252                .unwrap_err(),
2253            AblyEdgeError::CursorExpired { .. }
2254        ));
2255        let _ = cursor;
2256    }
2257
2258    #[test]
2259    fn helper_and_conformance_failure_branches() {
2260        assert_eq!(
2261            map_ably_edge_failure(AblyEdgeError::Unauthorized).code,
2262            HostedSyncErrorCode::UnauthorizedScope
2263        );
2264        assert_eq!(
2265            map_ably_edge_failure(AblyEdgeError::Unavailable).code,
2266            HostedSyncErrorCode::ProviderUnavailable
2267        );
2268        assert_eq!(
2269            map_ably_edge_failure(AblyEdgeError::CursorExpired {
2270                oldest_available_cursor: Some("c".to_string()),
2271            })
2272            .code,
2273            HostedSyncErrorCode::ResyncRequired
2274        );
2275        assert!(hex_decode("abc").is_none());
2276        assert!(hex_decode("zz").is_none());
2277        assert_eq!(hex_decode("Ab"), Some(vec![0xab]));
2278        assert!(
2279            expect_error_code(
2280                &Ok::<(), HostedSyncError>(()),
2281                HostedSyncErrorCode::InvalidEnvelope,
2282                "boom"
2283            )
2284            .is_err()
2285        );
2286        let leaked = HostedSyncObservation {
2287            governing_spec: HOSTED_TRANSPORT_SPEC.to_string(),
2288            kind: "x".to_string(),
2289            operation_id: None,
2290            key_version_id: None,
2291            hashed_scope: None,
2292            connection_state: None,
2293            outcome: Some("token-live".to_string()),
2294            latency_ms: None,
2295        };
2296        assert!(assert_observation_redacted(&leaked).is_err());
2297
2298        let mut event = wrap_ecca_event(&sample_op("op"));
2299        event.data = json!({});
2300        assert_eq!(
2301            operation_from_ecca_event(&event).unwrap_err().code,
2302            HostedSyncErrorCode::InvalidEnvelope
2303        );
2304        event.data = json!({"ciphertext": "zz"});
2305        assert_eq!(
2306            operation_from_ecca_event(&event).unwrap_err().code,
2307            HostedSyncErrorCode::InvalidEnvelope
2308        );
2309        event.data = json!({"ciphertext": "aa"});
2310        assert_eq!(
2311            operation_from_ecca_event(&event).unwrap_err().code,
2312            HostedSyncErrorCode::InvalidEnvelope
2313        );
2314        event.data = json!({
2315            "ciphertext": "aa",
2316            "operation_id": "op",
2317            "synchronization_set_id": "s",
2318            "writer_id": "w",
2319            "key_version_id": "k",
2320        });
2321        assert_eq!(
2322            operation_from_ecca_event(&event).unwrap_err().code,
2323            HostedSyncErrorCode::InvalidEnvelope
2324        );
2325
2326        for mode in [
2327            SabotageMode::BadPublishReceipt,
2328            SabotageMode::BadDedup,
2329            SabotageMode::NotDegradedOnOutage,
2330            SabotageMode::ResyncOnReplay,
2331            SabotageMode::WrongReplayEnvelope,
2332            SabotageMode::DeliverOnExpiredCursor,
2333            SabotageMode::EmptyLineage,
2334            SabotageMode::BadLineage,
2335            SabotageMode::WrongStaleKeyCode,
2336            SabotageMode::WrongForeignScopeCode,
2337            SabotageMode::WrongOutageCode,
2338        ] {
2339            let mut sabotage = SabotageTransport::new(mode);
2340            assert_eq!(sabotage.adapter_kind(), "sabotage");
2341            assert!(
2342                run_hosted_sync_conformance(&mut sabotage).is_err(),
2343                "mode {mode:?} should fail conformance"
2344            );
2345        }
2346    }
2347
2348    #[derive(Clone, Copy, Debug)]
2349    enum SabotageMode {
2350        BadPublishReceipt,
2351        BadDedup,
2352        NotDegradedOnOutage,
2353        ResyncOnReplay,
2354        WrongReplayEnvelope,
2355        DeliverOnExpiredCursor,
2356        EmptyLineage,
2357        BadLineage,
2358        WrongStaleKeyCode,
2359        WrongForeignScopeCode,
2360        WrongOutageCode,
2361    }
2362
2363    struct SabotageTransport {
2364        mode: SabotageMode,
2365        publish_calls: u8,
2366        lineage: Vec<HostedSyncLineageEvidence>,
2367        available: bool,
2368    }
2369
2370    impl SabotageTransport {
2371        fn new(mode: SabotageMode) -> Self {
2372            let lineage = match mode {
2373                SabotageMode::EmptyLineage => Vec::new(),
2374                SabotageMode::BadLineage => vec![HostedSyncLineageEvidence {
2375                    governing_spec: "wrong".to_string(),
2376                    protocol_spec: SYNC_PROTOCOL_SPEC.to_string(),
2377                    operation_id: "op-1".to_string(),
2378                    synchronization_set_id: "sync-set-1".to_string(),
2379                    writer_id: "writer-a".to_string(),
2380                    lamport_clock: 3,
2381                    correlation_id: None,
2382                    causation_id: None,
2383                    key_version_id: "key-active".to_string(),
2384                    event_id: "evt".to_string(),
2385                    observed: true,
2386                }],
2387                _ => vec![HostedSyncLineageEvidence {
2388                    governing_spec: HOSTED_TRANSPORT_SPEC.to_string(),
2389                    protocol_spec: SYNC_PROTOCOL_SPEC.to_string(),
2390                    operation_id: "op-1".to_string(),
2391                    synchronization_set_id: "sync-set-1".to_string(),
2392                    writer_id: "writer-a".to_string(),
2393                    lamport_clock: 3,
2394                    correlation_id: None,
2395                    causation_id: None,
2396                    key_version_id: "key-active".to_string(),
2397                    event_id: "evt".to_string(),
2398                    observed: true,
2399                }],
2400            };
2401            Self {
2402                mode,
2403                publish_calls: 0,
2404                lineage,
2405                available: true,
2406            }
2407        }
2408    }
2409
2410    impl HostedSyncTransport for SabotageTransport {
2411        fn connect(&mut self, _credential: HostedSyncCredential) -> Result<(), HostedSyncError> {
2412            Ok(())
2413        }
2414        fn refresh_credential(
2415            &mut self,
2416            credential: HostedSyncCredential,
2417        ) -> Result<(), HostedSyncError> {
2418            if credential.scope.as_str().contains("tenant-b") {
2419                let code = if matches!(self.mode, SabotageMode::WrongForeignScopeCode) {
2420                    HostedSyncErrorCode::UnauthorizedScope
2421                } else {
2422                    HostedSyncErrorCode::CredentialMismatch
2423                };
2424                return Err(hosted_error(code, "foreign", json!({})));
2425            }
2426            Ok(())
2427        }
2428        fn publish(
2429            &mut self,
2430            operation: EncryptedSyncOperation,
2431        ) -> Result<HostedSyncPublishReceipt, HostedSyncError> {
2432            if !self.available {
2433                let code = if matches!(self.mode, SabotageMode::WrongOutageCode) {
2434                    HostedSyncErrorCode::InvalidEnvelope
2435                } else {
2436                    HostedSyncErrorCode::ProviderUnavailable
2437                };
2438                return Err(hosted_error(code, "outage", json!({})));
2439            }
2440            if operation.key_version_id == "key-retired" {
2441                let code = if matches!(self.mode, SabotageMode::WrongStaleKeyCode) {
2442                    HostedSyncErrorCode::InvalidEnvelope
2443                } else {
2444                    HostedSyncErrorCode::KeyMismatch
2445                };
2446                return Err(hosted_error(code, "stale", json!({})));
2447            }
2448            self.publish_calls = self.publish_calls.saturating_add(1);
2449            let operation_id = match self.mode {
2450                SabotageMode::BadPublishReceipt => "wrong".to_string(),
2451                SabotageMode::BadDedup if self.publish_calls > 1 => "changed".to_string(),
2452                _ => operation.operation_id,
2453            };
2454            Ok(HostedSyncPublishReceipt {
2455                operation_id,
2456                cursor: "c1".to_string(),
2457            })
2458        }
2459        fn replay_from(
2460            &mut self,
2461            cursor: Option<&str>,
2462        ) -> Result<HostedSyncReplayResult, HostedSyncError> {
2463            if cursor.is_some() {
2464                return match self.mode {
2465                    SabotageMode::DeliverOnExpiredCursor => Ok(HostedSyncReplayResult::Delivered {
2466                        operations: vec![sample_operation()],
2467                        cursor: "c2".to_string(),
2468                    }),
2469                    _ => Ok(HostedSyncReplayResult::ResyncRequired {
2470                        oldest_available_cursor: None,
2471                    }),
2472                };
2473            }
2474            match self.mode {
2475                SabotageMode::ResyncOnReplay => Ok(HostedSyncReplayResult::ResyncRequired {
2476                    oldest_available_cursor: None,
2477                }),
2478                SabotageMode::WrongReplayEnvelope => Ok(HostedSyncReplayResult::Delivered {
2479                    operations: vec![sample_op("other")],
2480                    cursor: "c1".to_string(),
2481                }),
2482                _ => Ok(HostedSyncReplayResult::Delivered {
2483                    operations: vec![sample_operation()],
2484                    cursor: "c1".to_string(),
2485                }),
2486            }
2487        }
2488        fn connection_state(&self) -> HostedSyncConnectionState {
2489            if !self.available {
2490                if matches!(self.mode, SabotageMode::NotDegradedOnOutage) {
2491                    return HostedSyncConnectionState::Connected;
2492                }
2493                return HostedSyncConnectionState::Degraded {
2494                    reason: HostedSyncDegradedReason::RelayUnavailable,
2495                };
2496            }
2497            HostedSyncConnectionState::Connected
2498        }
2499        fn advance_clock(&mut self, _now_ms: u64) {}
2500        fn set_relay_available(&mut self, available: bool) {
2501            self.available = available;
2502        }
2503        fn observations(&self) -> &[HostedSyncObservation] {
2504            &[]
2505        }
2506        fn lineage(&self) -> &[HostedSyncLineageEvidence] {
2507            &self.lineage
2508        }
2509        fn adapter_kind(&self) -> &'static str {
2510            "sabotage"
2511        }
2512    }
2513}