1use std::sync::{Arc, Mutex};
24use std::time::Duration;
25
26use crate::{
27 BlobDownload, BlobUploadGrant, SegmentFetch, SegmentRequest, Transport, TransportError,
28};
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
32pub enum RedirectPolicy {
33 #[default]
38 Deny,
39 Follow,
49}
50
51#[derive(Debug, Clone, Default)]
54pub struct HostTransportPolicy {
55 pub request_timeout: Option<Duration>,
57 pub round_deadline: Option<Duration>,
62 pub max_request_bytes: Option<u64>,
65 pub max_response_bytes: Option<u64>,
68 pub redirects: RedirectPolicy,
69}
70
71impl HostTransportPolicy {
72 pub fn validate(&self) -> Result<(), String> {
76 let now = std::time::Instant::now();
77 if let Some(timeout) = self.request_timeout {
78 if timeout.is_zero() || now.checked_add(timeout).is_none() {
79 return Err(
80 "sync.invalid_request: transport config requestTimeoutMs is out of range"
81 .to_owned(),
82 );
83 }
84 }
85 if let Some(deadline) = self.round_deadline {
86 if deadline.is_zero() || now.checked_add(deadline).is_none() {
87 return Err(
88 "sync.invalid_request: transport config roundDeadlineMs is out of range"
89 .to_owned(),
90 );
91 }
92 }
93 if self.max_request_bytes == Some(0) {
94 return Err(
95 "sync.invalid_request: transport config maxRequestBytes must be a positive integer"
96 .to_owned(),
97 );
98 }
99 if self.max_response_bytes == Some(0) {
100 return Err("sync.invalid_request: transport config maxResponseBytes must be a positive integer".to_owned());
101 }
102 Ok(())
103 }
104
105 pub fn from_config(config: &serde_json::Value) -> Result<Self, String> {
108 let bound = |key: &str, message: &'static str| -> Result<Option<u64>, String> {
109 match config.get(key) {
110 None | Some(serde_json::Value::Null) => Ok(None),
111 Some(value) => match value.as_u64() {
112 Some(number) if number >= 1 => Ok(Some(number)),
113 _ => Err(message.to_owned()),
114 },
115 }
116 };
117 let redirects = match config.get("redirects") {
118 None | Some(serde_json::Value::Null) => RedirectPolicy::Deny,
119 Some(serde_json::Value::String(value)) if value == "deny" => RedirectPolicy::Deny,
120 Some(serde_json::Value::String(value)) if value == "follow" => RedirectPolicy::Follow,
121 Some(_) => return Err(
122 "sync.invalid_request: transport config redirects must be \"deny\" or \"follow\""
123 .to_owned(),
124 ),
125 };
126 let policy = Self {
127 request_timeout: bound(
128 "requestTimeoutMs",
129 "sync.invalid_request: transport config requestTimeoutMs must be a positive integer",
130 )?
131 .map(Duration::from_millis),
132 round_deadline: bound(
133 "roundDeadlineMs",
134 "sync.invalid_request: transport config roundDeadlineMs must be a positive integer",
135 )?
136 .map(Duration::from_millis),
137 max_request_bytes: bound(
138 "maxRequestBytes",
139 "sync.invalid_request: transport config maxRequestBytes must be a positive integer",
140 )?,
141 max_response_bytes: bound(
142 "maxResponseBytes",
143 "sync.invalid_request: transport config maxResponseBytes must be a positive integer",
144 )?,
145 redirects,
146 };
147 policy.validate()?;
148 Ok(policy)
149 }
150}
151
152pub enum Inbound {
154 Text(String),
155 Binary(Vec<u8>),
156 Disconnected(TransportError),
159}
160
161pub struct InboundBuffer {
165 frames: Mutex<Vec<Inbound>>,
166 notify: Option<Arc<dyn Fn() + Send + Sync>>,
167}
168
169impl Default for InboundBuffer {
170 fn default() -> Self {
171 Self {
172 frames: Mutex::new(Vec::new()),
173 notify: None,
174 }
175 }
176}
177
178impl InboundBuffer {
179 pub fn with_notify(notify: Arc<dyn Fn() + Send + Sync>) -> Self {
180 Self {
181 frames: Mutex::new(Vec::new()),
182 notify: Some(notify),
183 }
184 }
185
186 pub fn push(&self, frame: Inbound) {
187 self.frames.lock().expect("inbound lock").push(frame);
188 if let Some(notify) = &self.notify {
189 notify();
190 }
191 }
192 fn take(&self) -> Vec<Inbound> {
193 std::mem::take(&mut *self.frames.lock().expect("inbound lock"))
194 }
195}
196
197#[allow(clippy::large_enum_variant)]
200pub enum HostTransport {
201 Null {
203 signed_urls: bool,
204 inbound: Arc<InboundBuffer>,
205 },
206 #[cfg(feature = "native-transport")]
207 Native(native::NativeTransport),
208 #[cfg(feature = "local-authority")]
210 Local(Box<LocalAuthorityTransport<HostTransport>>),
211}
212
213impl HostTransport {
214 pub fn from_config(config: &serde_json::Value) -> Result<Self, String> {
217 Self::new_from_config(config)
218 }
219
220 pub fn new_from_config(config: &serde_json::Value) -> Result<Self, String> {
223 Self::from_config_with_notify(config, None)
224 }
225
226 pub fn from_config_with_notify(
230 config: &serde_json::Value,
231 notify: Option<Arc<dyn Fn() + Send + Sync>>,
232 ) -> Result<Self, String> {
233 let policy = HostTransportPolicy::from_config(config)?;
234 Self::from_config_with_policy(config, policy, notify)
235 }
236
237 pub fn from_config_with_policy(
241 config: &serde_json::Value,
242 policy: HostTransportPolicy,
243 notify: Option<Arc<dyn Fn() + Send + Sync>>,
244 ) -> Result<Self, String> {
245 let local_authority_path = match config.get("localAuthorityPath") {
246 None | Some(serde_json::Value::Null) => None,
247 Some(serde_json::Value::String(path)) if !path.is_empty() => Some(path.clone()),
248 Some(_) => return Err("localAuthorityPath must be a non-empty string".to_owned()),
249 };
250 #[cfg(not(feature = "local-authority"))]
251 if local_authority_path.is_some() {
252 return Err(format!(
253 "{}: this build has no local authority (rebuild with --features local-authority)",
254 crate::LOCAL_AUTHORITY_UNCONFIGURED_CODE
255 ));
256 }
257 let remote = match config.get("baseUrl").and_then(|v| v.as_str()) {
258 #[cfg(feature = "native-transport")]
259 Some(base_url) => HostTransport::Native(native::NativeTransport::with_policy(
260 base_url, config, policy, notify,
261 )?),
262 _ => {
263 #[cfg(not(feature = "native-transport"))]
264 {
265 policy.validate()?;
266 if config.get("baseUrl").is_some() {
267 return Err(
268 "this build has no native transport (rebuild with --features native-transport)"
269 .to_owned(),
270 );
271 }
272 }
273 HostTransport::Null {
274 signed_urls: false,
275 inbound: Arc::new(match notify {
276 Some(notify) => InboundBuffer::with_notify(notify),
277 None => InboundBuffer::default(),
278 }),
279 }
280 }
281 };
282 #[cfg(feature = "local-authority")]
283 if let Some(path) = local_authority_path {
284 let remote_configured = !matches!(remote, HostTransport::Null { .. });
285 return Ok(HostTransport::Local(Box::new(
286 LocalAuthorityTransport::new(path, remote, remote_configured),
287 )));
288 }
289 Ok(remote)
290 }
291
292 pub fn fork_round(&self) -> Self {
295 match self {
296 Self::Null {
297 signed_urls,
298 inbound,
299 } => Self::Null {
300 signed_urls: *signed_urls,
301 inbound: inbound.clone(),
302 },
303 #[cfg(feature = "native-transport")]
304 Self::Native(t) => Self::Native(t.fork_round()),
305 #[cfg(feature = "local-authority")]
306 Self::Local(t) => Self::Local(Box::new(t.fork_round())),
307 }
308 }
309
310 pub fn set_signed_urls(&mut self, value: bool) {
311 match self {
312 HostTransport::Null { signed_urls, .. } => *signed_urls = value,
313 #[cfg(feature = "native-transport")]
314 HostTransport::Native(t) => t.signed_urls = value,
315 #[cfg(feature = "local-authority")]
316 HostTransport::Local(t) => t.remote.set_signed_urls(value),
317 }
318 }
319
320 pub fn set_headers(&mut self, headers: Vec<(String, String)>) {
323 match self {
324 HostTransport::Null { .. } => drop(headers),
325 #[cfg(feature = "native-transport")]
326 HostTransport::Native(t) => t.set_headers(headers),
327 #[cfg(feature = "local-authority")]
328 HostTransport::Local(t) => t.remote.set_headers(headers),
329 }
330 }
331
332 pub fn set_policy(&mut self, policy: HostTransportPolicy) -> Result<(), String> {
337 policy.validate()?;
338 match self {
339 HostTransport::Null { .. } => {}
340 #[cfg(feature = "native-transport")]
341 HostTransport::Native(t) => t.set_policy(policy)?,
342 #[cfg(feature = "local-authority")]
343 HostTransport::Local(t) => t.remote.set_policy(policy)?,
344 }
345 Ok(())
346 }
347
348 pub fn take_inbound(&mut self) -> Vec<Inbound> {
350 match self {
351 HostTransport::Null { inbound, .. } => inbound.take(),
352 #[cfg(feature = "native-transport")]
353 HostTransport::Native(t) => t.inbound.take(),
354 #[cfg(feature = "local-authority")]
355 HostTransport::Local(t) => t.remote.take_inbound(),
356 }
357 }
358
359 pub fn shutdown(&mut self) {
362 match self {
363 HostTransport::Null { .. } => {}
364 #[cfg(feature = "native-transport")]
365 HostTransport::Native(t) => t.shutdown(),
366 #[cfg(feature = "local-authority")]
367 HostTransport::Local(t) => t.shutdown(),
368 }
369 }
370}
371
372fn unavailable(op: &str) -> TransportError {
373 TransportError::new(
374 "transport.unavailable",
375 format!("{op} needs the native transport (build with --features native-transport)"),
376 )
377}
378
379#[cfg_attr(not(feature = "native-transport"), allow(unused_variables))]
382impl Transport for HostTransport {
383 fn sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
384 match self {
385 HostTransport::Null { .. } => Err(unavailable("sync")),
386 #[cfg(feature = "native-transport")]
387 HostTransport::Native(t) => t.sync(request),
388 #[cfg(feature = "local-authority")]
389 HostTransport::Local(t) => t.sync(request),
390 }
391 }
392
393 fn remote_operation(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
394 match self {
395 HostTransport::Null { .. } => Err(unavailable("remoteOperation")),
396 #[cfg(feature = "native-transport")]
397 HostTransport::Native(t) => t.remote_operation(request),
398 #[cfg(feature = "local-authority")]
399 HostTransport::Local(t) => t.remote_operation(request),
400 }
401 }
402
403 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
404 match self {
405 HostTransport::Null { .. } => Err(unavailable("realtimeSync")),
406 #[cfg(feature = "native-transport")]
407 HostTransport::Native(t) => t.realtime_sync(request),
408 #[cfg(feature = "local-authority")]
409 HostTransport::Local(t) => t.realtime_sync(request),
410 }
411 }
412
413 fn download_segment(
414 &mut self,
415 request: &SegmentRequest,
416 on_progress: &mut dyn FnMut(u64),
417 ) -> Result<Vec<u8>, TransportError> {
418 match self {
419 HostTransport::Null { .. } => Err(unavailable("downloadSegment")),
420 #[cfg(feature = "native-transport")]
421 HostTransport::Native(t) => t.download_segment(request, on_progress),
422 #[cfg(feature = "local-authority")]
423 HostTransport::Local(t) => t.download_segment(request, on_progress),
424 }
425 }
426
427 fn supports_url_fetch(&self) -> bool {
428 match self {
429 HostTransport::Null { signed_urls, .. } => *signed_urls,
430 #[cfg(feature = "native-transport")]
431 HostTransport::Native(t) => t.signed_urls,
432 #[cfg(feature = "local-authority")]
433 HostTransport::Local(t) => t.supports_url_fetch(),
434 }
435 }
436
437 fn download_segments(
438 &mut self,
439 fetches: &[SegmentFetch],
440 on_progress: &mut (dyn FnMut(usize, u64) + Send),
441 ) -> Vec<Result<Vec<u8>, TransportError>> {
442 match self {
443 HostTransport::Null { .. } => fetches
444 .first()
445 .map(|_| Err(unavailable("downloadSegments")))
446 .into_iter()
447 .collect(),
448 #[cfg(feature = "native-transport")]
449 HostTransport::Native(t) => t.download_segments(fetches, on_progress),
450 #[cfg(feature = "local-authority")]
451 HostTransport::Local(t) => t.download_segments(fetches, on_progress),
452 }
453 }
454
455 fn fetch_url(
456 &mut self,
457 url: &str,
458 on_progress: &mut dyn FnMut(u64),
459 ) -> Result<Vec<u8>, TransportError> {
460 match self {
461 HostTransport::Null { .. } => Err(unavailable("fetchUrl")),
462 #[cfg(feature = "native-transport")]
463 HostTransport::Native(t) => t.fetch_url(url, on_progress),
464 #[cfg(feature = "local-authority")]
465 HostTransport::Local(t) => t.fetch_url(url, on_progress),
466 }
467 }
468
469 fn blob_upload(
470 &mut self,
471 blob_id: &str,
472 bytes: &[u8],
473 media_type: Option<&str>,
474 ) -> Result<(), TransportError> {
475 match self {
476 HostTransport::Null { .. } => Err(unavailable("blobUpload")),
477 #[cfg(feature = "native-transport")]
478 HostTransport::Native(t) => t.blob_upload(blob_id, bytes, media_type),
479 #[cfg(feature = "local-authority")]
480 HostTransport::Local(t) => t.blob_upload(blob_id, bytes, media_type),
481 }
482 }
483
484 fn blob_download(&mut self, blob_id: &str) -> Result<BlobDownload, TransportError> {
485 match self {
486 HostTransport::Null { .. } => Err(unavailable("blobDownload")),
487 #[cfg(feature = "native-transport")]
488 HostTransport::Native(t) => t.blob_download(blob_id),
489 #[cfg(feature = "local-authority")]
490 HostTransport::Local(t) => t.blob_download(blob_id),
491 }
492 }
493
494 fn fetch_blob_url(&mut self, url: &str) -> Result<Vec<u8>, TransportError> {
495 match self {
496 HostTransport::Null { .. } => Err(unavailable("fetchBlobUrl")),
497 #[cfg(feature = "native-transport")]
498 HostTransport::Native(t) => t.fetch_blob_url(url),
499 #[cfg(feature = "local-authority")]
500 HostTransport::Local(t) => t.fetch_blob_url(url),
501 }
502 }
503
504 fn blob_upload_grant(
505 &mut self,
506 blob_id: &str,
507 byte_length: u64,
508 media_type: Option<&str>,
509 ) -> Result<BlobUploadGrant, TransportError> {
510 match self {
511 HostTransport::Null { .. } => Ok(BlobUploadGrant::None),
514 #[cfg(feature = "native-transport")]
515 HostTransport::Native(t) => t.blob_upload_grant(blob_id, byte_length, media_type),
516 #[cfg(feature = "local-authority")]
517 HostTransport::Local(t) => t.blob_upload_grant(blob_id, byte_length, media_type),
518 }
519 }
520
521 fn blob_put_url(
522 &mut self,
523 url: &str,
524 bytes: &[u8],
525 media_type: Option<&str>,
526 ) -> Result<(), TransportError> {
527 match self {
528 HostTransport::Null { .. } => Err(unavailable("blobPutUrl")),
529 #[cfg(feature = "native-transport")]
530 HostTransport::Native(t) => t.blob_put_url(url, bytes, media_type),
531 #[cfg(feature = "local-authority")]
532 HostTransport::Local(t) => t.blob_put_url(url, bytes, media_type),
533 }
534 }
535
536 fn realtime_connect(&mut self) -> Result<(), TransportError> {
537 match self {
538 HostTransport::Null { .. } => Err(unavailable("realtimeConnect")),
539 #[cfg(feature = "native-transport")]
540 HostTransport::Native(t) => {
541 t.set_realtime_client_id(None);
542 t.realtime_connect()
543 }
544 #[cfg(feature = "local-authority")]
545 HostTransport::Local(t) => t.realtime_connect(),
546 }
547 }
548
549 fn realtime_connect_for_client(&mut self, client_id: &str) -> Result<(), TransportError> {
550 match self {
551 HostTransport::Null { .. } => Err(unavailable("realtimeConnect")),
552 #[cfg(feature = "native-transport")]
553 HostTransport::Native(t) => {
554 t.set_realtime_client_id(Some(client_id));
555 t.realtime_connect()
556 }
557 #[cfg(feature = "local-authority")]
558 HostTransport::Local(t) => t.realtime_connect_for_client(client_id),
559 }
560 }
561
562 fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
563 match self {
564 HostTransport::Null { .. } => Err(unavailable("realtimeSend")),
565 #[cfg(feature = "native-transport")]
566 HostTransport::Native(t) => t.realtime_send(text),
567 #[cfg(feature = "local-authority")]
568 HostTransport::Local(t) => t.realtime_send(text),
569 }
570 }
571
572 fn realtime_close(&mut self) -> Result<(), TransportError> {
573 match self {
574 HostTransport::Null { .. } => Ok(()),
575 #[cfg(feature = "native-transport")]
576 HostTransport::Native(t) => t.realtime_close(),
577 #[cfg(feature = "local-authority")]
578 HostTransport::Local(t) => t.realtime_close(),
579 }
580 }
581
582 fn round_deadline(&self) -> Option<std::time::Duration> {
583 match self {
584 HostTransport::Null { .. } => None,
585 #[cfg(feature = "native-transport")]
586 HostTransport::Native(t) => t.round_deadline(),
587 #[cfg(feature = "local-authority")]
588 HostTransport::Local(t) => t.round_deadline(),
589 }
590 }
591
592 fn set_round_deadline(&mut self, deadline: Option<std::time::Instant>) {
593 match self {
594 HostTransport::Null { .. } => {}
595 #[cfg(feature = "native-transport")]
596 HostTransport::Native(t) => t.set_round_deadline(deadline),
597 #[cfg(feature = "local-authority")]
598 HostTransport::Local(t) => t.set_round_deadline(deadline),
599 }
600 }
601
602 #[cfg(feature = "local-authority")]
603 fn open_local_authority(
604 &mut self,
605 schema: &serde_json::Value,
606 replica_path: Option<&str>,
607 ) -> Result<(), TransportError> {
608 match self {
609 HostTransport::Local(t) => t.open_local_authority(schema, replica_path),
610 _ => Ok(()),
611 }
612 }
613
614 #[cfg(feature = "local-authority")]
615 fn local_authority_state(&self) -> Option<&'static str> {
616 match self {
617 HostTransport::Local(t) => t.local_authority_state(),
618 _ => None,
619 }
620 }
621
622 #[cfg(feature = "local-authority")]
623 fn attach_local_authority(&mut self) -> Result<(), TransportError> {
624 match self {
625 HostTransport::Local(t) => t.attach_local_authority(),
626 _ => Err(local::unconfigured("this transport has no local authority")),
627 }
628 }
629
630 #[cfg(feature = "local-authority")]
631 fn abandon_local_authority_attach(&mut self) -> Result<(), TransportError> {
632 match self {
633 HostTransport::Local(t) => t.abandon_local_authority_attach(),
634 _ => Err(local::unconfigured("this transport has no local authority")),
635 }
636 }
637}
638
639#[cfg(feature = "local-authority")]
640pub use local::LocalAuthorityTransport;
641
642#[cfg(feature = "local-authority")]
643mod local {
644 use std::sync::{Arc, Mutex};
652
653 use syncular_authority::{AuthorityError, AuthorityState, LocalAuthority};
654
655 use super::HostTransport;
656 use crate::{
657 BlobDownload, BlobUploadGrant, SegmentFetch, SegmentRequest, Transport, TransportError,
658 LOCAL_AUTHORITY_UNCONFIGURED_CODE,
659 };
660
661 pub(super) fn unconfigured(message: &'static str) -> TransportError {
662 TransportError::new(LOCAL_AUTHORITY_UNCONFIGURED_CODE, message)
663 }
664
665 fn transport_error(error: AuthorityError) -> TransportError {
666 TransportError {
667 code: error.code,
668 message: error.message,
669 details: error.details,
670 }
671 }
672
673 fn authority_error(error: TransportError) -> AuthorityError {
674 AuthorityError {
675 code: error.code,
676 message: error.message,
677 details: error.details,
678 }
679 }
680
681 fn refused(message: &'static str) -> TransportError {
682 TransportError::new("sync.invalid_request", message)
683 }
684
685 pub struct LocalAuthorityTransport<R> {
687 path: String,
688 authority: Arc<Mutex<Option<LocalAuthority>>>,
691 pub(super) remote: R,
692 remote_configured: bool,
695 }
696
697 impl<R> LocalAuthorityTransport<R> {
698 pub fn new(path: impl Into<String>, remote: R, remote_configured: bool) -> Self {
702 Self {
703 path: path.into(),
704 authority: Arc::new(Mutex::new(None)),
705 remote,
706 remote_configured,
707 }
708 }
709
710 fn local_active(&self) -> bool {
711 self.authority
712 .lock()
713 .expect("local authority lock")
714 .as_ref()
715 .is_none_or(|authority| authority.state() != AuthorityState::Attached)
716 }
717 }
718
719 impl LocalAuthorityTransport<HostTransport> {
720 pub(super) fn fork_round(&self) -> Self {
721 Self {
722 path: self.path.clone(),
723 authority: self.authority.clone(),
724 remote: self.remote.fork_round(),
725 remote_configured: self.remote_configured,
726 }
727 }
728
729 pub(super) fn shutdown(&mut self) {
730 self.remote.shutdown();
731 *self.authority.lock().expect("local authority lock") = None;
732 }
733 }
734
735 impl<R: Transport> Transport for LocalAuthorityTransport<R> {
736 fn sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
737 let mut authority = self.authority.lock().expect("local authority lock");
738 match authority.as_mut() {
739 None => Err(unconfigured(
740 "the local authority opens with create; create a client first",
741 )),
742 Some(local) if local.state() != AuthorityState::Attached => {
743 local.sync(request).map_err(transport_error)
744 }
745 Some(_) => {
746 drop(authority);
747 self.remote.sync(request)
748 }
749 }
750 }
751
752 fn remote_operation(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
753 self.remote.remote_operation(request)
754 }
755
756 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
757 if self.local_active() {
758 return Err(refused("the local authority has no realtime channel"));
759 }
760 self.remote.realtime_sync(request)
761 }
762
763 fn download_segment(
764 &mut self,
765 request: &SegmentRequest,
766 on_progress: &mut dyn FnMut(u64),
767 ) -> Result<Vec<u8>, TransportError> {
768 if self.local_active() {
769 return Err(refused("the local authority has no segment endpoint"));
770 }
771 self.remote.download_segment(request, on_progress)
772 }
773
774 fn download_segments(
775 &mut self,
776 fetches: &[SegmentFetch],
777 on_progress: &mut (dyn FnMut(usize, u64) + Send),
778 ) -> Vec<Result<Vec<u8>, TransportError>> {
779 if self.local_active() {
780 return fetches
781 .first()
782 .map(|_| Err(refused("the local authority has no segment endpoint")))
783 .into_iter()
784 .collect();
785 }
786 self.remote.download_segments(fetches, on_progress)
787 }
788
789 fn supports_url_fetch(&self) -> bool {
790 self.remote.supports_url_fetch()
791 }
792
793 fn fetch_url(
794 &mut self,
795 url: &str,
796 on_progress: &mut dyn FnMut(u64),
797 ) -> Result<Vec<u8>, TransportError> {
798 if self.local_active() {
799 return Err(refused("the local authority has no segment endpoint"));
800 }
801 self.remote.fetch_url(url, on_progress)
802 }
803
804 fn blob_upload(
805 &mut self,
806 blob_id: &str,
807 bytes: &[u8],
808 media_type: Option<&str>,
809 ) -> Result<(), TransportError> {
810 self.remote.blob_upload(blob_id, bytes, media_type)
811 }
812
813 fn blob_download(&mut self, blob_id: &str) -> Result<BlobDownload, TransportError> {
814 self.remote.blob_download(blob_id)
815 }
816
817 fn fetch_blob_url(&mut self, url: &str) -> Result<Vec<u8>, TransportError> {
818 self.remote.fetch_blob_url(url)
819 }
820
821 fn blob_upload_grant(
822 &mut self,
823 blob_id: &str,
824 byte_length: u64,
825 media_type: Option<&str>,
826 ) -> Result<BlobUploadGrant, TransportError> {
827 self.remote
828 .blob_upload_grant(blob_id, byte_length, media_type)
829 }
830
831 fn blob_put_url(
832 &mut self,
833 url: &str,
834 bytes: &[u8],
835 media_type: Option<&str>,
836 ) -> Result<(), TransportError> {
837 self.remote.blob_put_url(url, bytes, media_type)
838 }
839
840 fn realtime_connect(&mut self) -> Result<(), TransportError> {
841 if self.local_active() {
842 return Err(refused("the local authority has no realtime channel"));
843 }
844 self.remote.realtime_connect()
845 }
846
847 fn realtime_connect_for_client(&mut self, client_id: &str) -> Result<(), TransportError> {
848 if self.local_active() {
849 return Err(refused("the local authority has no realtime channel"));
850 }
851 self.remote.realtime_connect_for_client(client_id)
852 }
853
854 fn round_deadline(&self) -> Option<std::time::Duration> {
855 self.remote.round_deadline()
856 }
857
858 fn set_round_deadline(&mut self, deadline: Option<std::time::Instant>) {
859 self.remote.set_round_deadline(deadline);
860 }
861
862 fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
863 self.remote.realtime_send(text)
864 }
865
866 fn realtime_close(&mut self) -> Result<(), TransportError> {
867 self.remote.realtime_close()
868 }
869
870 fn open_local_authority(
871 &mut self,
872 schema: &serde_json::Value,
873 replica_path: Option<&str>,
874 ) -> Result<(), TransportError> {
875 if self.path != ":memory:" && replica_path == Some(self.path.as_str()) {
876 return Err(TransportError::new(
877 "sync.invalid_request",
878 "localAuthorityPath must name a file other than the replica's dbPath",
879 ));
880 }
881 let mut authority = self.authority.lock().expect("local authority lock");
882 *authority = None;
884 *authority = Some(LocalAuthority::open(&self.path, schema).map_err(transport_error)?);
885 Ok(())
886 }
887
888 fn local_authority_state(&self) -> Option<&'static str> {
889 self.authority
890 .lock()
891 .expect("local authority lock")
892 .as_ref()
893 .map(|authority| match authority.state() {
894 AuthorityState::Local => "local",
895 AuthorityState::Attaching => "attaching",
896 AuthorityState::Attached => "attached",
897 })
898 }
899
900 fn attach_local_authority(&mut self) -> Result<(), TransportError> {
901 if !self.remote_configured {
902 return Err(unconfigured(
903 "attachLocalAuthority needs a baseUrl for the remote server",
904 ));
905 }
906 let remote = &mut self.remote;
907 let mut authority = self.authority.lock().expect("local authority lock");
908 authority
909 .as_mut()
910 .ok_or_else(|| {
911 unconfigured("the local authority opens with create; create a client first")
912 })?
913 .attach(&mut |request: &[u8]| remote.sync(request).map_err(authority_error))
914 .map_err(transport_error)
915 }
916
917 fn abandon_local_authority_attach(&mut self) -> Result<(), TransportError> {
918 if !self.remote_configured {
919 return Err(unconfigured(
920 "abandonLocalAuthorityAttach needs a baseUrl for the remote server",
921 ));
922 }
923 let remote = &mut self.remote;
924 let mut authority = self.authority.lock().expect("local authority lock");
925 authority
926 .as_mut()
927 .ok_or_else(|| {
928 unconfigured("the local authority opens with create; create a client first")
929 })?
930 .abandon_attach(Some(&mut |request: &[u8]| {
931 remote.sync(request).map_err(authority_error)
932 }))
933 .map_err(transport_error)
934 }
935 }
936}
937
938#[cfg(feature = "native-transport")]
939mod native {
940 use std::io::{self, Read, Write};
952 use std::net::{TcpStream, ToSocketAddrs};
953 use std::sync::atomic::{AtomicBool, Ordering};
954 use std::sync::{mpsc, Arc, Condvar, Mutex};
955 use std::thread::JoinHandle;
956 use std::time::{Duration, Instant};
957
958 use polling::{Event, Events, Poller};
959 use tungstenite::stream::MaybeTlsStream;
960 use tungstenite::Message;
961 use ureq::unversioned::resolver::DefaultResolver;
962 use ureq::unversioned::transport::{
963 time::Duration as UreqDuration, ConnectionDetails, Connector, DefaultConnector,
964 NextTimeout, Transport as UreqTransport,
965 };
966 use ureq::Timeout as UreqTimeout;
967
968 use super::{HostTransportPolicy, Inbound, InboundBuffer, RedirectPolicy};
969 use crate::{
970 BlobDownload, BlobUploadGrant, RealtimeRound, RoundInbound, SegmentFetch, SegmentRequest,
971 Transport, TransportError,
972 };
973
974 struct SocketRegistration {
976 poller: Arc<Poller>,
977 stream: TcpStream,
978 }
979
980 impl Drop for SocketRegistration {
981 fn drop(&mut self) {
982 let _ = self.poller.delete(&self.stream);
983 }
984 }
985
986 #[derive(Clone)]
987 struct SocketSender {
988 queue: mpsc::SyncSender<Outgoing>,
989 poller: Arc<Poller>,
990 }
991
992 struct Outgoing {
993 message: Message,
994 completed: mpsc::Sender<Result<(), TransportError>>,
995 }
996
997 const ROUND_IDLE_TIMEOUT: Duration = Duration::from_secs(30);
1003 const REQUEST_CHUNK_BYTES: usize = 64 * 1024;
1006
1007 fn round_expiry(
1010 last_progress: Instant,
1011 idle: Duration,
1012 ceiling: Option<Instant>,
1013 ) -> (Instant, bool) {
1014 let silent_at = last_progress + idle;
1015 match ceiling {
1016 Some(ceiling) if ceiling < silent_at => (ceiling, true),
1017 _ => (silent_at, false),
1018 }
1019 }
1020 #[derive(Default)]
1027 pub(super) struct RoundChannel {
1028 state: Mutex<RoundState>,
1029 ready: Condvar,
1030 }
1031
1032 #[derive(Default)]
1033 struct RoundState {
1034 round: RealtimeRound,
1035 last_progress: Option<Instant>,
1038 lost: bool,
1040 outcome: Option<Result<Vec<u8>, TransportError>>,
1042 }
1043
1044 impl RoundChannel {
1045 fn begin(&self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
1049 let mut state = self.state.lock().expect("round lock");
1050 state.outcome = None;
1051 let framed = state.round.begin(request)?;
1052 state.last_progress = Some(Instant::now());
1053 Ok(framed)
1054 }
1055
1056 fn progressed(&self) {
1058 self.state.lock().expect("round lock").last_progress = Some(Instant::now());
1059 }
1060
1061 fn lost(&self) -> bool {
1064 self.state.lock().expect("round lock").lost
1065 }
1066
1067 fn route_binary(&self, frame: &[u8]) -> Option<Vec<u8>> {
1071 let mut state = self.state.lock().expect("round lock");
1072 match state.round.route_binary(frame) {
1073 Ok(RoundInbound::Delta(body)) => Some(body),
1074 Ok(RoundInbound::RoundProgress) => {
1075 state.last_progress = Some(Instant::now());
1076 None
1077 }
1078 Ok(RoundInbound::Ignored) => None,
1079 Ok(RoundInbound::RoundComplete(bytes)) => {
1080 state.outcome = Some(Ok(bytes));
1081 self.ready.notify_all();
1082 None
1083 }
1084 Err(error) => {
1085 state.outcome = Some(Err(error));
1086 self.ready.notify_all();
1087 None
1088 }
1089 }
1090 }
1091
1092 fn fail_in_flight(&self, error: TransportError) {
1094 let mut state = self.state.lock().expect("round lock");
1095 state.lost = true;
1096 if state.round.in_flight() && state.outcome.is_none() {
1097 state.round.abort();
1098 state.outcome = Some(Err(error));
1099 self.ready.notify_all();
1100 }
1101 }
1102
1103 fn wait(
1106 &self,
1107 ceiling: Option<Instant>,
1108 idle: Duration,
1109 ) -> Result<Vec<u8>, TransportError> {
1110 let mut state = self.state.lock().expect("round lock");
1111 while state.outcome.is_none() {
1112 let (deadline, ceiling_binds) = round_expiry(
1113 state.last_progress.unwrap_or_else(Instant::now),
1114 idle,
1115 ceiling,
1116 );
1117 let now = Instant::now();
1118 if now >= deadline {
1119 state.round.abort();
1120 return Err(if ceiling_binds {
1121 timeout_error("sync round deadline exceeded", "round_deadline")
1122 } else {
1123 TransportError::new(
1124 "sync.transport_failed",
1125 "realtime sync round made no progress (§8.7)",
1126 )
1127 });
1128 }
1129 let (guard, _timeout) = self
1130 .ready
1131 .wait_timeout(state, deadline - now)
1132 .expect("round wait");
1133 state = guard;
1134 }
1135 state.outcome.take().expect("outcome present")
1136 }
1137 }
1138
1139 fn transfer_error(
1140 message: &'static str,
1141 cause_kind: &'static str,
1142 http_status: Option<u16>,
1143 ) -> TransportError {
1144 let mut details = serde_json::json!({ "causeKind": cause_kind });
1145 if let Some(status) = http_status {
1146 details["httpStatus"] = status.into();
1147 }
1148 let mut error = TransportError::new("transport.failed", message);
1149 error.details = Some(details);
1150 error
1151 }
1152
1153 fn timeout_error(message: &'static str, cause_kind: &'static str) -> TransportError {
1156 let mut error = TransportError::new("transport.timeout", message);
1157 error.details = Some(serde_json::json!({ "causeKind": cause_kind }));
1158 error
1159 }
1160
1161 fn redirect_error(message: &'static str, http_status: Option<u16>) -> TransportError {
1163 let mut details = serde_json::json!({ "causeKind": "redirect" });
1164 if let Some(status) = http_status {
1165 details["httpStatus"] = status.into();
1166 }
1167 let mut error = TransportError::new("transport.redirect", message);
1168 error.details = Some(details);
1169 error
1170 }
1171
1172 fn redirect_refused(resp: &ureq::http::Response<ureq::Body>) -> Result<(), TransportError> {
1177 let status = resp.status().as_u16();
1178 if (300..400).contains(&status) {
1179 return Err(redirect_error("redirect refused", Some(status)));
1180 }
1181 Ok(())
1182 }
1183
1184 fn is_timeout_io(error: &std::io::Error) -> bool {
1190 if error.kind() == std::io::ErrorKind::TimedOut {
1191 return true;
1192 }
1193 let Some(inner) = error.get_ref() else {
1194 return false;
1195 };
1196 let Some(ureq_error) = inner.downcast_ref::<ureq::Error>() else {
1197 return false;
1198 };
1199 match ureq_error {
1200 ureq::Error::Timeout(_) => true,
1201 ureq::Error::Io(nested) => is_timeout_io(nested),
1202 ureq::Error::Decompress(_, nested) => is_timeout_io(nested),
1203 _ => false,
1204 }
1205 }
1206
1207 fn http_err(message: &'static str, error: ureq::Error) -> TransportError {
1208 let (kind, status) = match error {
1209 ureq::Error::StatusCode(status) if (300..400).contains(&status) => {
1210 return redirect_error(message, Some(status))
1211 }
1212 ureq::Error::StatusCode(status) => ("status", Some(status)),
1213 ureq::Error::Timeout(_) => return timeout_error(message, "timeout"),
1214 ureq::Error::Io(error) => return io_err(message, error),
1215 ureq::Error::HostNotFound
1216 | ureq::Error::ConnectionFailed
1217 | ureq::Error::ConnectProxyFailed(_) => ("connect", None),
1218 ureq::Error::Tls(_)
1219 | ureq::Error::Rustls(_)
1220 | ureq::Error::Pem(_)
1221 | ureq::Error::TlsRequired => ("tls", None),
1222 ureq::Error::BadUri(_)
1223 | ureq::Error::Http(_)
1224 | ureq::Error::RequireHttpsOnly(_)
1225 | ureq::Error::InvalidProxyUrl => ("request", None),
1226 ureq::Error::Protocol(_) => ("protocol", None),
1227 ureq::Error::BodyExceedsLimit(_)
1228 | ureq::Error::BodyStalled
1229 | ureq::Error::Decompress(_, _) => ("body", None),
1230 ureq::Error::RedirectFailed | ureq::Error::TooManyRedirects => {
1231 return redirect_error(message, None)
1232 }
1233 _ => ("unknown", None),
1234 };
1235 transfer_error(message, kind, status)
1236 }
1237
1238 fn io_err(message: &'static str, error: std::io::Error) -> TransportError {
1239 if is_timeout_io(&error) {
1240 return timeout_error(message, "timeout");
1241 }
1242 let kind = match error.kind() {
1243 std::io::ErrorKind::ConnectionRefused
1244 | std::io::ErrorKind::ConnectionAborted
1245 | std::io::ErrorKind::ConnectionReset
1246 | std::io::ErrorKind::NotConnected => "connect",
1247 _ => "io",
1248 };
1249 transfer_error(message, kind, None)
1250 }
1251
1252 fn ws_err(message: &'static str, error: tungstenite::Error) -> TransportError {
1253 match error {
1254 tungstenite::Error::Io(error) => io_err(message, error),
1255 tungstenite::Error::Tls(_) => transfer_error(message, "tls", None),
1256 tungstenite::Error::Http(response) if response.status().is_redirection() => {
1259 redirect_error(message, Some(response.status().as_u16()))
1260 }
1261 tungstenite::Error::Http(response) => {
1262 transfer_error(message, "status", Some(response.status().as_u16()))
1263 }
1264 tungstenite::Error::Url(_) | tungstenite::Error::HttpFormat(_) => {
1265 transfer_error(message, "request", None)
1266 }
1267 _ => transfer_error(message, "protocol", None),
1268 }
1269 }
1270
1271 const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
1272
1273 fn remaining(deadline: Instant) -> io::Result<Duration> {
1274 deadline
1275 .checked_duration_since(Instant::now())
1276 .filter(|left| !left.is_zero())
1277 .ok_or_else(|| {
1278 io::Error::new(
1279 io::ErrorKind::TimedOut,
1280 "websocket establishment deadline exceeded",
1281 )
1282 })
1283 }
1284
1285 struct EstablishingStream {
1286 socket: TcpStream,
1287 deadline: Option<Instant>,
1288 }
1289
1290 impl EstablishingStream {
1291 fn timeout(&self) -> io::Result<Option<Duration>> {
1292 self.deadline.map(remaining).transpose()
1293 }
1294 fn established(&mut self) -> io::Result<TcpStream> {
1295 self.deadline = None;
1296 self.socket.set_read_timeout(None)?;
1297 self.socket.set_write_timeout(None)?;
1298 self.socket.try_clone()
1299 }
1300 }
1301
1302 fn establishment_io<T>(result: io::Result<T>, establishing: bool) -> io::Result<T> {
1303 result.map_err(|error| {
1304 if establishing
1305 && matches!(
1306 error.kind(),
1307 io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut
1308 )
1309 {
1310 io::Error::new(
1311 io::ErrorKind::TimedOut,
1312 "websocket establishment deadline exceeded",
1313 )
1314 } else {
1315 error
1316 }
1317 })
1318 }
1319
1320 impl Read for EstablishingStream {
1321 fn read(&mut self, bytes: &mut [u8]) -> io::Result<usize> {
1322 if self.deadline.is_some() {
1323 self.socket.set_read_timeout(self.timeout()?)?;
1324 }
1325 establishment_io(self.socket.read(bytes), self.deadline.is_some())
1326 }
1327 }
1328 impl Write for EstablishingStream {
1329 fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
1330 if self.deadline.is_some() {
1331 self.socket.set_write_timeout(self.timeout()?)?;
1332 }
1333 establishment_io(self.socket.write(bytes), self.deadline.is_some())
1334 }
1335 fn flush(&mut self) -> io::Result<()> {
1336 if self.deadline.is_some() {
1337 self.socket.set_write_timeout(self.timeout()?)?;
1338 }
1339 establishment_io(self.socket.flush(), self.deadline.is_some())
1340 }
1341 }
1342
1343 fn connect_websocket(
1344 request: tungstenite::handshake::client::Request,
1345 deadline: Instant,
1346 ) -> Result<tungstenite::WebSocket<MaybeTlsStream<EstablishingStream>>, TransportError> {
1347 let host = request
1348 .uri()
1349 .host()
1350 .ok_or_else(|| transfer_error("realtime URL invalid", "request", None))?;
1351 let host = host.trim_start_matches('[').trim_end_matches(']');
1352 let port = match request.uri().scheme_str() {
1353 Some("ws") => request.uri().port_u16().unwrap_or(80),
1354 Some("wss") => request.uri().port_u16().unwrap_or(443),
1355 _ => return Err(transfer_error("realtime URL invalid", "request", None)),
1356 };
1357 let host = host.to_owned();
1360 let (resolved, addresses) = mpsc::sync_channel(1);
1361 std::thread::Builder::new()
1362 .name("syncular-dns".to_owned())
1363 .spawn(move || {
1364 let result = (host.as_str(), port)
1365 .to_socket_addrs()
1366 .map(|items| items.collect::<Vec<_>>());
1367 let _ = resolved.send(result);
1368 })
1369 .map_err(|e| io_err("realtime DNS failed", e))?;
1370 let addresses = addresses
1371 .recv_timeout(remaining(deadline).map_err(|e| io_err("realtime DNS failed", e))?)
1372 .map_err(|e| match e {
1373 mpsc::RecvTimeoutError::Timeout => {
1374 timeout_error("realtime DNS timed out", "timeout")
1375 }
1376 mpsc::RecvTimeoutError::Disconnected => {
1377 transfer_error("realtime DNS failed", "io", None)
1378 }
1379 })?
1380 .map_err(|e| io_err("realtime DNS failed", e))?;
1381 let mut last_error = io::Error::new(io::ErrorKind::NotFound, "DNS returned no addresses");
1382 let mut socket = None;
1383 for address in addresses {
1384 match TcpStream::connect_timeout(
1385 &address,
1386 remaining(deadline).map_err(|e| io_err("realtime TCP connect failed", e))?,
1387 ) {
1388 Ok(stream) => {
1389 socket = Some(stream);
1390 break;
1391 }
1392 Err(error) => last_error = error,
1393 }
1394 }
1395 let socket = socket.ok_or_else(|| io_err("realtime TCP connect failed", last_error))?;
1396 socket
1397 .set_nodelay(true)
1398 .map_err(|e| io_err("realtime TCP connect failed", e))?;
1399 let stream = EstablishingStream {
1400 socket,
1401 deadline: Some(deadline),
1402 };
1403 let (ws, _) = tungstenite::client_tls_with_config(request, stream, None, None).map_err(
1406 |e| match e {
1407 tungstenite::HandshakeError::Failure(error) => {
1408 ws_err("realtime connection failed", error)
1409 }
1410 tungstenite::HandshakeError::Interrupted(_) => {
1413 transfer_error("realtime connection failed", "protocol", None)
1414 }
1415 },
1416 )?;
1417 remaining(deadline).map_err(|e| io_err("realtime connection failed", e))?;
1418 Ok(ws)
1419 }
1420
1421 #[cfg(test)]
1422 mod establishment_tests {
1423 use super::*;
1424 use std::net::{Shutdown, TcpListener};
1425 use tungstenite::client::IntoClientRequest;
1426
1427 fn withheld_response(tls: bool, old: bool) {
1430 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1431 let url = format!(
1432 "{}://{}/realtime",
1433 if tls { "wss" } else { "ws" },
1434 listener.local_addr().unwrap()
1435 );
1436 let (accepted_tx, accepted_rx) = mpsc::channel();
1437 let (release_tx, release_rx) = mpsc::channel();
1438 let peer = std::thread::spawn(move || {
1439 let (mut stream, _) = listener.accept().unwrap();
1440 stream
1441 .set_read_timeout(Some(Duration::from_secs(2)))
1442 .unwrap();
1443 let mut bytes = [0; 4096];
1444 assert!(stream.read(&mut bytes).unwrap() > 0);
1445 accepted_tx.send(()).unwrap();
1446 release_rx.recv_timeout(Duration::from_secs(3)).unwrap();
1447 let _ = stream.shutdown(Shutdown::Both);
1448 });
1449 let (done_tx, done_rx) = mpsc::channel();
1450 let worker = std::thread::spawn(move || {
1451 let result = if old {
1452 tungstenite::connect(url)
1453 .map(|_| ())
1454 .map_err(|e| e.to_string())
1455 } else {
1456 connect_websocket(
1457 url.into_client_request().unwrap(),
1458 Instant::now() + Duration::from_millis(250),
1459 )
1460 .map(|_| ())
1461 .map_err(|e| format!("{}: {}", e.code, e.message))
1462 };
1463 done_tx.send(result).unwrap();
1464 });
1465 accepted_rx.recv_timeout(Duration::from_secs(2)).unwrap();
1466 if old {
1467 assert!(
1468 matches!(
1469 done_rx.recv_timeout(Duration::from_millis(500)),
1470 Err(mpsc::RecvTimeoutError::Timeout)
1471 ),
1472 "original connect unexpectedly had a deadline"
1473 );
1474 release_tx.send(()).unwrap();
1475 assert!(done_rx
1476 .recv_timeout(Duration::from_secs(2))
1477 .unwrap()
1478 .is_err());
1479 } else {
1480 let error = done_rx
1481 .recv_timeout(Duration::from_secs(2))
1482 .expect("establishment remained blocked")
1483 .unwrap_err();
1484 assert_eq!(error, "transport.timeout: realtime connection failed");
1485 eprintln!(
1486 "withheld {} produces {error}",
1487 if tls { "TLS" } else { "upgrade" }
1488 );
1489 release_tx.send(()).unwrap();
1490 }
1491 worker.join().unwrap();
1492 peer.join().unwrap();
1493 }
1494
1495 #[test]
1496 fn original_connect_blocks_on_withheld_upgrade_and_tls() {
1497 withheld_response(false, true);
1498 withheld_response(true, true);
1499 }
1500 #[test]
1501 fn establishment_deadline_errors_on_withheld_upgrade_and_tls() {
1502 withheld_response(false, false);
1503 withheld_response(true, false);
1504 }
1505
1506 #[test]
1507 fn slow_upgrade_bytes_cannot_renew_the_absolute_deadline() {
1508 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1509 let url = format!("ws://{}", listener.local_addr().unwrap());
1510 let peer = std::thread::spawn(move || {
1511 let (mut stream, _) = listener.accept().unwrap();
1512 let mut bytes = [0; 4096];
1513 let _ = stream.read(&mut bytes).unwrap();
1514 for byte in b"HTTP/1.1 101 Switching Protocols\r\n" {
1515 if stream.write_all(&[*byte]).is_err() {
1516 break;
1517 }
1518 std::thread::sleep(Duration::from_millis(40));
1519 }
1520 });
1521 let started = Instant::now();
1522 assert!(connect_websocket(
1523 url.into_client_request().unwrap(),
1524 started + Duration::from_millis(250)
1525 )
1526 .is_err());
1527 assert!(started.elapsed() < Duration::from_secs(1));
1528 peer.join().unwrap();
1529 }
1530
1531 #[test]
1532 fn saturated_tcp_accept_queue_returns_an_observable_error() {
1533 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1536 let address = listener.local_addr().unwrap();
1537 let mut queued = Vec::new();
1538 loop {
1539 match TcpStream::connect_timeout(&address, Duration::from_millis(50)) {
1540 Ok(stream) => queued.push(stream),
1541 Err(error) => {
1542 assert!(
1543 matches!(
1544 error.kind(),
1545 io::ErrorKind::TimedOut
1546 | io::ErrorKind::WouldBlock
1547 | io::ErrorKind::ConnectionRefused
1548 | io::ErrorKind::ConnectionReset
1549 ),
1550 "{error:?}"
1551 );
1552 break;
1553 }
1554 }
1555 assert!(queued.len() < 4096, "listener queue did not saturate");
1556 }
1557 let started = Instant::now();
1558 let url = format!("ws://{address}");
1559 let error = match connect_websocket(
1560 url.into_client_request().unwrap(),
1561 started + Duration::from_millis(250),
1562 ) {
1563 Err(error) => error,
1564 Ok(_) => panic!("full accept queue unexpectedly connected"),
1565 };
1566 assert!(
1567 (error.code == "transport.timeout" || error.code == "transport.failed")
1568 && (error.message == "realtime TCP connect failed"
1569 || error.message == "realtime connection failed"),
1570 "{}: {}",
1571 error.code,
1572 error.message
1573 );
1574 assert!(started.elapsed() < Duration::from_secs(2));
1575 }
1576 }
1577
1578 fn segment_error(mut error: TransportError) -> TransportError {
1582 if error.code == "transport.failed" {
1583 error.code = "sync.transport_failed".into();
1584 error.message = "segment transfer failed".into();
1585 }
1586 error
1587 }
1588
1589 fn is_would_block(e: &tungstenite::Error) -> bool {
1591 matches!(
1592 e,
1593 tungstenite::Error::Io(io) if matches!(
1594 io.kind(),
1595 std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
1596 )
1597 )
1598 }
1599
1600 pub struct NativeTransport {
1601 base_url: String,
1602 ws_url: String,
1603 headers: Vec<(String, String)>,
1605 base_url_has_userinfo: bool,
1609 agent: ureq::Agent,
1610 policy: HostTransportPolicy,
1611 round_deadline_at: Option<std::time::Instant>,
1615 pub signed_urls: bool,
1616 pub inbound: Arc<InboundBuffer>,
1617 outgoing: Option<Box<SocketSender>>,
1619 reader: Option<JoinHandle<()>>,
1620 reader_stop: Arc<AtomicBool>,
1621 round: Arc<RoundChannel>,
1623 realtime_client_id: Option<String>,
1624 }
1625
1626 #[derive(Debug)]
1633 struct IdleConnector(Option<Duration>);
1634
1635 impl Connector<Box<dyn UreqTransport>> for IdleConnector {
1636 type Out = Box<dyn UreqTransport>;
1637
1638 fn connect(
1639 &self,
1640 _: &ConnectionDetails,
1641 chained: Option<Box<dyn UreqTransport>>,
1642 ) -> Result<Option<Self::Out>, ureq::Error> {
1643 Ok(chained.map(|inner| match self.0 {
1644 Some(idle) => IdleTransport { inner, idle }.boxed(),
1645 None => inner,
1646 }))
1647 }
1648 }
1649
1650 #[derive(Debug)]
1651 struct IdleTransport {
1652 inner: Box<dyn UreqTransport>,
1653 idle: Duration,
1654 }
1655
1656 fn idle_bound(timeout: NextTimeout, idle: Duration) -> NextTimeout {
1659 let idle = UreqDuration::Exact(idle);
1660 if timeout.after.is_not_happening() || timeout.after.as_nanos() > idle.as_nanos() {
1661 NextTimeout {
1662 after: idle,
1663 reason: UreqTimeout::RecvBody,
1664 }
1665 } else {
1666 timeout
1667 }
1668 }
1669
1670 impl UreqTransport for IdleTransport {
1671 fn buffers(&mut self) -> &mut dyn ureq::unversioned::transport::Buffers {
1672 self.inner.buffers()
1673 }
1674 fn transmit_output(
1675 &mut self,
1676 amount: usize,
1677 timeout: NextTimeout,
1678 ) -> Result<(), ureq::Error> {
1679 self.inner
1680 .transmit_output(amount, idle_bound(timeout, self.idle))
1681 }
1682 fn await_input(&mut self, timeout: NextTimeout) -> Result<bool, ureq::Error> {
1683 self.inner.await_input(idle_bound(timeout, self.idle))
1684 }
1685 fn is_open(&mut self) -> bool {
1686 self.inner.is_open()
1687 }
1688 fn is_tls(&self) -> bool {
1689 self.inner.is_tls()
1690 }
1691 }
1692
1693 fn build_agent(policy: &HostTransportPolicy) -> ureq::Agent {
1694 let config = ureq::Agent::config_builder()
1695 .timeout_resolve(policy.request_timeout)
1696 .timeout_connect(policy.request_timeout)
1697 .timeout_send_request(policy.request_timeout)
1698 .timeout_recv_response(policy.request_timeout)
1699 .max_redirects(match policy.redirects {
1700 RedirectPolicy::Deny => 0,
1701 RedirectPolicy::Follow => 10,
1702 })
1703 .max_redirects_will_error(matches!(policy.redirects, RedirectPolicy::Follow))
1704 .redirect_auth_headers(ureq::config::RedirectAuthHeaders::Never)
1705 .http_status_as_error(false)
1710 .build();
1711 ureq::Agent::with_parts(
1712 config,
1713 DefaultConnector::new().chain(IdleConnector(policy.request_timeout)),
1714 DefaultResolver::default(),
1715 )
1716 }
1717
1718 fn derive_ws_url(base_url: &str) -> String {
1719 let ws = if let Some(rest) = base_url.strip_prefix("https://") {
1722 format!("wss://{rest}")
1723 } else if let Some(rest) = base_url.strip_prefix("http://") {
1724 format!("ws://{rest}")
1725 } else {
1726 base_url.to_owned()
1727 };
1728 let trimmed = ws.trim_end_matches('/');
1729 format!("{trimmed}/realtime")
1730 }
1731
1732 impl NativeTransport {
1733 pub fn fork_round(&self) -> Self {
1734 Self {
1735 base_url: self.base_url.clone(),
1736 ws_url: self.ws_url.clone(),
1737 headers: self.headers.clone(),
1738 base_url_has_userinfo: self.base_url_has_userinfo,
1739 agent: self.agent.clone(),
1740 policy: self.policy.clone(),
1741 round_deadline_at: None,
1742 signed_urls: self.signed_urls,
1743 inbound: self.inbound.clone(),
1744 outgoing: self.outgoing.clone(),
1745 reader: None,
1746 reader_stop: self.reader_stop.clone(),
1747 round: self.round.clone(),
1748 realtime_client_id: self.realtime_client_id.clone(),
1749 }
1750 }
1751
1752 pub fn new(
1753 base_url: &str,
1754 config: &serde_json::Value,
1755 notify: Option<Arc<dyn Fn() + Send + Sync>>,
1756 ) -> Result<Self, String> {
1757 let policy = HostTransportPolicy::from_config(config)?;
1758 Self::with_policy(base_url, config, policy, notify)
1759 }
1760
1761 pub fn with_policy(
1762 base_url: &str,
1763 config: &serde_json::Value,
1764 policy: HostTransportPolicy,
1765 notify: Option<Arc<dyn Fn() + Send + Sync>>,
1766 ) -> Result<Self, String> {
1767 policy.validate()?;
1768 let mut headers = Vec::new();
1769 if let Some(map) = config.get("headers").and_then(|v| v.as_object()) {
1770 for (k, v) in map {
1771 if let Some(s) = v.as_str() {
1772 headers.push((k.clone(), s.to_owned()));
1773 }
1774 }
1775 }
1776 let ws_url = config
1777 .get("wsUrl")
1778 .and_then(|v| v.as_str())
1779 .map(str::to_owned)
1780 .unwrap_or_else(|| derive_ws_url(base_url));
1781 let base_url_has_userinfo = url::Url::parse(base_url)
1784 .map(|url| !url.username().is_empty() || url.password().is_some())
1785 .unwrap_or(false);
1786 Ok(NativeTransport {
1787 base_url: base_url.trim_end_matches('/').to_owned(),
1788 ws_url,
1789 headers,
1790 base_url_has_userinfo,
1791 agent: build_agent(&policy),
1792 policy,
1793 round_deadline_at: None,
1794 signed_urls: false,
1795 inbound: Arc::new(match notify {
1796 Some(notify) => InboundBuffer::with_notify(notify),
1797 None => InboundBuffer::default(),
1798 }),
1799 outgoing: None,
1800 reader: None,
1801 reader_stop: Arc::new(AtomicBool::new(false)),
1802 round: Arc::new(RoundChannel::default()),
1803 realtime_client_id: None,
1804 })
1805 }
1806
1807 pub fn set_policy(&mut self, policy: HostTransportPolicy) -> Result<(), String> {
1811 policy.validate()?;
1812 self.agent = build_agent(&policy);
1813 self.policy = policy;
1814 Ok(())
1815 }
1816
1817 pub fn round_deadline(&self) -> Option<Duration> {
1818 self.policy.round_deadline
1819 }
1820
1821 pub fn set_round_deadline(&mut self, deadline: Option<std::time::Instant>) {
1822 self.round_deadline_at = deadline;
1823 }
1824
1825 fn round_remaining(&self) -> Result<Option<Duration>, TransportError> {
1830 match self.round_deadline_at {
1831 Some(deadline) => deadline
1832 .checked_duration_since(std::time::Instant::now())
1833 .map(Some)
1834 .ok_or_else(|| timeout_error("sync round deadline exceeded", "round_deadline")),
1835 None => Ok(None),
1836 }
1837 }
1838
1839 fn credentialed(&self) -> bool {
1842 !self.headers.is_empty() || self.base_url_has_userinfo
1843 }
1844
1845 fn finish_request<B>(
1851 &self,
1852 req: ureq::RequestBuilder<B>,
1853 credentialed: bool,
1854 ) -> Result<ureq::RequestBuilder<B>, TransportError> {
1855 let timeout = self.round_remaining()?;
1856 let follow = self.policy.redirects == RedirectPolicy::Follow && !credentialed;
1857 Ok(req
1858 .config()
1859 .timeout_global(timeout)
1860 .max_redirects(if follow { 10 } else { 0 })
1861 .max_redirects_will_error(true)
1862 .build())
1863 }
1864
1865 fn check_request_bytes(&self, len: u64) -> Result<(), TransportError> {
1867 match self.policy.max_request_bytes {
1868 Some(max) if len > max => {
1869 let mut error = TransportError::new(
1870 "transport.request_too_large",
1871 "request body exceeds the configured transport limit",
1872 );
1873 error.details = Some(serde_json::json!({ "limit": max, "size": len }));
1874 Err(error)
1875 }
1876 _ => Ok(()),
1877 }
1878 }
1879
1880 fn post_sync(&self, path: &str, body: &[u8]) -> Result<Vec<u8>, TransportError> {
1881 self.check_request_bytes(body.len() as u64)?;
1882 let credentialed = self.credentialed();
1883 let url = format!("{}{}", self.base_url, path);
1884 let mut req = self
1887 .agent
1888 .post(&url)
1889 .header("content-type", "application/vnd.syncular.sync.v2");
1890 for (k, v) in &self.headers {
1891 req = req.header(k.as_str(), v.as_str());
1892 }
1893 let req = self.finish_request(req, credentialed)?;
1894 let resp = req
1895 .send(body)
1896 .map_err(|e| http_err("sync request failed", e))?;
1897 read_body(resp, None, self.policy.max_response_bytes)
1898 }
1899
1900 fn post_operation(&self, body: &[u8]) -> Result<Vec<u8>, TransportError> {
1901 self.check_request_bytes(body.len() as u64)?;
1902 let credentialed = self.credentialed();
1903 let url = format!("{}/operations", self.base_url);
1904 let mut req = self.agent.post(&url).header(
1905 "content-type",
1906 "application/vnd.syncular.operations.v1+json",
1907 );
1908 for (key, value) in &self.headers {
1909 req = req.header(key.as_str(), value.as_str());
1910 }
1911 let req = self.finish_request(req, credentialed)?;
1912 let response = req
1913 .send(body)
1914 .map_err(|error| http_err("remote operation request failed", error))?;
1915 read_body(response, None, self.policy.max_response_bytes)
1916 }
1917
1918 fn get_bytes(&self, url: &str, with_headers: bool) -> Result<Vec<u8>, TransportError> {
1919 let mut req = self.agent.get(url);
1922 if with_headers {
1923 for (k, v) in &self.headers {
1924 req = req.header(k.as_str(), v.as_str());
1925 }
1926 }
1927 let req = self.finish_request(req, true)?;
1928 let resp = req.call().map_err(|e| http_err("URL fetch failed", e))?;
1929 read_body(resp, None, self.policy.max_response_bytes)
1930 }
1931
1932 pub fn set_headers(&mut self, headers: Vec<(String, String)>) {
1933 self.headers = headers;
1934 }
1935
1936 pub fn set_realtime_client_id(&mut self, client_id: Option<&str>) {
1937 self.realtime_client_id = client_id.map(str::to_owned);
1938 }
1939
1940 pub fn shutdown(&mut self) {
1941 self.reader_stop.store(true, Ordering::SeqCst);
1942 self.round.fail_in_flight(TransportError::new(
1945 "sync.transport_failed",
1946 "realtime disconnected mid-round (§8.7)",
1947 ));
1948 if let Some(outgoing) = &self.outgoing {
1949 let _ = outgoing.poller.notify();
1950 }
1951 if let Some(handle) = self.reader.take() {
1952 let _ = handle.join();
1953 }
1954 self.outgoing = None;
1955 }
1956
1957 fn send_message(&mut self, message: Message) -> Result<(), TransportError> {
1958 let (wait, round_bound) = match self.round_deadline_at {
1963 Some(deadline) => {
1964 match deadline.checked_duration_since(std::time::Instant::now()) {
1965 Some(remaining) if remaining < ROUND_IDLE_TIMEOUT => (remaining, true),
1966 Some(_) => (ROUND_IDLE_TIMEOUT, false),
1967 None => {
1970 return Err(timeout_error("realtime send timed out", "round_deadline"));
1971 }
1972 }
1973 }
1974 None => (ROUND_IDLE_TIMEOUT, false),
1975 };
1976 let result = (|| {
1977 let Some(outgoing) = &self.outgoing else {
1978 return Err(TransportError::new(
1979 "transport.failed",
1980 "realtime not connected",
1981 ));
1982 };
1983 let (completed, result) = mpsc::channel();
1984 outgoing
1985 .queue
1986 .try_send(Outgoing { message, completed })
1987 .map_err(|_| {
1988 TransportError::new("transport.failed", "realtime send queue unavailable")
1989 })?;
1990 outgoing
1991 .poller
1992 .notify()
1993 .map_err(|e| io_err("realtime wake failed", e))?;
1994 result.recv_timeout(wait).map_err(|_| {
1995 if round_bound {
1996 timeout_error("realtime send timed out", "round_deadline")
1997 } else {
1998 TransportError::new("transport.failed", "realtime send did not complete")
1999 }
2000 })?
2001 })();
2002 if let Err(error) = &result {
2005 self.shutdown();
2006 self.inbound.push(Inbound::Disconnected(error.clone()));
2009 }
2010 result
2011 }
2012 }
2013
2014 fn http_status_body_error(status: u16, body: &[u8]) -> TransportError {
2018 let mut error = TransportError::new("transport.failed", "HTTP request failed");
2019 let mut details = serde_json::Map::new();
2020 if let Ok(serde_json::Value::Object(parsed)) =
2021 serde_json::from_slice::<serde_json::Value>(body)
2022 {
2023 if let Some(code) = parsed.get("code").and_then(serde_json::Value::as_str) {
2024 if !code.is_empty() {
2025 error.code = code.to_owned();
2026 }
2027 }
2028 if let Some(message) = parsed.get("message").and_then(serde_json::Value::as_str) {
2029 error.message = message.to_owned();
2030 }
2031 for key in ["category", "recommendedAction"] {
2032 if let Some(value) = parsed.get(key).and_then(serde_json::Value::as_str) {
2033 details.insert(key.to_owned(), serde_json::Value::from(value));
2034 }
2035 }
2036 if let Some(retryable) = parsed.get("retryable").and_then(serde_json::Value::as_bool) {
2037 details.insert("retryable".to_owned(), serde_json::Value::from(retryable));
2038 }
2039 if let Some(serde_json::Value::Object(inner)) = parsed.get("details") {
2040 for (key, value) in inner {
2041 details.insert(key.clone(), value.clone());
2042 }
2043 }
2044 }
2045 details.insert("causeKind".to_owned(), serde_json::Value::from("status"));
2046 details.insert("httpStatus".to_owned(), serde_json::Value::from(status));
2047 error.details = Some(serde_json::Value::Object(details));
2048 error
2049 }
2050
2051 fn read_body(
2052 resp: ureq::http::Response<ureq::Body>,
2053 mut progress: Option<&mut dyn FnMut(u64)>,
2054 max: Option<u64>,
2055 ) -> Result<Vec<u8>, TransportError> {
2056 redirect_refused(&resp)?;
2057 use std::io::Read;
2058 let status = resp.status().as_u16();
2059 let mut reader = resp.into_body().into_with_config().reader();
2060 let mut bytes = Vec::new();
2061 let mut chunk = [0u8; 64 * 1024];
2062 let mut reported = 0;
2063 loop {
2064 let n = reader.read(&mut chunk).map_err(|error| {
2065 if is_timeout_io(&error) {
2066 timeout_error("response body read times out", "timeout")
2067 } else {
2068 transfer_error("response body read failed", "body", Some(status))
2069 }
2070 })?;
2071 if n == 0 {
2072 break;
2073 }
2074 if let Some(max) = max {
2077 if bytes.len() as u64 + n as u64 > max {
2078 return Err(response_too_large(max));
2079 }
2080 }
2081 bytes.extend_from_slice(&chunk[..n]);
2082 if bytes.len() - reported >= 64 * 1024 {
2083 if let Some(callback) = progress.as_mut() {
2084 callback(bytes.len() as u64);
2085 }
2086 reported = bytes.len();
2087 }
2088 }
2089 if bytes.len() != reported {
2090 if let Some(callback) = progress {
2091 callback(bytes.len() as u64);
2092 }
2093 }
2094 if status >= 400 {
2095 return Err(http_status_body_error(status, &bytes));
2096 }
2097 Ok(bytes)
2098 }
2099
2100 fn response_too_large(max: u64) -> TransportError {
2101 let mut error = TransportError::new(
2102 "transport.response_too_large",
2103 "response body exceeds the configured transport limit",
2104 );
2105 error.details = Some(serde_json::json!({ "limit": max }));
2106 error
2107 }
2108
2109 impl Transport for NativeTransport {
2110 fn sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
2111 self.post_sync("/sync", request)
2112 }
2113
2114 fn remote_operation(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
2115 self.post_operation(request)
2116 }
2117
2118 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
2119 if self.outgoing.is_none() {
2129 return self.post_sync("/sync", request);
2130 }
2131 self.round_remaining()?;
2132 let framed = self.round.begin(request)?;
2133 let (tag, body) = framed.split_first().expect("framed request carries a tag");
2134 for chunk in body.chunks(REQUEST_CHUNK_BYTES) {
2135 let mut message = Vec::with_capacity(chunk.len() + 1);
2136 message.push(*tag);
2137 message.extend_from_slice(chunk);
2138 if let Err(error) = self.send_message(Message::Binary(message.into())) {
2139 self.round.fail_in_flight(error);
2140 break;
2141 }
2142 self.round.progressed();
2143 }
2144 let outcome = self.round.wait(self.round_deadline_at, ROUND_IDLE_TIMEOUT);
2145 if let Err(error) = &outcome {
2150 if !self.round.lost() {
2151 self.shutdown();
2152 self.inbound.push(Inbound::Disconnected(error.clone()));
2153 }
2154 }
2155 outcome
2156 }
2157
2158 fn download_segment(
2159 &mut self,
2160 request: &SegmentRequest,
2161 on_progress: &mut dyn FnMut(u64),
2162 ) -> Result<Vec<u8>, TransportError> {
2163 let url = format!("{}/segments/{}", self.base_url, request.segment_id);
2170 let credentialed = self.credentialed();
2171 let mut req = self
2172 .agent
2173 .get(&url)
2174 .header("x-syncular-scopes", &request.requested_scopes_json);
2175 for (k, v) in &self.headers {
2176 req = req.header(k.as_str(), v.as_str());
2177 }
2178 let req = self.finish_request(req, credentialed)?;
2179 let resp = req
2180 .call()
2181 .map_err(|error| segment_error(http_err("segment request failed", error)))?;
2182 read_body(resp, Some(on_progress), self.policy.max_response_bytes)
2183 .map_err(segment_error)
2184 }
2185
2186 fn supports_url_fetch(&self) -> bool {
2187 self.signed_urls
2188 }
2189
2190 fn download_segments(
2191 &mut self,
2192 fetches: &[SegmentFetch],
2193 on_progress: &mut (dyn FnMut(usize, u64) + Send),
2194 ) -> Vec<Result<Vec<u8>, TransportError>> {
2195 let workers = (0..fetches.len().min(4))
2204 .map(|_| {
2205 let mut worker = self.fork_round();
2206 worker.round_deadline_at = self.round_deadline_at;
2207 worker
2208 })
2209 .collect::<Vec<_>>();
2210 let slots = fetches.iter().map(|_| Mutex::new(None)).collect::<Vec<_>>();
2211 let progress = Mutex::new(on_progress);
2212 let next = std::sync::atomic::AtomicUsize::new(0);
2213 std::thread::scope(|scope| {
2214 for worker in workers {
2215 let (slots, progress, next) = (&slots, &progress, &next);
2216 scope.spawn(move || {
2217 let mut worker = worker;
2218 loop {
2219 let index = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2220 let Some(fetch) = fetches.get(index) else {
2221 break;
2222 };
2223 let mut report = |bytes: u64| {
2224 (*progress.lock().expect("segment progress lock"))(index, bytes);
2225 };
2226 let result = match fetch {
2227 SegmentFetch::Direct(request) => {
2228 worker.download_segment(request, &mut report)
2229 }
2230 SegmentFetch::Url(url) => worker.fetch_url(url, &mut report),
2231 };
2232 *slots[index].lock().expect("segment result slot") = Some(result);
2233 }
2234 });
2235 }
2236 });
2237 slots
2238 .into_iter()
2239 .map(|slot| slot.into_inner().expect("segment result slot"))
2240 .map(|result| result.expect("every fetch is attempted"))
2241 .collect()
2242 }
2243
2244 fn fetch_url(
2245 &mut self,
2246 url: &str,
2247 on_progress: &mut dyn FnMut(u64),
2248 ) -> Result<Vec<u8>, TransportError> {
2249 let req = self.finish_request(self.agent.get(url), true)?;
2251 let resp = req
2252 .call()
2253 .map_err(|error| segment_error(http_err("segment request failed", error)))?;
2254 read_body(resp, Some(on_progress), self.policy.max_response_bytes)
2255 .map_err(segment_error)
2256 }
2257
2258 fn blob_upload(
2259 &mut self,
2260 blob_id: &str,
2261 bytes: &[u8],
2262 media_type: Option<&str>,
2263 ) -> Result<(), TransportError> {
2264 self.check_request_bytes(bytes.len() as u64)?;
2267 let credentialed = self.credentialed();
2268 let url = format!("{}/blobs/{}", self.base_url, blob_id);
2269 let mut req = self.agent.put(&url).header(
2270 "content-type",
2271 media_type.unwrap_or("application/octet-stream"),
2272 );
2273 for (k, v) in &self.headers {
2274 req = req.header(k.as_str(), v.as_str());
2275 }
2276 let req = self.finish_request(req, credentialed)?;
2277 let resp = req
2278 .send(bytes)
2279 .map_err(|e| http_err("blob upload failed", e))?;
2280 read_body(resp, None, self.policy.max_response_bytes)?;
2281 Ok(())
2282 }
2283
2284 fn blob_download(&mut self, blob_id: &str) -> Result<BlobDownload, TransportError> {
2285 let credentialed = self.credentialed();
2288 let url = format!("{}/blobs/{}", self.base_url, blob_id);
2289 let mut req = self.agent.get(&url);
2290 for (k, v) in &self.headers {
2291 req = req.header(k.as_str(), v.as_str());
2292 }
2293 let req = self.finish_request(req, credentialed)?;
2294 let resp = req.call().map_err(|cause| {
2295 let status = match &cause {
2296 ureq::Error::StatusCode(status) => Some(*status),
2297 _ => None,
2298 };
2299 let mut error = http_err("blob download failed", cause);
2300 match status {
2301 Some(404) => {
2302 error.code = "blob.not_found".into();
2303 error.message = "blob not found".into();
2304 }
2305 Some(403) => {
2306 error.code = "blob.forbidden".into();
2307 error.message = "blob download forbidden".into();
2308 }
2309 Some(401) => {
2310 error.code = "sync.auth_required".into();
2311 error.message = "host authentication required".into();
2312 }
2313 _ => {}
2314 }
2315 error
2316 })?;
2317 let is_json = resp
2320 .headers()
2321 .get(ureq::http::header::CONTENT_TYPE)
2322 .and_then(|value| value.to_str().ok())
2323 .is_some_and(|value| value.contains("application/json"));
2324 let body =
2325 read_body(resp, None, self.policy.max_response_bytes).map_err(|mut error| {
2326 let status = error
2330 .details
2331 .as_ref()
2332 .filter(|details| {
2333 details.get("causeKind").and_then(serde_json::Value::as_str)
2334 == Some("status")
2335 })
2336 .and_then(|details| details.get("httpStatus"))
2337 .and_then(serde_json::Value::as_u64);
2338 match status {
2339 Some(404) => {
2340 error.code = "blob.not_found".into();
2341 error.message = "blob not found".into();
2342 }
2343 Some(403) => {
2344 error.code = "blob.forbidden".into();
2345 error.message = "blob download forbidden".into();
2346 }
2347 Some(401) => {
2348 error.code = "sync.auth_required".into();
2349 error.message = "host authentication required".into();
2350 }
2351 Some(_) => {
2352 error.code = "transport.failed".into();
2353 error.message = "blob download failed".into();
2354 }
2355 None => {}
2356 }
2357 error
2358 })?;
2359 if is_json {
2360 if let Ok(parsed) = serde_json::from_slice::<serde_json::Value>(&body) {
2361 if let Some(u) = parsed.get("url").and_then(|v| v.as_str()) {
2362 return Ok(BlobDownload::Url {
2363 url: u.to_owned(),
2364 url_expires_at_ms: parsed
2365 .get("urlExpiresAtMs")
2366 .and_then(|v| v.as_i64()),
2367 });
2368 }
2369 }
2370 }
2371 Ok(BlobDownload::Bytes(body))
2372 }
2373
2374 fn fetch_blob_url(&mut self, url: &str) -> Result<Vec<u8>, TransportError> {
2375 self.get_bytes(url, false)
2377 }
2378
2379 fn blob_upload_grant(
2380 &mut self,
2381 blob_id: &str,
2382 byte_length: u64,
2383 media_type: Option<&str>,
2384 ) -> Result<BlobUploadGrant, TransportError> {
2385 let url = format!("{}/blobs/{}/upload-grant", self.base_url, blob_id);
2386 let body = serde_json::json!({
2387 "byteLength": byte_length,
2388 "mediaType": media_type,
2389 })
2390 .to_string();
2391 self.check_request_bytes(body.len() as u64)?;
2392 let credentialed = self.credentialed();
2393 let mut req = self
2394 .agent
2395 .post(&url)
2396 .header("content-type", "application/json");
2397 for (k, v) in &self.headers {
2398 req = req.header(k.as_str(), v.as_str());
2399 }
2400 let req = self.finish_request(req, credentialed)?;
2401 let resp = req
2402 .send(body)
2403 .map_err(|e| http_err("blob upload grant request failed", e))?;
2404 let grant_body = read_body(resp, None, self.policy.max_response_bytes)?;
2405 let parsed: serde_json::Value = serde_json::from_slice(&grant_body)
2406 .map_err(|_| transfer_error("blob upload grant decode failed", "decode", None))?;
2407 if let Some(u) = parsed.get("url").and_then(|v| v.as_str()) {
2408 return Ok(BlobUploadGrant::Url {
2409 url: u.to_owned(),
2410 url_expires_at_ms: parsed.get("urlExpiresAtMs").and_then(|v| v.as_i64()),
2411 });
2412 }
2413 if parsed.get("present").and_then(|v| v.as_bool()) == Some(true) {
2414 return Ok(BlobUploadGrant::Present);
2415 }
2416 Ok(BlobUploadGrant::None)
2417 }
2418
2419 fn blob_put_url(
2420 &mut self,
2421 url: &str,
2422 bytes: &[u8],
2423 media_type: Option<&str>,
2424 ) -> Result<(), TransportError> {
2425 self.check_request_bytes(bytes.len() as u64)?;
2427 let req = self.finish_request(
2428 self.agent.put(url).header(
2429 "content-type",
2430 media_type.unwrap_or("application/octet-stream"),
2431 ),
2432 true,
2433 )?;
2434 let resp = req
2435 .send(bytes)
2436 .map_err(|e| http_err("signed blob upload failed", e))?;
2437 read_body(resp, None, self.policy.max_response_bytes)?;
2438 Ok(())
2439 }
2440
2441 fn realtime_connect(&mut self) -> Result<(), TransportError> {
2442 if self.outgoing.is_some() {
2443 if self.reader_stop.load(Ordering::SeqCst)
2444 || self
2445 .reader
2446 .as_ref()
2447 .is_some_and(|reader| reader.is_finished())
2448 {
2449 self.shutdown();
2450 } else {
2451 return Ok(());
2452 }
2453 }
2454 use tungstenite::client::IntoClientRequest;
2460 let mut url = url::Url::parse(&self.ws_url)
2461 .map_err(|_| transfer_error("realtime URL invalid", "request", None))?;
2462 if let Some(client_id) = self.realtime_client_id.as_deref() {
2463 let retained_query: Vec<(String, String)> = url
2464 .query_pairs()
2465 .filter(|(key, _)| key != "clientId")
2466 .map(|(key, value)| (key.into_owned(), value.into_owned()))
2467 .collect();
2468 url.set_query(None);
2469 url.query_pairs_mut()
2470 .extend_pairs(retained_query)
2471 .append_pair("clientId", client_id);
2472 }
2473 let mut request = url
2474 .as_str()
2475 .into_client_request()
2476 .map_err(|error| ws_err("realtime request failed", error))?;
2477 {
2478 let out = request.headers_mut();
2479 for (k, v) in &self.headers {
2480 if let (Ok(name), Ok(value)) = (
2481 tungstenite::http::HeaderName::try_from(k.as_str()),
2482 tungstenite::http::HeaderValue::try_from(v.as_str()),
2483 ) {
2484 out.insert(name, value);
2485 }
2486 }
2487 }
2488 if self.policy.redirects == RedirectPolicy::Follow {
2491 return Err(redirect_error(
2492 "realtime establishment does not follow redirects",
2493 None,
2494 ));
2495 }
2496 let mut ws = connect_websocket(request, Instant::now() + CONNECT_TIMEOUT)?;
2497 let stream = match ws.get_mut() {
2498 MaybeTlsStream::Plain(stream) => stream.established(),
2499 MaybeTlsStream::Rustls(stream) => stream.get_mut().established(),
2500 _ => {
2501 return Err(TransportError::new(
2502 "transport.failed",
2503 "unsupported websocket stream",
2504 ))
2505 }
2506 }
2507 .map_err(|e| io_err("realtime clone failed", e))?;
2508 stream
2509 .set_nonblocking(true)
2510 .map_err(|e| io_err("realtime nonblocking failed", e))?;
2511 let poller = Arc::new(Poller::new().map_err(|e| io_err("realtime poller failed", e))?);
2512 unsafe { poller.add(&stream, Event::readable(0)) }
2514 .map_err(|e| io_err("realtime register failed", e))?;
2515 let registration = SocketRegistration {
2516 poller: Arc::clone(&poller),
2517 stream,
2518 };
2519 let (outgoing, queued) = mpsc::sync_channel::<Outgoing>(1);
2520 self.outgoing = Some(Box::new(SocketSender {
2521 queue: outgoing,
2522 poller,
2523 }));
2524 self.reader_stop = Arc::new(AtomicBool::new(false));
2525 self.round = Arc::new(RoundChannel::default());
2526 let inbound = Arc::clone(&self.inbound);
2527 let stop = Arc::clone(&self.reader_stop);
2528 let round = Arc::clone(&self.round);
2529 self.reader = Some(std::thread::spawn(move || {
2530 let mut events = Events::new();
2531 let mut pending: Option<mpsc::Sender<Result<(), TransportError>>> = None;
2532 let failure = 'io: loop {
2533 if stop.load(Ordering::SeqCst) {
2534 let _ = ws.close(None);
2535 let _ = ws.flush();
2536 break TransportError::new(
2537 "sync.transport_failed",
2538 "realtime disconnected mid-round (§8.7)",
2539 );
2540 }
2541 if pending.is_none() {
2542 if let Ok(outgoing) = queued.try_recv() {
2543 pending = Some(outgoing.completed);
2544 if let Err(error) = ws.write(outgoing.message) {
2546 if !is_would_block(&error) {
2547 break ws_err("realtime write failed", error);
2548 }
2549 }
2550 }
2551 }
2552 let mut drained = false;
2554 for _ in 0..64 {
2555 match ws.read() {
2556 Ok(Message::Text(text)) => {
2557 inbound.push(Inbound::Text(text.to_string()))
2558 }
2559 Ok(Message::Binary(bytes)) => {
2560 if let Some(delta) = round.route_binary(&bytes) {
2561 inbound.push(Inbound::Binary(delta));
2562 }
2563 }
2564 Err(error) if is_would_block(&error) => {
2565 drained = true;
2566 break;
2567 }
2568 Ok(Message::Close(_)) => {
2569 let _ = ws.flush();
2570 break 'io TransportError::new(
2571 "sync.transport_failed",
2572 "realtime disconnected mid-round (§8.7)",
2573 );
2574 }
2575 Err(_) => {
2576 break 'io TransportError::new(
2577 "sync.transport_failed",
2578 "realtime disconnected mid-round (§8.7)",
2579 );
2580 }
2581 Ok(_) => {}
2582 }
2583 }
2584 let interest = match ws.flush() {
2585 Ok(()) => {
2586 if let Some(completed) = pending.take() {
2587 let _ = completed.send(Ok(()));
2588 }
2589 Event::readable(0)
2590 }
2591 Err(error) if is_would_block(&error) => Event::all(0),
2592 Err(error) => break ws_err("realtime flush failed", error),
2593 };
2594 if !drained {
2595 continue;
2596 }
2597 if let Err(error) = registration.poller.modify(®istration.stream, interest) {
2598 break io_err("realtime rearm failed", error);
2599 }
2600 events.clear();
2601 if let Err(error) = registration.poller.wait(&mut events, None) {
2602 if error.kind() != std::io::ErrorKind::Interrupted {
2603 break io_err("realtime wait failed", error);
2604 }
2605 }
2606 };
2607 if !stop.load(Ordering::SeqCst) {
2612 inbound.push(Inbound::Disconnected(failure.clone()));
2613 }
2614 if let Some(completed) = pending {
2615 let _ = completed.send(Err(failure.clone()));
2616 }
2617 while let Ok(outgoing) = queued.try_recv() {
2618 let _ = outgoing.completed.send(Err(failure.clone()));
2619 }
2620 round.fail_in_flight(failure);
2621 }));
2622 Ok(())
2623 }
2624
2625 fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
2626 self.send_message(Message::Text(text.to_owned().into()))
2627 }
2628
2629 fn realtime_close(&mut self) -> Result<(), TransportError> {
2630 self.shutdown();
2631 Ok(())
2632 }
2633
2634 fn round_deadline(&self) -> Option<Duration> {
2635 NativeTransport::round_deadline(self)
2636 }
2637
2638 fn set_round_deadline(&mut self, deadline: Option<std::time::Instant>) {
2639 NativeTransport::set_round_deadline(self, deadline);
2640 }
2641 }
2642 #[cfg(test)]
2643 mod transfer_tests {
2644 use super::*;
2645 use ureq::unversioned::resolver::DefaultResolver;
2646 use ureq::unversioned::transport::{ConnectionDetails, Connector};
2647
2648 const SECRET: &str = "https://user:password@cdn.example/secret-capability-path?secret-query=value#secret-fragment";
2649
2650 #[derive(Debug)]
2651 struct SecretError;
2652 impl std::fmt::Display for SecretError {
2653 fn fmt(&self, _: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2654 panic!("transport error must never be formatted")
2655 }
2656 }
2657 impl std::error::Error for SecretError {}
2658
2659 #[derive(Debug)]
2660 struct FailingConnector(&'static str);
2661 impl Connector for FailingConnector {
2662 type Out = ();
2663 fn connect(
2664 &self,
2665 _: &ConnectionDetails,
2666 _: Option<()>,
2667 ) -> Result<Option<()>, ureq::Error> {
2668 Err(match self.0 {
2669 "io" => ureq::Error::Io(std::io::Error::new(
2670 std::io::ErrorKind::ConnectionRefused,
2671 format!("404 {SECRET}"),
2672 )),
2673 "tls" => ureq::Error::Tls(SECRET),
2674 "request" => ureq::Error::BadUri(SECRET.into()),
2675 "timeout" => ureq::Error::Timeout(ureq::Timeout::Global),
2676 "404" => ureq::Error::StatusCode(404),
2677 "401" => ureq::Error::StatusCode(401),
2678 "403" => ureq::Error::StatusCode(403),
2679 "503" => ureq::Error::StatusCode(503),
2680 _ => ureq::Error::Other(Box::new(SecretError)),
2681 })
2682 }
2683 }
2684
2685 #[test]
2686 fn injected_http_failures_never_expose_capabilities_or_format_the_cause() {
2687 for kind in [
2688 "io", "tls", "request", "timeout", "unknown", "404", "401", "403", "503",
2689 ] {
2690 for operation in [
2691 "sync",
2692 "remote",
2693 "segment",
2694 "signed_segment",
2695 "blob",
2696 "signed_blob",
2697 "upload",
2698 "grant",
2699 "signed_put",
2700 ] {
2701 let mut transport =
2702 NativeTransport::new("http://127.0.0.1", &serde_json::json!({}), None)
2703 .unwrap();
2704 transport.agent = ureq::Agent::with_parts(
2705 ureq::Agent::config_builder().build(),
2706 FailingConnector(kind),
2707 DefaultResolver::default(),
2708 );
2709 let mut progress = |_| {};
2710 let result = match operation {
2711 "sync" => transport.sync(&[]).map(|_| ()),
2712 "remote" => transport.remote_operation(&[]).map(|_| ()),
2713 "segment" => transport.download_segment(&SegmentRequest { segment_id: "sha256:test".into(), table: "tasks".into(), requested_scopes_json: "{}".into() }, &mut progress).map(|_| ()),
2714 "signed_segment" => transport.fetch_url("http://127.0.0.1/secret-capability-path?secret-query=value#secret-fragment", &mut progress).map(|_| ()),
2715 "blob" => transport.blob_download("sha256:test").map(|_| ()),
2716 "signed_blob" => transport.fetch_blob_url("http://127.0.0.1/secret-capability-path?secret-query=value#secret-fragment").map(|_| ()),
2717 "upload" => transport.blob_upload("sha256:test", &[], None),
2718 "grant" => transport.blob_upload_grant("sha256:test", 0, None).map(|_| ()),
2719 _ => transport.blob_put_url("http://127.0.0.1/secret-capability-path?secret-query=value#secret-fragment", &[], None),
2720 };
2721 let error = result.unwrap_err();
2722 let expected_code = match (operation, kind) {
2723 (_, "timeout") => "transport.timeout",
2724 ("segment" | "signed_segment", _) => "sync.transport_failed",
2725 ("blob", "404") => "blob.not_found",
2726 ("blob", "401") => "sync.auth_required",
2727 ("blob", "403") => "blob.forbidden",
2728 _ => "transport.failed",
2729 };
2730 assert_eq!(error.code, expected_code, "{operation} {kind}");
2731 let details = error.details.unwrap();
2732 assert_eq!(
2733 details["causeKind"],
2734 match kind {
2735 "io" => "connect",
2736 "404" | "401" | "403" | "503" => "status",
2737 other => other,
2738 }
2739 );
2740 assert_eq!(
2741 details.get("httpStatus").and_then(|value| value.as_u64()),
2742 kind.parse::<u64>().ok()
2743 );
2744 let rendered = format!("{}{}", error.message, details);
2745 for secret in [
2746 "http://",
2747 "https://",
2748 "password",
2749 "secret-capability-path",
2750 "secret-query",
2751 "secret-fragment",
2752 ] {
2753 assert!(!rendered.contains(secret), "{operation} {kind}");
2754 }
2755 assert!(details.get("causeMessage").is_none());
2756 assert!(details.get("path").is_none());
2757 }
2758 }
2759 }
2760
2761 #[test]
2762 fn injected_body_and_websocket_errors_do_not_expose_payloads_or_source_chains() {
2763 struct FailingRead;
2764 impl std::io::Read for FailingRead {
2765 fn read(&mut self, _: &mut [u8]) -> std::io::Result<usize> {
2766 Err(std::io::Error::other(SECRET))
2767 }
2768 }
2769 let response = ureq::http::Response::builder()
2770 .status(200)
2771 .body(ureq::Body::builder().reader(FailingRead))
2772 .unwrap();
2773 let error = segment_error(read_body(response, None, None).unwrap_err());
2774 assert_eq!(error.code, "sync.transport_failed");
2775 assert_eq!(error.message, "segment transfer failed");
2776 assert_eq!(
2777 error.details,
2778 Some(serde_json::json!({ "causeKind": "body", "httpStatus": 200 }))
2779 );
2780 for cause in [
2781 tungstenite::Error::Io(std::io::Error::other(SecretError)),
2782 tungstenite::Error::Http(Box::new(
2783 tungstenite::http::Response::builder()
2784 .status(403)
2785 .header("location", SECRET)
2786 .body(Some(SECRET.as_bytes().to_vec()))
2787 .unwrap(),
2788 )),
2789 ] {
2790 let error = ws_err("realtime connection failed", cause);
2791 assert_eq!(error.message, "realtime connection failed");
2792 let details = error.details.unwrap();
2793 assert!(details.get("path").is_none());
2794 assert!(details.get("causeMessage").is_none());
2795 assert!(!details.to_string().contains("secret"));
2796 }
2797 }
2798
2799 #[test]
2800 fn body_read_timeouts_are_classified_through_ureq_wrapping() {
2801 let direct = ureq::Error::Timeout(ureq::Timeout::Global).into_io();
2803 assert!(is_timeout_io(&direct));
2804 let wrapped = ureq::Error::Decompress(
2806 "gzip",
2807 ureq::Error::Timeout(ureq::Timeout::Global).into_io(),
2808 )
2809 .into_io();
2810 assert!(is_timeout_io(&wrapped));
2811 assert!(is_timeout_io(&std::io::Error::new(
2813 std::io::ErrorKind::TimedOut,
2814 "plain"
2815 )));
2816 assert!(!is_timeout_io(&std::io::Error::other("other")));
2817 assert!(!is_timeout_io(&ureq::Error::StatusCode(500).into_io()));
2818 }
2819
2820 #[test]
2821 fn body_read_wrapped_timeouts_surface_the_typed_code() {
2822 struct TimeoutRead(bool);
2823 impl std::io::Read for TimeoutRead {
2824 fn read(&mut self, _: &mut [u8]) -> std::io::Result<usize> {
2825 Err(if self.0 {
2826 ureq::Error::Timeout(ureq::Timeout::Global).into_io()
2827 } else {
2828 ureq::Error::Decompress(
2829 "gzip",
2830 ureq::Error::Timeout(ureq::Timeout::Global).into_io(),
2831 )
2832 .into_io()
2833 })
2834 }
2835 }
2836 for compressed in [false, true] {
2837 let response = ureq::http::Response::builder()
2838 .status(200)
2839 .body(ureq::Body::builder().reader(TimeoutRead(compressed)))
2840 .unwrap();
2841 let error = read_body(response, None, None).unwrap_err();
2842 assert_eq!(error.code, "transport.timeout", "{compressed}");
2843 assert_eq!(
2844 error.details,
2845 Some(serde_json::json!({ "causeKind": "timeout" }))
2846 );
2847 }
2848 }
2849
2850 #[test]
2851 fn policy_validation_rejects_zero_and_unrepresentable_bounds() {
2852 assert_eq!(
2853 HostTransportPolicy {
2854 request_timeout: Some(Duration::ZERO),
2855 ..Default::default()
2856 }
2857 .validate()
2858 .unwrap_err(),
2859 "sync.invalid_request: transport config requestTimeoutMs is out of range"
2860 );
2861 assert_eq!(
2862 HostTransportPolicy {
2863 round_deadline: Some(Duration::MAX),
2864 ..Default::default()
2865 }
2866 .validate()
2867 .unwrap_err(),
2868 "sync.invalid_request: transport config roundDeadlineMs is out of range"
2869 );
2870 assert!(HostTransportPolicy {
2871 max_request_bytes: Some(0),
2872 ..Default::default()
2873 }
2874 .validate()
2875 .is_err());
2876 assert!(HostTransportPolicy {
2877 max_response_bytes: Some(0),
2878 ..Default::default()
2879 }
2880 .validate()
2881 .is_err());
2882 assert!(HostTransportPolicy::default().validate().is_ok());
2885 assert_eq!(
2886 HostTransportPolicy::from_config(&serde_json::json!({ "requestTimeoutMs": 0 }))
2887 .unwrap_err(),
2888 "sync.invalid_request: transport config requestTimeoutMs must be a positive integer"
2889 );
2890 }
2891
2892 #[test]
2893 fn set_policy_rejects_invalid_and_preserves_the_current_policy() {
2894 let mut transport =
2895 NativeTransport::new("http://127.0.0.1", &serde_json::json!({}), None).unwrap();
2896 assert_eq!(transport.policy.max_response_bytes, None);
2897 let error = transport
2898 .set_policy(HostTransportPolicy {
2899 max_response_bytes: Some(0),
2900 ..Default::default()
2901 })
2902 .unwrap_err();
2903 assert_eq!(
2904 error,
2905 "sync.invalid_request: transport config maxResponseBytes must be a positive integer"
2906 );
2907 assert_eq!(transport.policy.max_response_bytes, None);
2908 }
2909
2910 #[test]
2911 fn an_expired_round_deadline_is_typed_and_cleared_for_the_next_round() {
2912 let mut transport =
2913 NativeTransport::new("http://127.0.0.1:9", &serde_json::json!({}), None).unwrap();
2914 transport.set_round_deadline(Some(std::time::Instant::now() - Duration::from_secs(1)));
2915 let error = transport.blob_download("sha256:test").unwrap_err();
2916 assert_eq!(error.code, "transport.timeout");
2917 assert_eq!(
2918 error.details,
2919 Some(serde_json::json!({ "causeKind": "round_deadline" }))
2920 );
2921 transport.set_round_deadline(None);
2923 assert!(transport.round_remaining().is_ok());
2924 }
2925
2926 #[test]
2927 fn an_expired_round_deadline_refuses_before_queuing_a_realtime_send() {
2928 let mut transport =
2929 NativeTransport::new("http://127.0.0.1:9", &serde_json::json!({}), None).unwrap();
2930 transport.set_round_deadline(Some(std::time::Instant::now() - Duration::from_secs(1)));
2931 let error = transport.realtime_send("blocked").unwrap_err();
2935 assert_eq!(error.code, "transport.timeout");
2936 assert_eq!(
2937 error.details,
2938 Some(serde_json::json!({ "causeKind": "round_deadline" }))
2939 );
2940 transport.set_round_deadline(None);
2943 let error = transport.realtime_send("blocked").unwrap_err();
2944 assert_eq!(error.code, "transport.failed");
2945 }
2946
2947 #[test]
2948 fn the_request_timeout_bounds_phases_and_the_round_deadline_bounds_the_call() {
2949 #[derive(Debug)]
2950 struct TimeoutRecording(Arc<Mutex<Vec<Option<Duration>>>>);
2951 impl Connector for TimeoutRecording {
2952 type Out = ();
2953 fn connect(
2954 &self,
2955 details: &ConnectionDetails,
2956 _: Option<()>,
2957 ) -> Result<Option<()>, ureq::Error> {
2958 self.0
2959 .lock()
2960 .unwrap()
2961 .push(details.config.timeouts().global);
2962 Err(ureq::Error::ConnectionFailed)
2963 }
2964 }
2965 let mut transport =
2966 NativeTransport::new("http://127.0.0.1", &serde_json::json!({}), None).unwrap();
2967 transport
2968 .set_policy(HostTransportPolicy {
2969 request_timeout: Some(Duration::from_millis(1234)),
2970 ..Default::default()
2971 })
2972 .unwrap();
2973 let timeouts = transport.agent.config().timeouts();
2974 for phase in [
2975 timeouts.resolve,
2976 timeouts.connect,
2977 timeouts.send_request,
2978 timeouts.recv_response,
2979 ] {
2980 assert_eq!(phase, Some(Duration::from_millis(1234)));
2981 }
2982 assert_eq!(timeouts.global, None, "the request bound is not end to end");
2983 assert_eq!(timeouts.recv_body, None, "a body is bounded by silence");
2984 let seen = Arc::new(Mutex::new(Vec::new()));
2985 transport.agent = ureq::Agent::with_parts(
2986 ureq::Agent::config_builder().build(),
2987 TimeoutRecording(Arc::clone(&seen)),
2988 DefaultResolver::default(),
2989 );
2990 let _ = transport.blob_upload("sha256:test", &[0u8; 4], None);
2991 transport.set_round_deadline(Some(std::time::Instant::now() + Duration::from_secs(60)));
2992 let _ = transport.blob_upload("sha256:test", &[0u8; 4], None);
2993 let seen = seen.lock().unwrap();
2994 assert_eq!(seen[0], None, "no round deadline, no call ceiling");
2995 assert!(seen[1].is_some_and(|ceiling| ceiling <= Duration::from_secs(60)));
2996 }
2997
2998 #[test]
2999 fn the_idle_bound_replaces_an_unbounded_or_longer_wait_and_keeps_a_shorter_one() {
3000 let idle = Duration::from_secs(30);
3001 let unbounded = NextTimeout {
3002 after: UreqDuration::NotHappening,
3003 reason: UreqTimeout::Global,
3004 };
3005 assert_eq!(idle_bound(unbounded, idle).after, UreqDuration::Exact(idle));
3006 let longer = NextTimeout {
3007 after: UreqDuration::from_secs(90),
3008 reason: UreqTimeout::Global,
3009 };
3010 assert_eq!(idle_bound(longer, idle).after, UreqDuration::Exact(idle));
3011 let shorter = NextTimeout {
3012 after: UreqDuration::from_secs(5),
3013 reason: UreqTimeout::Global,
3014 };
3015 assert_eq!(idle_bound(shorter, idle), shorter);
3016 }
3017
3018 fn shaped_link(
3023 total: u64,
3024 chunk: u64,
3025 latency: Duration,
3026 rate: u64,
3027 stalls: &[(Duration, Duration)],
3028 ) -> Vec<Duration> {
3029 let mut at = latency;
3030 let mut arrivals = Vec::new();
3031 let mut left = total;
3032 while left > 0 {
3033 let piece = left.min(chunk);
3034 at += Duration::from_micros(piece * 1_000_000 / rate);
3035 for (start, length) in stalls {
3036 if at > *start && at < *start + *length {
3037 at = *start + *length;
3038 }
3039 }
3040 arrivals.push(at);
3041 left -= piece;
3042 }
3043 arrivals
3044 }
3045
3046 fn round_over(
3050 arrivals: &[Duration],
3051 idle: Duration,
3052 ceiling: Option<Duration>,
3053 ) -> Result<Duration, (Duration, bool)> {
3054 let base = Instant::now();
3055 let mut last = base;
3056 for arrival in arrivals {
3057 let (deadline, ceiling_binds) =
3058 round_expiry(last, idle, ceiling.map(|ceiling| base + ceiling));
3059 if base + *arrival > deadline {
3060 return Err((deadline - base, ceiling_binds));
3061 }
3062 last = base + *arrival;
3063 }
3064 Ok(last - base)
3065 }
3066
3067 #[test]
3068 fn a_slow_link_that_keeps_delivering_never_trips_the_idle_bound() {
3069 let link = shaped_link(
3071 8_600_000,
3072 64 * 1024,
3073 Duration::from_millis(80),
3074 100_000,
3075 &[],
3076 );
3077 let finished = round_over(&link, ROUND_IDLE_TIMEOUT, None).unwrap();
3078 assert!(
3079 finished > ROUND_IDLE_TIMEOUT * 2,
3080 "the transfer outlasts the old whole-transfer bound ({finished:?})"
3081 );
3082 }
3083
3084 #[test]
3085 fn a_stall_shorter_than_the_idle_bound_is_survived_and_a_longer_one_is_not() {
3086 let link = |length| {
3087 shaped_link(
3088 4_000_000,
3089 64 * 1024,
3090 Duration::from_millis(80),
3091 100_000,
3092 &[(Duration::from_secs(10), length)],
3093 )
3094 };
3095 round_over(&link(Duration::from_secs(29)), ROUND_IDLE_TIMEOUT, None).unwrap();
3096 let (expired, ceiling_bound) =
3097 round_over(&link(Duration::from_secs(31)), ROUND_IDLE_TIMEOUT, None).unwrap_err();
3098 assert!(!ceiling_bound);
3099 assert!(
3100 expired >= Duration::from_secs(30) && expired < Duration::from_secs(41),
3101 "the bound counts from the last chunk before the stall ({expired:?})"
3102 );
3103 }
3104
3105 #[test]
3106 fn the_host_ceiling_still_bounds_a_link_that_keeps_delivering() {
3107 let link = shaped_link(
3108 8_600_000,
3109 64 * 1024,
3110 Duration::from_millis(80),
3111 100_000,
3112 &[],
3113 );
3114 let (expired, ceiling_bound) =
3115 round_over(&link, ROUND_IDLE_TIMEOUT, Some(Duration::from_secs(60))).unwrap_err();
3116 assert!(ceiling_bound);
3117 assert_eq!(expired, Duration::from_secs(60));
3118 }
3119
3120 #[test]
3121 fn an_expired_round_deadline_bounds_the_socket_wait() {
3122 let channel = RoundChannel::default();
3123 let error = channel
3124 .wait(
3125 Some(std::time::Instant::now() - Duration::from_secs(1)),
3126 ROUND_IDLE_TIMEOUT,
3127 )
3128 .unwrap_err();
3129 assert_eq!(error.code, "transport.timeout");
3130 assert_eq!(
3131 error.details,
3132 Some(serde_json::json!({ "causeKind": "round_deadline" }))
3133 );
3134 }
3135 }
3136}
3137
3138#[cfg(all(test, feature = "native-transport"))]
3139mod tests {
3140 use super::*;
3141 use std::io::{BufRead, BufReader, Write};
3142
3143 #[test]
3144 fn detached_round_uses_the_owned_socket_and_shutdown_releases_it() {
3145 use ssp2::model::{Frame, Message, MsgKind};
3146 use std::sync::mpsc::channel;
3147 for cancelled in [false, true] {
3148 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
3149 let base = format!("http://{}", listener.local_addr().unwrap());
3150 let (entered_tx, entered_rx) = channel();
3151 let (release_tx, release_rx) = channel();
3152 let server = std::thread::spawn(move || {
3153 let (socket, _) = listener.accept().unwrap();
3154 let mut socket = tungstenite::accept(socket).unwrap();
3155 let request = socket.read().unwrap().into_data();
3156 assert_eq!(request[0], crate::REALTIME_TAG_ROUND);
3157 assert_eq!(
3158 ssp2::decode_message(&request[1..]).unwrap().msg_kind,
3159 MsgKind::Request
3160 );
3161 entered_tx.send(()).unwrap();
3162 release_rx.recv().unwrap();
3163 if !cancelled {
3164 let response = ssp2::encode_message(&Message {
3165 wire_version: ssp2::decode::WIRE_VERSION,
3166 msg_kind: MsgKind::Response,
3167 frames: vec![Frame::RespHeader {
3168 required_schema_version: None,
3169 latest_schema_version: None,
3170 log_epoch: Some("epoch".into()),
3171 reset_required: Some(true),
3172 }],
3173 });
3174 let mut chunk = vec![crate::REALTIME_TAG_ROUND];
3175 chunk.extend(response);
3176 socket
3177 .send(tungstenite::Message::Binary(chunk.into()))
3178 .unwrap();
3179 }
3180 });
3181 let mut owner =
3182 HostTransport::new_from_config(&serde_json::json!({"baseUrl":base})).unwrap();
3183 owner.realtime_connect_for_client("detached-round").unwrap();
3184 let mut detached = owner.fork_round();
3185 let network = std::thread::spawn(move || {
3186 let request = ssp2::encode_message(&Message {
3187 wire_version: ssp2::decode::WIRE_VERSION,
3188 msg_kind: MsgKind::Request,
3189 frames: vec![
3190 Frame::ReqHeader {
3191 client_id: "detached-round".into(),
3192 schema_version: 1,
3193 log_epoch: None,
3194 },
3195 Frame::PullHeader {
3196 limit_commits: 0,
3197 limit_snapshot_rows: 0,
3198 max_snapshot_pages: 0,
3199 accept: 3,
3200 },
3201 ],
3202 });
3203 detached.realtime_sync(&request)
3204 });
3205 entered_rx.recv().unwrap();
3206 owner.set_headers(vec![("authorization".into(), "Bearer rotated".into())]);
3207 assert!(owner.take_inbound().is_empty());
3208 if cancelled {
3209 owner.shutdown();
3210 }
3211 release_tx.send(()).unwrap();
3212 let response = network.join().unwrap();
3213 if cancelled {
3214 assert!(response.is_err());
3215 } else {
3216 assert_eq!(
3217 ssp2::decode_message(&response.unwrap()).unwrap().msg_kind,
3218 MsgKind::Response
3219 );
3220 owner.shutdown();
3221 }
3222 server.join().unwrap();
3223 }
3224 }
3225
3226 fn minimal_response() -> Vec<u8> {
3227 use ssp2::model::{Frame, Message, MsgKind};
3228 ssp2::encode_message(&Message {
3229 wire_version: ssp2::decode::WIRE_VERSION,
3230 msg_kind: MsgKind::Response,
3231 frames: vec![Frame::RespHeader {
3232 required_schema_version: None,
3233 latest_schema_version: None,
3234 log_epoch: Some("epoch".into()),
3235 reset_required: Some(true),
3236 }],
3237 })
3238 }
3239
3240 #[test]
3241 fn a_stalled_response_body_times_out_on_silence() {
3242 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
3243 let base = format!("http://{}", listener.local_addr().unwrap());
3244 let server = std::thread::spawn(move || {
3245 let (mut socket, _) = listener.accept().unwrap();
3246 let mut reader = BufReader::new(socket.try_clone().unwrap());
3247 loop {
3248 let mut line = String::new();
3249 reader.read_line(&mut line).unwrap();
3250 if line == "\r\n" {
3251 break;
3252 }
3253 }
3254 socket
3255 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 1000\r\n\r\n0123456789")
3256 .unwrap();
3257 let mut rest = Vec::new();
3259 let _ = std::io::Read::read_to_end(&mut reader, &mut rest);
3260 });
3261 let mut transport = HostTransport::new_from_config(
3262 &serde_json::json!({"baseUrl": base, "requestTimeoutMs": 200}),
3263 )
3264 .unwrap();
3265 let mut received = 0;
3266 let error = transport
3267 .download_segment(
3268 &SegmentRequest {
3269 segment_id: "sha256:test".into(),
3270 table: "tasks".into(),
3271 requested_scopes_json: "{}".into(),
3272 },
3273 &mut |bytes| received = bytes,
3274 )
3275 .unwrap_err();
3276 assert_eq!(error.code, "transport.timeout");
3277 assert_eq!(
3278 error.details,
3279 Some(serde_json::json!({ "causeKind": "timeout" }))
3280 );
3281 server.join().unwrap();
3282 }
3283
3284 #[test]
3285 fn a_timed_out_socket_round_closes_the_socket_and_reports_the_loss() {
3286 use std::sync::mpsc::channel;
3287 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
3288 let base = format!("http://{}", listener.local_addr().unwrap());
3289 let (closed_tx, closed_rx) = channel();
3290 let server = std::thread::spawn(move || {
3291 let (socket, _) = listener.accept().unwrap();
3292 let mut socket = tungstenite::accept(socket).unwrap();
3293 socket.read().unwrap();
3294 let response = minimal_response();
3296 let mut chunk = vec![crate::REALTIME_TAG_ROUND];
3297 chunk.extend_from_slice(&response[..response.len() / 2]);
3298 socket
3299 .send(tungstenite::Message::Binary(chunk.into()))
3300 .unwrap();
3301 while let Ok(message) = socket.read() {
3303 if matches!(message, tungstenite::Message::Close(_)) {
3304 break;
3305 }
3306 }
3307 closed_tx.send(()).unwrap();
3308 });
3309 let mut transport =
3310 HostTransport::new_from_config(&serde_json::json!({"baseUrl": base})).unwrap();
3311 transport.realtime_connect_for_client("slow").unwrap();
3312 transport.set_round_deadline(Some(
3313 std::time::Instant::now() + std::time::Duration::from_millis(300),
3314 ));
3315 let error = transport.realtime_sync(b"request").unwrap_err();
3316 assert_eq!(error.code, "transport.timeout");
3317 closed_rx.recv().unwrap();
3318 let lost = transport
3319 .take_inbound()
3320 .into_iter()
3321 .filter(|inbound| matches!(inbound, Inbound::Disconnected(e) if e.code == "transport.timeout"))
3322 .count();
3323 assert_eq!(lost, 1, "the loss is reported once");
3324 server.join().unwrap();
3325 }
3326
3327 #[test]
3328 fn a_request_larger_than_a_chunk_rides_as_several_round_chunks() {
3329 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
3330 let base = format!("http://{}", listener.local_addr().unwrap());
3331 let request: Vec<u8> = (0..3 * 64 * 1024 + 5).map(|i| (i % 251) as u8).collect();
3332 let expected = request.clone();
3333 let server = std::thread::spawn(move || {
3334 let (socket, _) = listener.accept().unwrap();
3335 let mut socket = tungstenite::accept(socket).unwrap();
3336 let mut body = Vec::new();
3337 let mut messages = 0;
3338 while body.len() < expected.len() {
3339 let message = socket.read().unwrap().into_data();
3340 assert_eq!(message[0], crate::REALTIME_TAG_ROUND);
3341 body.extend_from_slice(&message[1..]);
3342 messages += 1;
3343 }
3344 assert_eq!(body, expected);
3345 assert_eq!(messages, 4);
3346 let mut chunk = vec![crate::REALTIME_TAG_ROUND];
3347 chunk.extend(minimal_response());
3348 socket
3349 .send(tungstenite::Message::Binary(chunk.into()))
3350 .unwrap();
3351 });
3352 let mut transport =
3353 HostTransport::new_from_config(&serde_json::json!({"baseUrl": base})).unwrap();
3354 transport.realtime_connect_for_client("chunked").unwrap();
3355 let response = transport.realtime_sync(&request).unwrap();
3356 assert_eq!(response, minimal_response());
3357 transport.shutdown();
3358 server.join().unwrap();
3359 }
3360
3361 #[test]
3362 fn segment_http_failures_have_named_codes_and_safe_cause_details() {
3363 for signed in [false, true] {
3364 for body_failure in [false, true] {
3365 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
3366 let base = format!("http://{}", listener.local_addr().unwrap());
3367 let server = std::thread::spawn(move || {
3368 let (mut socket, _) = listener.accept().unwrap();
3369 let mut reader = BufReader::new(socket.try_clone().unwrap());
3370 loop {
3371 let mut line = String::new();
3372 reader.read_line(&mut line).unwrap();
3373 if line == "\r\n" {
3374 break;
3375 }
3376 assert!(!line.is_empty());
3377 }
3378 socket.write_all(if body_failure {
3379 b"HTTP/1.1 200 OK\r\nContent-Length: 999\r\nConnection: close\r\n\r\nshort"
3380 } else {
3381 b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
3382 }).unwrap();
3383 });
3384 let mut transport =
3385 HostTransport::new_from_config(&serde_json::json!({"baseUrl": base})).unwrap();
3386 let mut progress = |_| {};
3387 let error = if signed {
3388 transport.fetch_url(&format!("{base}/signed?signature=secret"), &mut progress)
3389 } else {
3390 transport.download_segment(
3391 &SegmentRequest {
3392 segment_id: "sha256:test".into(),
3393 table: "tasks".into(),
3394 requested_scopes_json: "{}".into(),
3395 },
3396 &mut progress,
3397 )
3398 }
3399 .unwrap_err();
3400 assert_eq!(error.code, "sync.transport_failed");
3401 assert_eq!(error.message, "segment transfer failed");
3402 let details = error.details.unwrap();
3403 assert!(details.get("path").is_none());
3404 assert!(details.get("causeMessage").is_none());
3405 assert_eq!(
3406 details["causeKind"],
3407 if body_failure { "body" } else { "status" }
3408 );
3409 assert_eq!(details["httpStatus"], if body_failure { 200 } else { 403 });
3410 server.join().unwrap();
3411 }
3412 }
3413 }
3414
3415 #[test]
3416 fn blob_status_classification_uses_the_actual_http_response() {
3417 for (status, expected) in [
3418 (404, "blob.not_found"),
3419 (401, "sync.auth_required"),
3420 (403, "blob.forbidden"),
3421 (500, "transport.failed"),
3422 ] {
3423 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
3424 let base = format!("http://{}", listener.local_addr().unwrap());
3425 let server = std::thread::spawn(move || {
3426 let (mut socket, _) = listener.accept().unwrap();
3427 let mut reader = BufReader::new(socket.try_clone().unwrap());
3428 loop {
3429 let mut line = String::new();
3430 reader.read_line(&mut line).unwrap();
3431 if line == "\r\n" {
3432 break;
3433 }
3434 assert!(!line.is_empty());
3435 }
3436 let body = r#"{"code":"blob.not_found","message":"https://host/secret-path?secret-query#secret-fragment","retryable":false}"#;
3437 write!(socket, "HTTP/1.1 {status} Error\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).unwrap();
3438 });
3439 let mut transport =
3440 HostTransport::new_from_config(&serde_json::json!({"baseUrl":base})).unwrap();
3441 let error = transport.blob_download("sha256:test").unwrap_err();
3442 assert_eq!(error.code, expected);
3443 assert_eq!(
3444 error.details,
3445 Some(serde_json::json!({
3446 "causeKind": "status",
3447 "httpStatus": status,
3448 "retryable": false
3449 }))
3450 );
3451 assert!(!error.message.contains("secret"));
3452 assert!(!error.message.contains("http"));
3453 server.join().unwrap();
3454 }
3455 }
3456
3457 #[test]
3458 fn refused_segment_connection_has_a_named_code_without_an_invented_status() {
3459 for signed in [false, true] {
3460 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
3461 let base = format!("http://{}", listener.local_addr().unwrap());
3462 drop(listener);
3463 let mut transport =
3464 HostTransport::new_from_config(&serde_json::json!({"baseUrl": base})).unwrap();
3465 let mut progress = |_| {};
3466 let error = if signed {
3467 transport.fetch_url(&format!("{base}/signed?signature=secret"), &mut progress)
3468 } else {
3469 transport.download_segment(
3470 &SegmentRequest {
3471 segment_id: "sha256:test".into(),
3472 table: "tasks".into(),
3473 requested_scopes_json: "{}".into(),
3474 },
3475 &mut progress,
3476 )
3477 }
3478 .unwrap_err();
3479 assert_eq!(error.code, "sync.transport_failed");
3480 assert!(error.details.as_ref().unwrap().get("httpStatus").is_none());
3481 }
3482 }
3483
3484 #[test]
3485 fn segment_http_reports_intermediate_bytes_for_direct_and_signed_urls() {
3486 for signed in [false, true] {
3487 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
3488 let base = format!("http://{}", listener.local_addr().unwrap());
3489 let server = std::thread::spawn(move || {
3490 let (mut socket, _) = listener.accept().unwrap();
3491 let mut reader = BufReader::new(socket.try_clone().unwrap());
3492 let mut headers = String::new();
3493 loop {
3494 let mut line = String::new();
3495 reader.read_line(&mut line).unwrap();
3496 if line == "\r\n" {
3497 break;
3498 }
3499 assert!(!line.is_empty());
3500 headers.push_str(&line.to_ascii_lowercase());
3501 }
3502 assert_eq!(headers.contains("authorization: bearer test"), !signed);
3503 assert_eq!(headers.contains("x-syncular-scopes:"), !signed);
3504 let bytes = vec![7; 128 * 1024 + 17];
3505 write!(
3506 socket,
3507 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
3508 bytes.len()
3509 )
3510 .unwrap();
3511 socket.write_all(&bytes).unwrap();
3512 });
3513 let mut transport = HostTransport::from_config(
3514 &serde_json::json!({"baseUrl": base, "headers": {"authorization": "Bearer test"}}),
3515 )
3516 .unwrap();
3517 let mut updates = Vec::new();
3518 let mut progress = |bytes| updates.push(bytes);
3519 let bytes = if signed {
3520 transport
3521 .fetch_url(&format!("{base}/signed"), &mut progress)
3522 .unwrap()
3523 } else {
3524 transport
3525 .download_segment(
3526 &SegmentRequest {
3527 segment_id: "sha256:test".into(),
3528 table: "tasks".into(),
3529 requested_scopes_json: "{}".into(),
3530 },
3531 &mut progress,
3532 )
3533 .unwrap()
3534 };
3535 assert_eq!(bytes.len(), 128 * 1024 + 17);
3536 assert!(updates.iter().any(|n| *n > 0 && *n < bytes.len() as u64));
3537 assert_eq!(updates.last(), Some(&(bytes.len() as u64)));
3538 assert!(updates.windows(2).all(|pair| pair[0] < pair[1]));
3539 server.join().unwrap();
3540 }
3541 }
3542
3543 #[test]
3544 fn request_over_cap_is_refused_before_network() {
3545 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
3546 listener.set_nonblocking(true).unwrap();
3547 let base = format!("http://{}", listener.local_addr().unwrap());
3548 let mut transport =
3549 HostTransport::from_config(&serde_json::json!({"baseUrl": base, "maxRequestBytes": 4}))
3550 .unwrap();
3551 let error = transport
3552 .blob_upload("sha256:test", &[0u8; 8], None)
3553 .unwrap_err();
3554 assert_eq!(error.code, "transport.request_too_large");
3555 assert_eq!(
3556 error.details,
3557 Some(serde_json::json!({ "limit": 4, "size": 8 }))
3558 );
3559 assert!(
3560 matches!(listener.accept(), Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock),
3561 "the request cap is checked before any connection"
3562 );
3563 }
3564
3565 #[test]
3566 fn credential_bearing_redirects_are_refused_without_contacting_the_destination() {
3567 for credentials in ["headers", "userinfo", "password"] {
3568 for redirects in ["deny", "follow"] {
3569 let destination = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
3570 destination.set_nonblocking(true).unwrap();
3571 let location = format!("http://{}/landed", destination.local_addr().unwrap());
3572 let origin = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
3573 let host = origin.local_addr().unwrap();
3574 let (base, headers) = match credentials {
3575 "headers" => (
3576 format!("http://{host}"),
3577 serde_json::json!({"authorization": "Bearer secret"}),
3578 ),
3579 "userinfo" => (format!("http://user:secret@{host}"), serde_json::json!({})),
3580 _ => (format!("http://:secret@{host}"), serde_json::json!({})),
3581 };
3582 let server = std::thread::spawn(move || {
3583 let (mut socket, _) = origin.accept().unwrap();
3584 let mut reader = BufReader::new(socket.try_clone().unwrap());
3585 loop {
3586 let mut line = String::new();
3587 reader.read_line(&mut line).unwrap();
3588 if line == "\r\n" {
3589 break;
3590 }
3591 }
3592 let _ = write!(
3593 socket,
3594 "HTTP/1.1 302 Found\r\nLocation: {location}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
3595 );
3596 });
3597 let mut transport = HostTransport::from_config(&serde_json::json!({
3598 "baseUrl": base,
3599 "redirects": redirects,
3600 "headers": headers,
3601 }))
3602 .unwrap();
3603 let error = transport.blob_download("sha256:test").unwrap_err();
3604 assert_eq!(
3605 error.code, "transport.redirect",
3606 "{credentials} {redirects}"
3607 );
3608 let details = error.details.unwrap();
3609 assert_eq!(details["causeKind"], "redirect");
3610 assert_eq!(details["httpStatus"], 302);
3611 assert!(
3612 matches!(destination.accept(), Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock),
3613 "{credentials} {redirects}: the destination is never contacted"
3614 );
3615 server.join().unwrap();
3616 }
3617 }
3618 }
3619
3620 #[test]
3621 fn websocket_handshake_redirects_obey_the_redirect_policy() {
3622 for credential in ["headers", "userinfo", "query", "none"] {
3629 let redirects = "deny";
3630 let destination = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
3631 let destination_addr = destination.local_addr().unwrap();
3632 let location = format!("ws://{destination_addr}/realtime");
3633 let landed = Arc::new(Mutex::new(Vec::new()));
3637 let landed_writer = Arc::clone(&landed);
3638 let destination_thread = std::thread::spawn(move || loop {
3639 let (mut socket, _) = destination.accept().unwrap();
3640 let mut reader = BufReader::new(socket.try_clone().unwrap());
3641 let mut head = String::new();
3642 loop {
3643 let mut line = String::new();
3644 reader.read_line(&mut line).unwrap();
3645 if line == "\r\n" {
3646 break;
3647 }
3648 head.push_str(&line);
3649 }
3650 let sentinel = head.starts_with("GET /sentinel");
3651 landed_writer.lock().unwrap().push(head);
3652 let _ = write!(
3653 socket,
3654 "HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
3655 );
3656 if sentinel {
3657 break;
3658 }
3659 });
3660 let origin = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
3661 let origin_addr = origin.local_addr().unwrap();
3662 let (headers, ws_url) = match credential {
3663 "headers" => (serde_json::json!({"authorization": "Bearer secret"}), None),
3664 "userinfo" => (
3665 serde_json::json!({}),
3666 Some(format!("ws://user:secret@{origin_addr}/realtime")),
3667 ),
3668 "query" => (
3669 serde_json::json!({}),
3670 Some(format!("ws://{origin_addr}/realtime?token=secret")),
3671 ),
3672 _ => (serde_json::json!({}), None),
3673 };
3674 let (head_tx, head_rx) = std::sync::mpsc::channel();
3675 let origin_thread = std::thread::spawn(move || {
3676 let (mut socket, _) = origin.accept().unwrap();
3677 let mut reader = BufReader::new(socket.try_clone().unwrap());
3678 let mut head = String::new();
3679 loop {
3680 let mut line = String::new();
3681 reader.read_line(&mut line).unwrap();
3682 if line == "\r\n" {
3683 break;
3684 }
3685 head.push_str(&line);
3686 }
3687 let _ = head_tx.send(head);
3688 let _ = write!(
3689 socket,
3690 "HTTP/1.1 302 Found\r\nLocation: {location}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
3691 );
3692 });
3693 let mut config = serde_json::json!({
3694 "baseUrl": format!("http://{origin_addr}"),
3695 "headers": headers,
3696 "redirects": redirects,
3697 });
3698 if let Some(ws_url) = &ws_url {
3699 config["wsUrl"] = serde_json::json!(ws_url);
3700 }
3701 let mut transport = HostTransport::from_config(&config).unwrap();
3702 let result = transport.realtime_connect();
3703 let mut sentinel = std::net::TcpStream::connect(destination_addr).unwrap();
3706 let _ = write!(sentinel, "GET /sentinel HTTP/1.1\r\nHost: sentinel\r\n\r\n");
3707 destination_thread.join().unwrap();
3708 let landed = landed.lock().unwrap().clone();
3709 origin_thread.join().unwrap();
3710 let error = result.unwrap_err();
3711 assert_eq!(
3712 error.code, "transport.redirect",
3713 "{credential}: a refused handshake redirect surfaces the redirect code"
3714 );
3715 let details = error.details.unwrap();
3716 assert_eq!(details["causeKind"], "redirect");
3717 assert_eq!(details["httpStatus"], 302);
3718 assert_eq!(
3719 landed.len(),
3720 1,
3721 "{credential}: the redirect target is never contacted: {landed:?}"
3722 );
3723 assert!(landed[0].starts_with("GET /sentinel"), "{landed:?}");
3724 let head = head_rx.recv().unwrap();
3725 if credential == "userinfo" {
3726 assert!(
3727 !head.contains("secret"),
3728 "the handshake request line drops URL userinfo"
3729 );
3730 }
3731 if credential == "query" {
3732 assert!(
3733 head.contains("token=secret"),
3734 "the handshake request line carries the realtime query"
3735 );
3736 }
3737 }
3738 }
3739
3740 const GZIP_BOMB: [u8; 548] = [
3743 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xed, 0xc9, 0xb1, 0x11, 0x80,
3744 0x20, 0x10, 0x00, 0xb0, 0x59, 0x1c, 0x80, 0xa1, 0xfe, 0x90, 0xb3, 0x41, 0xf0, 0x7c, 0x2d,
3745 0xd8, 0xde, 0x09, 0xdc, 0x20, 0x69, 0x93, 0x6b, 0xd4, 0xb7, 0xc7, 0x5d, 0xb2, 0x1d, 0x67,
3746 0x1b, 0x4f, 0xb9, 0x62, 0xf5, 0x19, 0xfb, 0x96, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
3747 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
3748 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
3749 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
3750 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
3751 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
3752 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
3753 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
3754 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
3755 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
3756 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
3757 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
3758 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
3759 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
3760 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
3761 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
3762 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
3763 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
3764 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
3765 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
3766 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
3767 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
3768 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
3769 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
3770 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
3771 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
3772 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
3773 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
3774 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
3775 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
3776 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
3777 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
3778 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0xf8, 0x8b, 0x0f,
3779 0xdb, 0x82, 0xf4, 0xa9, 0x40, 0x0d, 0x03, 0x00,
3780 ];
3781
3782 #[test]
3783 fn decoded_response_cap_counts_decompressed_bytes() {
3784 for cap in [200000u64, 199999] {
3785 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
3786 let base = format!("http://{}", listener.local_addr().unwrap());
3787 let server = std::thread::spawn(move || {
3788 let (mut socket, _) = listener.accept().unwrap();
3789 let mut reader = BufReader::new(socket.try_clone().unwrap());
3790 loop {
3791 let mut line = String::new();
3792 reader.read_line(&mut line).unwrap();
3793 if line == "\r\n" {
3794 break;
3795 }
3796 }
3797 let _ = write!(
3798 socket,
3799 "HTTP/1.1 200 OK\r\nContent-Encoding: gzip\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
3800 GZIP_BOMB.len()
3801 );
3802 let _ = socket.write_all(&GZIP_BOMB);
3803 });
3804 let mut transport = HostTransport::from_config(
3805 &serde_json::json!({"baseUrl": base, "maxResponseBytes": cap}),
3806 )
3807 .unwrap();
3808 let result = transport.fetch_blob_url(&format!("{base}/signed"));
3809 if cap == 200000 {
3810 let body = result.expect("a cap equal to the decoded size admits the body");
3811 assert_eq!(body.len(), 200000);
3812 } else {
3813 let error = result.unwrap_err();
3814 assert_eq!(error.code, "transport.response_too_large");
3815 assert_eq!(error.details, Some(serde_json::json!({ "limit": cap })));
3816 }
3817 server.join().unwrap();
3818 }
3819 }
3820
3821 #[test]
3822 fn a_json_http_error_body_reaches_the_caller() {
3823 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
3824 let base = format!("http://{}", listener.local_addr().unwrap());
3825 let body = r#"{"code":"sync.invalid_client_id","category":"invalid-request","retryable":false,"recommendedAction":"resetClientId","message":"clientId is bound to a different actor in this partition (§1.5)","details":{"actorId":"actor-b"}}"#;
3826 let server = std::thread::spawn(move || {
3827 let (mut socket, _) = listener.accept().unwrap();
3828 let mut reader = BufReader::new(socket.try_clone().unwrap());
3829 loop {
3830 let mut line = String::new();
3831 reader.read_line(&mut line).unwrap();
3832 if line == "\r\n" {
3833 break;
3834 }
3835 }
3836 let _ = write!(
3837 socket,
3838 "HTTP/1.1 400 Bad Request\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
3839 body.len(),
3840 body
3841 );
3842 });
3843 let mut transport =
3844 HostTransport::new_from_config(&serde_json::json!({"baseUrl": base})).unwrap();
3845 let error = transport.sync(&[]).unwrap_err();
3846 assert_eq!(error.code, "sync.invalid_client_id");
3847 assert_eq!(
3848 error.message,
3849 "clientId is bound to a different actor in this partition (§1.5)"
3850 );
3851 let details = error.details.unwrap();
3852 assert_eq!(details["recommendedAction"], "resetClientId");
3853 assert_eq!(details["category"], "invalid-request");
3854 assert_eq!(details["retryable"], false);
3855 assert_eq!(details["httpStatus"], 400);
3856 assert_eq!(details["actorId"], "actor-b");
3857 server.join().unwrap();
3858 }
3859}
3860
3861#[cfg(test)]
3862mod local_authority_tests {
3863 use super::HostTransport;
3864 use crate::Transport;
3865
3866 #[cfg(not(feature = "local-authority"))]
3867 #[test]
3868 fn a_local_authority_path_without_the_feature_fails_with_the_unconfigured_code() {
3869 let error = HostTransport::from_config(
3870 &serde_json::json!({ "localAuthorityPath": "authority.sqlite" }),
3871 )
3872 .err()
3873 .expect("a build without local-authority refuses localAuthorityPath");
3874 assert!(
3875 error.starts_with("client.local_authority_unconfigured:"),
3876 "{error}"
3877 );
3878 }
3879
3880 #[test]
3881 fn a_config_without_a_local_authority_reports_none() {
3882 let mut transport = HostTransport::from_config(&serde_json::json!({}))
3883 .ok()
3884 .unwrap();
3885 assert_eq!(transport.local_authority_state(), None);
3886 let error = transport.attach_local_authority().unwrap_err();
3887 assert_eq!(error.code, "client.local_authority_unconfigured");
3888 let error = transport.abandon_local_authority_attach().unwrap_err();
3889 assert_eq!(error.code, "client.local_authority_unconfigured");
3890 }
3891
3892 #[cfg(feature = "local-authority")]
3893 mod with_feature {
3894 use serde_json::{json, Map, Value};
3895 use syncular_authority::LocalAuthority;
3896
3897 use super::super::{HostTransport, LocalAuthorityTransport};
3898 use crate::{
3899 ClientLimits, Mutation, SegmentFetch, SegmentRequest, SubscriptionRequest, SyncClient,
3900 SyncOutcome, Transport, TransportError,
3901 };
3902
3903 fn schema() -> Value {
3904 json!({
3905 "version": 1,
3906 "tables": [{
3907 "name": "todos",
3908 "columns": [
3909 { "name": "id", "type": "string", "nullable": false },
3910 { "name": "list_id", "type": "string", "nullable": false }
3911 ],
3912 "primaryKey": "id",
3913 "scopes": [{ "pattern": "list:{list_id}", "column": "list_id" }]
3914 }]
3915 })
3916 }
3917
3918 struct AuthorityServer {
3921 server: LocalAuthority,
3922 syncs: usize,
3923 }
3924
3925 impl Transport for AuthorityServer {
3926 fn sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
3927 self.syncs += 1;
3928 self.server.sync(request).map_err(|error| TransportError {
3929 code: error.code,
3930 message: error.message,
3931 details: error.details,
3932 })
3933 }
3934 fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
3935 Err(TransportError::new("sync.transport_failed", "no socket"))
3936 }
3937 fn download_segment(
3938 &mut self,
3939 _request: &SegmentRequest,
3940 _on_progress: &mut dyn FnMut(u64),
3941 ) -> Result<Vec<u8>, TransportError> {
3942 Err(TransportError::new("sync.transport_failed", "no segments"))
3943 }
3944 fn realtime_connect(&mut self) -> Result<(), TransportError> {
3945 Err(TransportError::new("sync.transport_failed", "no socket"))
3946 }
3947 fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
3948 Ok(())
3949 }
3950 fn realtime_close(&mut self) -> Result<(), TransportError> {
3951 Ok(())
3952 }
3953 }
3954
3955 #[test]
3956 fn a_configured_path_routes_to_the_authority_and_refuses_what_it_lacks() {
3957 let mut transport =
3958 HostTransport::from_config(&json!({ "localAuthorityPath": ":memory:" }))
3959 .ok()
3960 .unwrap();
3961 assert!(matches!(transport, HostTransport::Local(_)));
3962 assert_eq!(transport.local_authority_state(), None);
3964 assert_eq!(
3965 transport.sync(b"").unwrap_err().code,
3966 "client.local_authority_unconfigured"
3967 );
3968 assert_eq!(
3969 transport
3970 .realtime_connect_for_client("device-1")
3971 .unwrap_err()
3972 .code,
3973 "sync.invalid_request"
3974 );
3975 let refused = transport.download_segments(
3976 &[SegmentFetch::Url("https://cdn.example.com/s1".to_owned())],
3977 &mut |_, _| {},
3978 );
3979 assert_eq!(refused.len(), 1);
3980 assert_eq!(
3981 refused[0].as_ref().unwrap_err().code,
3982 "sync.invalid_request"
3983 );
3984 let error = transport.attach_local_authority().unwrap_err();
3986 assert_eq!(error.code, "client.local_authority_unconfigured");
3987 }
3988
3989 #[test]
3990 fn the_authority_path_must_differ_from_the_replica_and_be_a_string() {
3991 let mut transport =
3992 HostTransport::from_config(&json!({ "localAuthorityPath": "/data/app.sqlite" }))
3993 .ok()
3994 .unwrap();
3995 let error = transport
3996 .open_local_authority(&schema(), Some("/data/app.sqlite"))
3997 .unwrap_err();
3998 assert_eq!(error.code, "sync.invalid_request");
3999 for path in [json!(""), json!(7)] {
4000 assert!(
4001 HostTransport::from_config(&json!({ "localAuthorityPath": path })).is_err()
4002 );
4003 }
4004 }
4005
4006 #[test]
4007 fn rounds_reach_the_authority_until_attach_then_the_remote_server() {
4008 let schema = schema();
4009 let remote = AuthorityServer {
4010 server: LocalAuthority::open(":memory:", &schema).expect("open the remote"),
4011 syncs: 0,
4012 };
4013 let mut transport = LocalAuthorityTransport::new(":memory:", remote, true);
4014 transport
4015 .open_local_authority(&schema, None)
4016 .expect("open the local authority");
4017 assert_eq!(transport.local_authority_state(), Some("local"));
4018
4019 let mut client = SyncClient::new_with_identity(
4020 Some("device-1".to_owned()),
4021 &schema,
4022 ClientLimits::default(),
4023 )
4024 .expect("client");
4025 client
4026 .subscribe_many(vec![SubscriptionRequest {
4027 id: "todos".to_owned(),
4028 table: "todos".to_owned(),
4029 scopes: vec![("list_id".to_owned(), vec!["l1".to_owned()])],
4030 params: None,
4031 }])
4032 .expect("subscribe");
4033 client
4034 .mutate(vec![Mutation::Upsert {
4035 table: "todos".to_owned(),
4036 values: Map::from_iter([
4037 ("id".to_owned(), json!("t1")),
4038 ("list_id".to_owned(), json!("l1")),
4039 ]),
4040 base_version: None,
4041 }])
4042 .expect("mutate");
4043 let outcome = client.sync_until_idle(&mut transport, None);
4044 assert!(
4045 matches!(outcome, SyncOutcome::Ok(_)),
4046 "{:?}",
4047 outcome.to_json()
4048 );
4049 assert!(client.pending_commit_ids().is_empty());
4050 assert_eq!(
4051 transport.remote.syncs, 0,
4052 "the local authority served the round"
4053 );
4054 let error = client.connect_realtime(&mut transport).unwrap_err();
4055 assert!(error.starts_with("sync.invalid_request:"), "{error}");
4056
4057 transport.attach_local_authority().expect("attach");
4058 assert_eq!(transport.local_authority_state(), Some("attached"));
4059 let uploads = transport.remote.syncs;
4060 assert!(uploads > 0, "attach uploaded through the remote transport");
4061
4062 let outcome = client.sync_until_idle(&mut transport, None);
4065 assert!(
4066 matches!(outcome, SyncOutcome::Ok(_)),
4067 "{:?}",
4068 outcome.to_json()
4069 );
4070 assert!(transport.remote.syncs > uploads);
4071 let rows = client.read_rows("todos").expect("rows");
4072 assert_eq!(rows.len(), 1);
4073 assert_eq!(rows[0].row_id, "t1");
4074 assert!(rows[0].version >= 1);
4075 }
4076 }
4077}