1use 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#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
21pub struct SyncScopeId(String);
22
23impl SyncScopeId {
24 #[must_use]
26 pub fn new(value: impl Into<String>) -> Self {
27 Self(value.into())
28 }
29
30 #[must_use]
32 pub fn as_str(&self) -> &str {
33 &self.0
34 }
35
36 #[must_use]
38 pub fn hashed(&self) -> String {
39 hex_digest(self.0.as_bytes())
40 }
41}
42
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45pub struct HostedSyncCredential {
46 pub token: String,
47 pub scope: SyncScopeId,
48 pub expires_at_ms: u64,
50}
51
52#[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 pub ciphertext: Vec<u8>,
64}
65
66#[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#[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#[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#[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#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
122pub struct HostedSyncPublishReceipt {
123 pub operation_id: String,
124 pub cursor: String,
125}
126
127#[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#[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
156pub trait HostedSyncTransport {
158 fn connect(&mut self, credential: HostedSyncCredential) -> Result<(), HostedSyncError>;
165
166 fn refresh_credential(
172 &mut self,
173 credential: HostedSyncCredential,
174 ) -> Result<(), HostedSyncError>;
175
176 fn publish(
182 &mut self,
183 operation: EncryptedSyncOperation,
184 ) -> Result<HostedSyncPublishReceipt, HostedSyncError>;
185
186 fn replay_from(
193 &mut self,
194 cursor: Option<&str>,
195 ) -> Result<HostedSyncReplayResult, HostedSyncError>;
196
197 fn connection_state(&self) -> HostedSyncConnectionState;
199
200 fn advance_clock(&mut self, now_ms: u64);
202
203 fn set_relay_available(&mut self, available: bool);
205
206 fn observations(&self) -> &[HostedSyncObservation];
208
209 fn lineage(&self) -> &[HostedSyncLineageEvidence];
211
212 fn adapter_kind(&self) -> &'static str;
214}
215
216#[derive(Debug)]
218pub struct InMemoryHostedSyncTransport {
219 inner: SharedRelay,
220}
221
222impl InMemoryHostedSyncTransport {
223 #[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#[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_channel: String,
309}
310
311pub trait AblyRealtimeEdge {
313 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 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 fn set_available(&mut self, available: bool);
343}
344
345#[derive(Debug, Clone, PartialEq, Eq)]
347pub enum AblyEdgeError {
348 Unauthorized,
349 Unavailable,
350 CursorExpired {
351 oldest_available_cursor: Option<String>,
352 },
353}
354
355#[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 #[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 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#[derive(Debug, Default)]
769pub struct InMemoryAblyEdge {
770 available: bool,
771 tokens: BTreeMap<String, BTreeSet<String>>,
773 channels: BTreeMap<String, VecDeque<RetainedMessage>>,
774}
775
776impl InMemoryAblyEdge {
777 #[must_use]
779 pub fn new() -> Self {
780 Self {
781 available: true,
782 tokens: BTreeMap::new(),
783 channels: BTreeMap::new(),
784 }
785 }
786
787 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 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 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_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
1663pub 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 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 transport.advance_clock(40);
1999 let mut transport = InMemoryHostedSyncTransport::new();
2002 transport.advance_clock(1);
2003 transport
2004 .connect(sample_cred("s", "live", 10))
2005 .expect("connect");
2006 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 let follow_on = transport.replay_from(Some(&cursor)).expect("follow-on");
2032 assert!(matches!(
2033 follow_on,
2034 HostedSyncReplayResult::Delivered { .. }
2035 ));
2036 let expired = transport
2038 .replay_from(Some("cursor:unknown"))
2039 .expect("typed resync");
2040 assert!(matches!(
2041 expired,
2042 HostedSyncReplayResult::ResyncRequired { .. }
2043 ));
2044 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 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 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 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 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 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 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}