1use std::sync::{Arc, Mutex};
20use std::time::Duration;
21
22use crate::{
23 BlobDownload, BlobUploadGrant, SegmentFetch, SegmentRequest, Transport, TransportError,
24};
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
28pub enum RedirectPolicy {
29 #[default]
34 Deny,
35 Follow,
45}
46
47#[derive(Debug, Clone, Default)]
50pub struct HostTransportPolicy {
51 pub request_timeout: Option<Duration>,
53 pub round_deadline: Option<Duration>,
58 pub max_request_bytes: Option<u64>,
61 pub max_response_bytes: Option<u64>,
64 pub redirects: RedirectPolicy,
65}
66
67impl HostTransportPolicy {
68 pub fn validate(&self) -> Result<(), String> {
72 let now = std::time::Instant::now();
73 if let Some(timeout) = self.request_timeout {
74 if timeout.is_zero() || now.checked_add(timeout).is_none() {
75 return Err(
76 "sync.invalid_request: transport config requestTimeoutMs is out of range"
77 .to_owned(),
78 );
79 }
80 }
81 if let Some(deadline) = self.round_deadline {
82 if deadline.is_zero() || now.checked_add(deadline).is_none() {
83 return Err(
84 "sync.invalid_request: transport config roundDeadlineMs is out of range"
85 .to_owned(),
86 );
87 }
88 }
89 if self.max_request_bytes == Some(0) {
90 return Err(
91 "sync.invalid_request: transport config maxRequestBytes must be a positive integer"
92 .to_owned(),
93 );
94 }
95 if self.max_response_bytes == Some(0) {
96 return Err("sync.invalid_request: transport config maxResponseBytes must be a positive integer".to_owned());
97 }
98 Ok(())
99 }
100
101 pub fn from_config(config: &serde_json::Value) -> Result<Self, String> {
104 let bound = |key: &str, message: &'static str| -> Result<Option<u64>, String> {
105 match config.get(key) {
106 None | Some(serde_json::Value::Null) => Ok(None),
107 Some(value) => match value.as_u64() {
108 Some(number) if number >= 1 => Ok(Some(number)),
109 _ => Err(message.to_owned()),
110 },
111 }
112 };
113 let redirects = match config.get("redirects") {
114 None | Some(serde_json::Value::Null) => RedirectPolicy::Deny,
115 Some(serde_json::Value::String(value)) if value == "deny" => RedirectPolicy::Deny,
116 Some(serde_json::Value::String(value)) if value == "follow" => RedirectPolicy::Follow,
117 Some(_) => return Err(
118 "sync.invalid_request: transport config redirects must be \"deny\" or \"follow\""
119 .to_owned(),
120 ),
121 };
122 let policy = Self {
123 request_timeout: bound(
124 "requestTimeoutMs",
125 "sync.invalid_request: transport config requestTimeoutMs must be a positive integer",
126 )?
127 .map(Duration::from_millis),
128 round_deadline: bound(
129 "roundDeadlineMs",
130 "sync.invalid_request: transport config roundDeadlineMs must be a positive integer",
131 )?
132 .map(Duration::from_millis),
133 max_request_bytes: bound(
134 "maxRequestBytes",
135 "sync.invalid_request: transport config maxRequestBytes must be a positive integer",
136 )?,
137 max_response_bytes: bound(
138 "maxResponseBytes",
139 "sync.invalid_request: transport config maxResponseBytes must be a positive integer",
140 )?,
141 redirects,
142 };
143 policy.validate()?;
144 Ok(policy)
145 }
146}
147
148pub enum Inbound {
150 Text(String),
151 Binary(Vec<u8>),
152}
153
154pub struct InboundBuffer {
158 frames: Mutex<Vec<Inbound>>,
159 notify: Option<Arc<dyn Fn() + Send + Sync>>,
160}
161
162impl Default for InboundBuffer {
163 fn default() -> Self {
164 Self {
165 frames: Mutex::new(Vec::new()),
166 notify: None,
167 }
168 }
169}
170
171impl InboundBuffer {
172 pub fn with_notify(notify: Arc<dyn Fn() + Send + Sync>) -> Self {
173 Self {
174 frames: Mutex::new(Vec::new()),
175 notify: Some(notify),
176 }
177 }
178
179 pub fn push(&self, frame: Inbound) {
180 self.frames.lock().expect("inbound lock").push(frame);
181 if let Some(notify) = &self.notify {
182 notify();
183 }
184 }
185 fn take(&self) -> Vec<Inbound> {
186 std::mem::take(&mut *self.frames.lock().expect("inbound lock"))
187 }
188}
189
190#[allow(clippy::large_enum_variant)]
193pub enum HostTransport {
194 Null {
196 signed_urls: bool,
197 inbound: Arc<InboundBuffer>,
198 },
199 #[cfg(feature = "native-transport")]
200 Native(native::NativeTransport),
201}
202
203impl HostTransport {
204 pub fn from_config(config: &serde_json::Value) -> Result<Self, String> {
207 Self::new_from_config(config)
208 }
209
210 pub fn new_from_config(config: &serde_json::Value) -> Result<Self, String> {
213 Self::from_config_with_notify(config, None)
214 }
215
216 pub fn from_config_with_notify(
220 config: &serde_json::Value,
221 notify: Option<Arc<dyn Fn() + Send + Sync>>,
222 ) -> Result<Self, String> {
223 let policy = HostTransportPolicy::from_config(config)?;
224 Self::from_config_with_policy(config, policy, notify)
225 }
226
227 pub fn from_config_with_policy(
230 config: &serde_json::Value,
231 policy: HostTransportPolicy,
232 notify: Option<Arc<dyn Fn() + Send + Sync>>,
233 ) -> Result<Self, String> {
234 #[cfg(feature = "native-transport")]
235 {
236 if let Some(base_url) = config.get("baseUrl").and_then(|v| v.as_str()) {
237 return Ok(HostTransport::Native(native::NativeTransport::with_policy(
238 base_url, config, policy, notify,
239 )?));
240 }
241 }
242 #[cfg(not(feature = "native-transport"))]
243 {
244 policy.validate()?;
245 if config.get("baseUrl").is_some() {
246 return Err(
247 "this build has no native transport (rebuild with --features native-transport)"
248 .to_owned(),
249 );
250 }
251 }
252 Ok(HostTransport::Null {
253 signed_urls: false,
254 inbound: Arc::new(match notify {
255 Some(notify) => InboundBuffer::with_notify(notify),
256 None => InboundBuffer::default(),
257 }),
258 })
259 }
260
261 pub fn fork_round(&self) -> Self {
264 match self {
265 Self::Null {
266 signed_urls,
267 inbound,
268 } => Self::Null {
269 signed_urls: *signed_urls,
270 inbound: inbound.clone(),
271 },
272 #[cfg(feature = "native-transport")]
273 Self::Native(t) => Self::Native(t.fork_round()),
274 }
275 }
276
277 pub fn set_signed_urls(&mut self, value: bool) {
278 match self {
279 HostTransport::Null { signed_urls, .. } => *signed_urls = value,
280 #[cfg(feature = "native-transport")]
281 HostTransport::Native(t) => t.signed_urls = value,
282 }
283 }
284
285 pub fn set_headers(&mut self, headers: Vec<(String, String)>) {
288 match self {
289 HostTransport::Null { .. } => drop(headers),
290 #[cfg(feature = "native-transport")]
291 HostTransport::Native(t) => t.set_headers(headers),
292 }
293 }
294
295 pub fn set_policy(&mut self, policy: HostTransportPolicy) -> Result<(), String> {
300 policy.validate()?;
301 match self {
302 HostTransport::Null { .. } => {}
303 #[cfg(feature = "native-transport")]
304 HostTransport::Native(t) => t.set_policy(policy)?,
305 }
306 Ok(())
307 }
308
309 pub fn take_inbound(&mut self) -> Vec<Inbound> {
311 match self {
312 HostTransport::Null { inbound, .. } => inbound.take(),
313 #[cfg(feature = "native-transport")]
314 HostTransport::Native(t) => t.inbound.take(),
315 }
316 }
317
318 pub fn shutdown(&mut self) {
320 match self {
321 HostTransport::Null { .. } => {}
322 #[cfg(feature = "native-transport")]
323 HostTransport::Native(t) => t.shutdown(),
324 }
325 }
326}
327
328fn unavailable(op: &str) -> TransportError {
329 TransportError::new(
330 "transport.unavailable",
331 format!("{op} needs the native transport (build with --features native-transport)"),
332 )
333}
334
335#[cfg_attr(not(feature = "native-transport"), allow(unused_variables))]
338impl Transport for HostTransport {
339 fn sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
340 match self {
341 HostTransport::Null { .. } => Err(unavailable("sync")),
342 #[cfg(feature = "native-transport")]
343 HostTransport::Native(t) => t.sync(request),
344 }
345 }
346
347 fn remote_operation(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
348 match self {
349 HostTransport::Null { .. } => Err(unavailable("remoteOperation")),
350 #[cfg(feature = "native-transport")]
351 HostTransport::Native(t) => t.remote_operation(request),
352 }
353 }
354
355 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
356 match self {
357 HostTransport::Null { .. } => Err(unavailable("realtimeSync")),
358 #[cfg(feature = "native-transport")]
359 HostTransport::Native(t) => t.realtime_sync(request),
360 }
361 }
362
363 fn download_segment(
364 &mut self,
365 request: &SegmentRequest,
366 on_progress: &mut dyn FnMut(u64),
367 ) -> Result<Vec<u8>, TransportError> {
368 match self {
369 HostTransport::Null { .. } => Err(unavailable("downloadSegment")),
370 #[cfg(feature = "native-transport")]
371 HostTransport::Native(t) => t.download_segment(request, on_progress),
372 }
373 }
374
375 fn supports_url_fetch(&self) -> bool {
376 match self {
377 HostTransport::Null { signed_urls, .. } => *signed_urls,
378 #[cfg(feature = "native-transport")]
379 HostTransport::Native(t) => t.signed_urls,
380 }
381 }
382
383 fn download_segments(
384 &mut self,
385 fetches: &[SegmentFetch],
386 on_progress: &mut (dyn FnMut(usize, u64) + Send),
387 ) -> Vec<Result<Vec<u8>, TransportError>> {
388 match self {
389 HostTransport::Null { .. } => fetches
390 .first()
391 .map(|_| Err(unavailable("downloadSegments")))
392 .into_iter()
393 .collect(),
394 #[cfg(feature = "native-transport")]
395 HostTransport::Native(t) => t.download_segments(fetches, on_progress),
396 }
397 }
398
399 fn fetch_url(
400 &mut self,
401 url: &str,
402 on_progress: &mut dyn FnMut(u64),
403 ) -> Result<Vec<u8>, TransportError> {
404 match self {
405 HostTransport::Null { .. } => Err(unavailable("fetchUrl")),
406 #[cfg(feature = "native-transport")]
407 HostTransport::Native(t) => t.fetch_url(url, on_progress),
408 }
409 }
410
411 fn blob_upload(
412 &mut self,
413 blob_id: &str,
414 bytes: &[u8],
415 media_type: Option<&str>,
416 ) -> Result<(), TransportError> {
417 match self {
418 HostTransport::Null { .. } => Err(unavailable("blobUpload")),
419 #[cfg(feature = "native-transport")]
420 HostTransport::Native(t) => t.blob_upload(blob_id, bytes, media_type),
421 }
422 }
423
424 fn blob_download(&mut self, blob_id: &str) -> Result<BlobDownload, TransportError> {
425 match self {
426 HostTransport::Null { .. } => Err(unavailable("blobDownload")),
427 #[cfg(feature = "native-transport")]
428 HostTransport::Native(t) => t.blob_download(blob_id),
429 }
430 }
431
432 fn fetch_blob_url(&mut self, url: &str) -> Result<Vec<u8>, TransportError> {
433 match self {
434 HostTransport::Null { .. } => Err(unavailable("fetchBlobUrl")),
435 #[cfg(feature = "native-transport")]
436 HostTransport::Native(t) => t.fetch_blob_url(url),
437 }
438 }
439
440 fn blob_upload_grant(
441 &mut self,
442 blob_id: &str,
443 byte_length: u64,
444 media_type: Option<&str>,
445 ) -> Result<BlobUploadGrant, TransportError> {
446 match self {
447 HostTransport::Null { .. } => Ok(BlobUploadGrant::None),
450 #[cfg(feature = "native-transport")]
451 HostTransport::Native(t) => t.blob_upload_grant(blob_id, byte_length, media_type),
452 }
453 }
454
455 fn blob_put_url(
456 &mut self,
457 url: &str,
458 bytes: &[u8],
459 media_type: Option<&str>,
460 ) -> Result<(), TransportError> {
461 match self {
462 HostTransport::Null { .. } => Err(unavailable("blobPutUrl")),
463 #[cfg(feature = "native-transport")]
464 HostTransport::Native(t) => t.blob_put_url(url, bytes, media_type),
465 }
466 }
467
468 fn realtime_connect(&mut self) -> Result<(), TransportError> {
469 match self {
470 HostTransport::Null { .. } => Err(unavailable("realtimeConnect")),
471 #[cfg(feature = "native-transport")]
472 HostTransport::Native(t) => {
473 t.set_realtime_client_id(None);
474 t.realtime_connect()
475 }
476 }
477 }
478
479 fn realtime_connect_for_client(&mut self, client_id: &str) -> Result<(), TransportError> {
480 match self {
481 HostTransport::Null { .. } => Err(unavailable("realtimeConnect")),
482 #[cfg(feature = "native-transport")]
483 HostTransport::Native(t) => {
484 t.set_realtime_client_id(Some(client_id));
485 t.realtime_connect()
486 }
487 }
488 }
489
490 fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
491 match self {
492 HostTransport::Null { .. } => Err(unavailable("realtimeSend")),
493 #[cfg(feature = "native-transport")]
494 HostTransport::Native(t) => t.realtime_send(text),
495 }
496 }
497
498 fn realtime_close(&mut self) -> Result<(), TransportError> {
499 match self {
500 HostTransport::Null { .. } => Ok(()),
501 #[cfg(feature = "native-transport")]
502 HostTransport::Native(t) => t.realtime_close(),
503 }
504 }
505
506 fn round_deadline(&self) -> Option<std::time::Duration> {
507 match self {
508 HostTransport::Null { .. } => None,
509 #[cfg(feature = "native-transport")]
510 HostTransport::Native(t) => t.round_deadline(),
511 }
512 }
513
514 fn set_round_deadline(&mut self, deadline: Option<std::time::Instant>) {
515 match self {
516 HostTransport::Null { .. } => {}
517 #[cfg(feature = "native-transport")]
518 HostTransport::Native(t) => t.set_round_deadline(deadline),
519 }
520 }
521}
522
523#[cfg(feature = "native-transport")]
524mod native {
525 use std::net::TcpStream;
537 use std::sync::atomic::{AtomicBool, Ordering};
538 use std::sync::{mpsc, Arc, Condvar, Mutex};
539 use std::thread::JoinHandle;
540 use std::time::Duration;
541
542 use polling::{Event, Events, Poller};
543 use tungstenite::stream::MaybeTlsStream;
544 use tungstenite::Message;
545
546 use super::{HostTransportPolicy, Inbound, InboundBuffer, RedirectPolicy};
547 use crate::{
548 BlobDownload, BlobUploadGrant, RealtimeRound, RoundInbound, SegmentFetch, SegmentRequest,
549 Transport, TransportError,
550 };
551
552 struct SocketRegistration {
554 poller: Arc<Poller>,
555 stream: TcpStream,
556 }
557
558 impl Drop for SocketRegistration {
559 fn drop(&mut self) {
560 let _ = self.poller.delete(&self.stream);
561 }
562 }
563
564 #[derive(Clone)]
565 struct SocketSender {
566 queue: mpsc::SyncSender<Outgoing>,
567 poller: Arc<Poller>,
568 }
569
570 struct Outgoing {
571 message: Message,
572 completed: mpsc::Sender<Result<(), TransportError>>,
573 }
574
575 const ROUND_TIMEOUT: Duration = Duration::from_secs(30);
579 #[derive(Default)]
586 pub(super) struct RoundChannel {
587 state: Mutex<RoundState>,
588 ready: Condvar,
589 }
590
591 #[derive(Default)]
592 struct RoundState {
593 round: RealtimeRound,
594 outcome: Option<Result<Vec<u8>, TransportError>>,
596 }
597
598 impl RoundChannel {
599 fn begin(&self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
603 let mut state = self.state.lock().expect("round lock");
604 state.outcome = None;
605 state.round.begin(request)
606 }
607
608 fn route_binary(&self, frame: &[u8]) -> Option<Vec<u8>> {
612 let mut state = self.state.lock().expect("round lock");
613 match state.round.route_binary(frame) {
614 Ok(RoundInbound::Delta(body)) => Some(body),
615 Ok(RoundInbound::RoundProgress) | Ok(RoundInbound::Ignored) => None,
616 Ok(RoundInbound::RoundComplete(bytes)) => {
617 state.outcome = Some(Ok(bytes));
618 self.ready.notify_all();
619 None
620 }
621 Err(error) => {
622 state.outcome = Some(Err(error));
623 self.ready.notify_all();
624 None
625 }
626 }
627 }
628
629 fn fail_in_flight(&self, error: TransportError) {
631 let mut state = self.state.lock().expect("round lock");
632 if state.round.in_flight() && state.outcome.is_none() {
633 state.round.abort();
634 state.outcome = Some(Err(error));
635 self.ready.notify_all();
636 }
637 }
638
639 fn wait(
643 &self,
644 round_deadline: Option<std::time::Instant>,
645 ) -> Result<Vec<u8>, TransportError> {
646 let mut state = self.state.lock().expect("round lock");
647 let hard = std::time::Instant::now() + ROUND_TIMEOUT;
648 let round_binding = round_deadline.is_some_and(|round| round < hard);
649 let deadline = match round_deadline {
650 Some(round) if round_binding => round,
651 _ => hard,
652 };
653 while state.outcome.is_none() {
654 let now = std::time::Instant::now();
655 if now >= deadline {
656 state.round.abort();
657 return Err(if round_binding {
658 timeout_error("sync round deadline exceeded", "round_deadline")
659 } else {
660 TransportError::new(
661 "sync.transport_failed",
662 "realtime sync round timed out (§8.7)",
663 )
664 });
665 }
666 let (guard, _timeout) = self
667 .ready
668 .wait_timeout(state, deadline - now)
669 .expect("round wait");
670 state = guard;
671 }
672 state.outcome.take().expect("outcome present")
673 }
674 }
675
676 fn transfer_error(
677 message: &'static str,
678 cause_kind: &'static str,
679 http_status: Option<u16>,
680 ) -> TransportError {
681 let mut details = serde_json::json!({ "causeKind": cause_kind });
682 if let Some(status) = http_status {
683 details["httpStatus"] = status.into();
684 }
685 let mut error = TransportError::new("transport.failed", message);
686 error.details = Some(details);
687 error
688 }
689
690 fn timeout_error(message: &'static str, cause_kind: &'static str) -> TransportError {
693 let mut error = TransportError::new("transport.timeout", message);
694 error.details = Some(serde_json::json!({ "causeKind": cause_kind }));
695 error
696 }
697
698 fn redirect_error(message: &'static str, http_status: Option<u16>) -> TransportError {
700 let mut details = serde_json::json!({ "causeKind": "redirect" });
701 if let Some(status) = http_status {
702 details["httpStatus"] = status.into();
703 }
704 let mut error = TransportError::new("transport.redirect", message);
705 error.details = Some(details);
706 error
707 }
708
709 fn redirect_refused(resp: &ureq::http::Response<ureq::Body>) -> Result<(), TransportError> {
714 let status = resp.status().as_u16();
715 if (300..400).contains(&status) {
716 return Err(redirect_error("redirect refused", Some(status)));
717 }
718 Ok(())
719 }
720
721 fn is_timeout_io(error: &std::io::Error) -> bool {
727 if error.kind() == std::io::ErrorKind::TimedOut {
728 return true;
729 }
730 let Some(inner) = error.get_ref() else {
731 return false;
732 };
733 let Some(ureq_error) = inner.downcast_ref::<ureq::Error>() else {
734 return false;
735 };
736 match ureq_error {
737 ureq::Error::Timeout(_) => true,
738 ureq::Error::Io(nested) => is_timeout_io(nested),
739 ureq::Error::Decompress(_, nested) => is_timeout_io(nested),
740 _ => false,
741 }
742 }
743
744 fn http_err(message: &'static str, error: ureq::Error) -> TransportError {
745 let (kind, status) = match error {
746 ureq::Error::StatusCode(status) if (300..400).contains(&status) => {
747 return redirect_error(message, Some(status))
748 }
749 ureq::Error::StatusCode(status) => ("status", Some(status)),
750 ureq::Error::Timeout(_) => return timeout_error(message, "timeout"),
751 ureq::Error::Io(error) => return io_err(message, error),
752 ureq::Error::HostNotFound
753 | ureq::Error::ConnectionFailed
754 | ureq::Error::ConnectProxyFailed(_) => ("connect", None),
755 ureq::Error::Tls(_)
756 | ureq::Error::Rustls(_)
757 | ureq::Error::Pem(_)
758 | ureq::Error::TlsRequired => ("tls", None),
759 ureq::Error::BadUri(_)
760 | ureq::Error::Http(_)
761 | ureq::Error::RequireHttpsOnly(_)
762 | ureq::Error::InvalidProxyUrl => ("request", None),
763 ureq::Error::Protocol(_) => ("protocol", None),
764 ureq::Error::BodyExceedsLimit(_)
765 | ureq::Error::BodyStalled
766 | ureq::Error::Decompress(_, _) => ("body", None),
767 ureq::Error::RedirectFailed | ureq::Error::TooManyRedirects => {
768 return redirect_error(message, None)
769 }
770 _ => ("unknown", None),
771 };
772 transfer_error(message, kind, status)
773 }
774
775 fn io_err(message: &'static str, error: std::io::Error) -> TransportError {
776 if is_timeout_io(&error) {
777 return timeout_error(message, "timeout");
778 }
779 let kind = match error.kind() {
780 std::io::ErrorKind::ConnectionRefused
781 | std::io::ErrorKind::ConnectionAborted
782 | std::io::ErrorKind::ConnectionReset
783 | std::io::ErrorKind::NotConnected => "connect",
784 _ => "io",
785 };
786 transfer_error(message, kind, None)
787 }
788
789 fn ws_err(message: &'static str, error: tungstenite::Error) -> TransportError {
790 match error {
791 tungstenite::Error::Io(error) => io_err(message, error),
792 tungstenite::Error::Tls(_) => transfer_error(message, "tls", None),
793 tungstenite::Error::Http(response) if response.status().is_redirection() => {
796 redirect_error(message, Some(response.status().as_u16()))
797 }
798 tungstenite::Error::Http(response) => {
799 transfer_error(message, "status", Some(response.status().as_u16()))
800 }
801 tungstenite::Error::Url(_) | tungstenite::Error::HttpFormat(_) => {
802 transfer_error(message, "request", None)
803 }
804 _ => transfer_error(message, "protocol", None),
805 }
806 }
807
808 fn segment_error(mut error: TransportError) -> TransportError {
812 if error.code == "transport.failed" {
813 error.code = "sync.transport_failed".into();
814 error.message = "segment transfer failed".into();
815 }
816 error
817 }
818
819 fn is_would_block(e: &tungstenite::Error) -> bool {
821 matches!(
822 e,
823 tungstenite::Error::Io(io) if matches!(
824 io.kind(),
825 std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
826 )
827 )
828 }
829
830 pub struct NativeTransport {
831 base_url: String,
832 ws_url: String,
833 headers: Vec<(String, String)>,
835 base_url_has_userinfo: bool,
839 ws_url_credentialed: bool,
843 agent: ureq::Agent,
844 policy: HostTransportPolicy,
845 round_deadline_at: Option<std::time::Instant>,
849 pub signed_urls: bool,
850 pub inbound: Arc<InboundBuffer>,
851 outgoing: Option<Box<SocketSender>>,
853 reader: Option<JoinHandle<()>>,
854 reader_stop: Arc<AtomicBool>,
855 round: Arc<RoundChannel>,
857 realtime_client_id: Option<String>,
858 }
859
860 fn build_agent(policy: &HostTransportPolicy) -> ureq::Agent {
861 ureq::Agent::config_builder()
862 .timeout_global(policy.request_timeout)
863 .max_redirects(match policy.redirects {
864 RedirectPolicy::Deny => 0,
865 RedirectPolicy::Follow => 10,
866 })
867 .max_redirects_will_error(matches!(policy.redirects, RedirectPolicy::Follow))
868 .redirect_auth_headers(ureq::config::RedirectAuthHeaders::Never)
869 .build()
870 .into()
871 }
872
873 fn derive_ws_url(base_url: &str) -> String {
874 let ws = if let Some(rest) = base_url.strip_prefix("https://") {
877 format!("wss://{rest}")
878 } else if let Some(rest) = base_url.strip_prefix("http://") {
879 format!("ws://{rest}")
880 } else {
881 base_url.to_owned()
882 };
883 let trimmed = ws.trim_end_matches('/');
884 format!("{trimmed}/realtime")
885 }
886
887 impl NativeTransport {
888 pub fn fork_round(&self) -> Self {
889 Self {
890 base_url: self.base_url.clone(),
891 ws_url: self.ws_url.clone(),
892 headers: self.headers.clone(),
893 base_url_has_userinfo: self.base_url_has_userinfo,
894 ws_url_credentialed: self.ws_url_credentialed,
895 agent: self.agent.clone(),
896 policy: self.policy.clone(),
897 round_deadline_at: None,
898 signed_urls: self.signed_urls,
899 inbound: self.inbound.clone(),
900 outgoing: self.outgoing.clone(),
901 reader: None,
902 reader_stop: self.reader_stop.clone(),
903 round: self.round.clone(),
904 realtime_client_id: self.realtime_client_id.clone(),
905 }
906 }
907
908 pub fn new(
909 base_url: &str,
910 config: &serde_json::Value,
911 notify: Option<Arc<dyn Fn() + Send + Sync>>,
912 ) -> Result<Self, String> {
913 let policy = HostTransportPolicy::from_config(config)?;
914 Self::with_policy(base_url, config, policy, notify)
915 }
916
917 pub fn with_policy(
918 base_url: &str,
919 config: &serde_json::Value,
920 policy: HostTransportPolicy,
921 notify: Option<Arc<dyn Fn() + Send + Sync>>,
922 ) -> Result<Self, String> {
923 policy.validate()?;
924 let mut headers = Vec::new();
925 if let Some(map) = config.get("headers").and_then(|v| v.as_object()) {
926 for (k, v) in map {
927 if let Some(s) = v.as_str() {
928 headers.push((k.clone(), s.to_owned()));
929 }
930 }
931 }
932 let ws_url = config
933 .get("wsUrl")
934 .and_then(|v| v.as_str())
935 .map(str::to_owned)
936 .unwrap_or_else(|| derive_ws_url(base_url));
937 let base_url_has_userinfo = url::Url::parse(base_url)
940 .map(|url| !url.username().is_empty() || url.password().is_some())
941 .unwrap_or(false);
942 let ws_url_credentialed = url::Url::parse(&ws_url)
946 .map(|url| {
947 !url.username().is_empty()
948 || url.password().is_some()
949 || url.query().is_some_and(|query| !query.is_empty())
950 })
951 .unwrap_or(false);
952 Ok(NativeTransport {
953 base_url: base_url.trim_end_matches('/').to_owned(),
954 ws_url,
955 headers,
956 base_url_has_userinfo,
957 ws_url_credentialed,
958 agent: build_agent(&policy),
959 policy,
960 round_deadline_at: None,
961 signed_urls: false,
962 inbound: Arc::new(match notify {
963 Some(notify) => InboundBuffer::with_notify(notify),
964 None => InboundBuffer::default(),
965 }),
966 outgoing: None,
967 reader: None,
968 reader_stop: Arc::new(AtomicBool::new(false)),
969 round: Arc::new(RoundChannel::default()),
970 realtime_client_id: None,
971 })
972 }
973
974 pub fn set_policy(&mut self, policy: HostTransportPolicy) -> Result<(), String> {
978 policy.validate()?;
979 self.agent = build_agent(&policy);
980 self.policy = policy;
981 Ok(())
982 }
983
984 pub fn round_deadline(&self) -> Option<Duration> {
985 self.policy.round_deadline
986 }
987
988 pub fn set_round_deadline(&mut self, deadline: Option<std::time::Instant>) {
989 self.round_deadline_at = deadline;
990 }
991
992 fn effective_timeout(&self) -> Result<Option<Duration>, TransportError> {
996 let remaining = match self.round_deadline_at {
997 Some(deadline) => {
998 match deadline.checked_duration_since(std::time::Instant::now()) {
999 Some(remaining) => Some(remaining),
1000 None => {
1001 return Err(timeout_error(
1002 "sync round deadline exceeded",
1003 "round_deadline",
1004 ))
1005 }
1006 }
1007 }
1008 None => None,
1009 };
1010 Ok(match (self.policy.request_timeout, remaining) {
1011 (Some(request), Some(round)) => Some(request.min(round)),
1012 (Some(request), None) => Some(request),
1013 (None, Some(round)) => Some(round),
1014 (None, None) => None,
1015 })
1016 }
1017
1018 fn credentialed(&self) -> bool {
1021 !self.headers.is_empty() || self.base_url_has_userinfo
1022 }
1023
1024 fn realtime_credentialed(&self) -> bool {
1028 !self.headers.is_empty() || self.ws_url_credentialed
1029 }
1030
1031 fn finish_request<B>(
1037 &self,
1038 req: ureq::RequestBuilder<B>,
1039 credentialed: bool,
1040 ) -> Result<ureq::RequestBuilder<B>, TransportError> {
1041 let timeout = self.effective_timeout()?;
1042 let follow = self.policy.redirects == RedirectPolicy::Follow && !credentialed;
1043 Ok(req
1044 .config()
1045 .timeout_global(timeout)
1046 .max_redirects(if follow { 10 } else { 0 })
1047 .max_redirects_will_error(true)
1048 .build())
1049 }
1050
1051 fn check_request_bytes(&self, len: u64) -> Result<(), TransportError> {
1053 match self.policy.max_request_bytes {
1054 Some(max) if len > max => {
1055 let mut error = TransportError::new(
1056 "transport.request_too_large",
1057 "request body exceeds the configured transport limit",
1058 );
1059 error.details = Some(serde_json::json!({ "limit": max, "size": len }));
1060 Err(error)
1061 }
1062 _ => Ok(()),
1063 }
1064 }
1065
1066 fn post_sync(&self, path: &str, body: &[u8]) -> Result<Vec<u8>, TransportError> {
1067 self.check_request_bytes(body.len() as u64)?;
1068 let credentialed = self.credentialed();
1069 let url = format!("{}{}", self.base_url, path);
1070 let mut req = self
1073 .agent
1074 .post(&url)
1075 .header("content-type", "application/vnd.syncular.sync.v2");
1076 for (k, v) in &self.headers {
1077 req = req.header(k.as_str(), v.as_str());
1078 }
1079 let req = self.finish_request(req, credentialed)?;
1080 let resp = req
1081 .send(body)
1082 .map_err(|e| http_err("sync request failed", e))?;
1083 read_body(resp, None, self.policy.max_response_bytes)
1084 }
1085
1086 fn post_operation(&self, body: &[u8]) -> Result<Vec<u8>, TransportError> {
1087 self.check_request_bytes(body.len() as u64)?;
1088 let credentialed = self.credentialed();
1089 let url = format!("{}/operations", self.base_url);
1090 let mut req = self.agent.post(&url).header(
1091 "content-type",
1092 "application/vnd.syncular.operations.v1+json",
1093 );
1094 for (key, value) in &self.headers {
1095 req = req.header(key.as_str(), value.as_str());
1096 }
1097 let req = self.finish_request(req, credentialed)?;
1098 let response = req
1099 .send(body)
1100 .map_err(|error| http_err("remote operation request failed", error))?;
1101 read_body(response, None, self.policy.max_response_bytes)
1102 }
1103
1104 fn get_bytes(&self, url: &str, with_headers: bool) -> Result<Vec<u8>, TransportError> {
1105 let mut req = self.agent.get(url);
1108 if with_headers {
1109 for (k, v) in &self.headers {
1110 req = req.header(k.as_str(), v.as_str());
1111 }
1112 }
1113 let req = self.finish_request(req, true)?;
1114 let resp = req.call().map_err(|e| http_err("URL fetch failed", e))?;
1115 read_body(resp, None, self.policy.max_response_bytes)
1116 }
1117
1118 pub fn set_headers(&mut self, headers: Vec<(String, String)>) {
1119 self.headers = headers;
1120 }
1121
1122 pub fn set_realtime_client_id(&mut self, client_id: Option<&str>) {
1123 self.realtime_client_id = client_id.map(str::to_owned);
1124 }
1125
1126 pub fn shutdown(&mut self) {
1127 self.reader_stop.store(true, Ordering::SeqCst);
1128 self.round.fail_in_flight(TransportError::new(
1131 "sync.transport_failed",
1132 "realtime disconnected mid-round (§8.7)",
1133 ));
1134 if let Some(outgoing) = &self.outgoing {
1135 let _ = outgoing.poller.notify();
1136 }
1137 if let Some(handle) = self.reader.take() {
1138 let _ = handle.join();
1139 }
1140 self.outgoing = None;
1141 }
1142
1143 fn send_message(&mut self, message: Message) -> Result<(), TransportError> {
1144 let (wait, round_bound) = match self.round_deadline_at {
1149 Some(deadline) => {
1150 match deadline.checked_duration_since(std::time::Instant::now()) {
1151 Some(remaining) if remaining < ROUND_TIMEOUT => (remaining, true),
1152 Some(_) => (ROUND_TIMEOUT, false),
1153 None => {
1156 return Err(timeout_error("realtime send timed out", "round_deadline"));
1157 }
1158 }
1159 }
1160 None => (ROUND_TIMEOUT, false),
1161 };
1162 let result = (|| {
1163 let Some(outgoing) = &self.outgoing else {
1164 return Err(TransportError::new(
1165 "transport.failed",
1166 "realtime not connected",
1167 ));
1168 };
1169 let (completed, result) = mpsc::channel();
1170 outgoing
1171 .queue
1172 .try_send(Outgoing { message, completed })
1173 .map_err(|_| {
1174 TransportError::new("transport.failed", "realtime send queue unavailable")
1175 })?;
1176 outgoing
1177 .poller
1178 .notify()
1179 .map_err(|e| io_err("realtime wake failed", e))?;
1180 result.recv_timeout(wait).map_err(|_| {
1181 if round_bound {
1182 timeout_error("realtime send timed out", "round_deadline")
1183 } else {
1184 TransportError::new("transport.failed", "realtime send did not complete")
1185 }
1186 })?
1187 })();
1188 if result.is_err() {
1191 self.shutdown();
1192 }
1193 result
1194 }
1195 }
1196
1197 fn read_body(
1198 resp: ureq::http::Response<ureq::Body>,
1199 mut progress: Option<&mut dyn FnMut(u64)>,
1200 max: Option<u64>,
1201 ) -> Result<Vec<u8>, TransportError> {
1202 redirect_refused(&resp)?;
1203 use std::io::Read;
1204 let status = resp.status().as_u16();
1205 let mut reader = resp.into_body().into_with_config().reader();
1206 let mut bytes = Vec::new();
1207 let mut chunk = [0u8; 64 * 1024];
1208 let mut reported = 0;
1209 loop {
1210 let n = reader.read(&mut chunk).map_err(|error| {
1211 if is_timeout_io(&error) {
1212 timeout_error("response body read times out", "timeout")
1213 } else {
1214 transfer_error("response body read failed", "body", Some(status))
1215 }
1216 })?;
1217 if n == 0 {
1218 break;
1219 }
1220 if let Some(max) = max {
1223 if bytes.len() as u64 + n as u64 > max {
1224 return Err(response_too_large(max));
1225 }
1226 }
1227 bytes.extend_from_slice(&chunk[..n]);
1228 if bytes.len() - reported >= 64 * 1024 {
1229 if let Some(callback) = progress.as_mut() {
1230 callback(bytes.len() as u64);
1231 }
1232 reported = bytes.len();
1233 }
1234 }
1235 if bytes.len() != reported {
1236 if let Some(callback) = progress {
1237 callback(bytes.len() as u64);
1238 }
1239 }
1240 Ok(bytes)
1241 }
1242
1243 fn response_too_large(max: u64) -> TransportError {
1244 let mut error = TransportError::new(
1245 "transport.response_too_large",
1246 "response body exceeds the configured transport limit",
1247 );
1248 error.details = Some(serde_json::json!({ "limit": max }));
1249 error
1250 }
1251
1252 impl Transport for NativeTransport {
1253 fn sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
1254 self.post_sync("/sync", request)
1255 }
1256
1257 fn remote_operation(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
1258 self.post_operation(request)
1259 }
1260
1261 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
1262 if self.outgoing.is_none() {
1272 return self.post_sync("/sync", request);
1273 }
1274 self.effective_timeout()?;
1275 let framed = self.round.begin(request)?;
1276 if let Err(error) = self.send_message(Message::Binary(framed.into())) {
1277 self.round.fail_in_flight(error);
1278 }
1279 self.round.wait(self.round_deadline_at)
1280 }
1281
1282 fn download_segment(
1283 &mut self,
1284 request: &SegmentRequest,
1285 on_progress: &mut dyn FnMut(u64),
1286 ) -> Result<Vec<u8>, TransportError> {
1287 let url = format!("{}/segments/{}", self.base_url, request.segment_id);
1294 let credentialed = self.credentialed();
1295 let mut req = self
1296 .agent
1297 .get(&url)
1298 .header("x-syncular-scopes", &request.requested_scopes_json);
1299 for (k, v) in &self.headers {
1300 req = req.header(k.as_str(), v.as_str());
1301 }
1302 let req = self.finish_request(req, credentialed)?;
1303 let resp = req
1304 .call()
1305 .map_err(|error| segment_error(http_err("segment request failed", error)))?;
1306 read_body(resp, Some(on_progress), self.policy.max_response_bytes)
1307 .map_err(segment_error)
1308 }
1309
1310 fn supports_url_fetch(&self) -> bool {
1311 self.signed_urls
1312 }
1313
1314 fn download_segments(
1315 &mut self,
1316 fetches: &[SegmentFetch],
1317 on_progress: &mut (dyn FnMut(usize, u64) + Send),
1318 ) -> Vec<Result<Vec<u8>, TransportError>> {
1319 let workers = (0..fetches.len().min(4))
1328 .map(|_| {
1329 let mut worker = self.fork_round();
1330 worker.round_deadline_at = self.round_deadline_at;
1331 worker
1332 })
1333 .collect::<Vec<_>>();
1334 let slots = fetches.iter().map(|_| Mutex::new(None)).collect::<Vec<_>>();
1335 let progress = Mutex::new(on_progress);
1336 let next = std::sync::atomic::AtomicUsize::new(0);
1337 std::thread::scope(|scope| {
1338 for worker in workers {
1339 let (slots, progress, next) = (&slots, &progress, &next);
1340 scope.spawn(move || {
1341 let mut worker = worker;
1342 loop {
1343 let index = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1344 let Some(fetch) = fetches.get(index) else {
1345 break;
1346 };
1347 let mut report = |bytes: u64| {
1348 (*progress.lock().expect("segment progress lock"))(index, bytes);
1349 };
1350 let result = match fetch {
1351 SegmentFetch::Direct(request) => {
1352 worker.download_segment(request, &mut report)
1353 }
1354 SegmentFetch::Url(url) => worker.fetch_url(url, &mut report),
1355 };
1356 *slots[index].lock().expect("segment result slot") = Some(result);
1357 }
1358 });
1359 }
1360 });
1361 slots
1362 .into_iter()
1363 .map(|slot| slot.into_inner().expect("segment result slot"))
1364 .map(|result| result.expect("every fetch is attempted"))
1365 .collect()
1366 }
1367
1368 fn fetch_url(
1369 &mut self,
1370 url: &str,
1371 on_progress: &mut dyn FnMut(u64),
1372 ) -> Result<Vec<u8>, TransportError> {
1373 let req = self.finish_request(self.agent.get(url), true)?;
1375 let resp = req
1376 .call()
1377 .map_err(|error| segment_error(http_err("segment request failed", error)))?;
1378 read_body(resp, Some(on_progress), self.policy.max_response_bytes)
1379 .map_err(segment_error)
1380 }
1381
1382 fn blob_upload(
1383 &mut self,
1384 blob_id: &str,
1385 bytes: &[u8],
1386 media_type: Option<&str>,
1387 ) -> Result<(), TransportError> {
1388 self.check_request_bytes(bytes.len() as u64)?;
1391 let credentialed = self.credentialed();
1392 let url = format!("{}/blobs/{}", self.base_url, blob_id);
1393 let mut req = self.agent.put(&url).header(
1394 "content-type",
1395 media_type.unwrap_or("application/octet-stream"),
1396 );
1397 for (k, v) in &self.headers {
1398 req = req.header(k.as_str(), v.as_str());
1399 }
1400 let req = self.finish_request(req, credentialed)?;
1401 let resp = req
1402 .send(bytes)
1403 .map_err(|e| http_err("blob upload failed", e))?;
1404 redirect_refused(&resp)?;
1405 Ok(())
1406 }
1407
1408 fn blob_download(&mut self, blob_id: &str) -> Result<BlobDownload, TransportError> {
1409 let credentialed = self.credentialed();
1412 let url = format!("{}/blobs/{}", self.base_url, blob_id);
1413 let mut req = self.agent.get(&url);
1414 for (k, v) in &self.headers {
1415 req = req.header(k.as_str(), v.as_str());
1416 }
1417 let req = self.finish_request(req, credentialed)?;
1418 let resp = req.call().map_err(|cause| {
1419 let status = match &cause {
1420 ureq::Error::StatusCode(status) => Some(*status),
1421 _ => None,
1422 };
1423 let mut error = http_err("blob download failed", cause);
1424 match status {
1425 Some(404) => {
1426 error.code = "blob.not_found".into();
1427 error.message = "blob not found".into();
1428 }
1429 Some(403) => {
1430 error.code = "blob.forbidden".into();
1431 error.message = "blob download forbidden".into();
1432 }
1433 Some(401) => {
1434 error.code = "sync.auth_required".into();
1435 error.message = "host authentication required".into();
1436 }
1437 _ => {}
1438 }
1439 error
1440 })?;
1441 let is_json = resp
1444 .headers()
1445 .get(ureq::http::header::CONTENT_TYPE)
1446 .and_then(|value| value.to_str().ok())
1447 .is_some_and(|value| value.contains("application/json"));
1448 let body = read_body(resp, None, self.policy.max_response_bytes)?;
1449 if is_json {
1450 if let Ok(parsed) = serde_json::from_slice::<serde_json::Value>(&body) {
1451 if let Some(u) = parsed.get("url").and_then(|v| v.as_str()) {
1452 return Ok(BlobDownload::Url {
1453 url: u.to_owned(),
1454 url_expires_at_ms: parsed
1455 .get("urlExpiresAtMs")
1456 .and_then(|v| v.as_i64()),
1457 });
1458 }
1459 }
1460 }
1461 Ok(BlobDownload::Bytes(body))
1462 }
1463
1464 fn fetch_blob_url(&mut self, url: &str) -> Result<Vec<u8>, TransportError> {
1465 self.get_bytes(url, false)
1467 }
1468
1469 fn blob_upload_grant(
1470 &mut self,
1471 blob_id: &str,
1472 byte_length: u64,
1473 media_type: Option<&str>,
1474 ) -> Result<BlobUploadGrant, TransportError> {
1475 let url = format!("{}/blobs/{}/upload-grant", self.base_url, blob_id);
1476 let body = serde_json::json!({
1477 "byteLength": byte_length,
1478 "mediaType": media_type,
1479 })
1480 .to_string();
1481 self.check_request_bytes(body.len() as u64)?;
1482 let credentialed = self.credentialed();
1483 let mut req = self
1484 .agent
1485 .post(&url)
1486 .header("content-type", "application/json");
1487 for (k, v) in &self.headers {
1488 req = req.header(k.as_str(), v.as_str());
1489 }
1490 let req = self.finish_request(req, credentialed)?;
1491 let resp = req
1492 .send(body)
1493 .map_err(|e| http_err("blob upload grant request failed", e))?;
1494 let grant_body = read_body(resp, None, self.policy.max_response_bytes)?;
1495 let parsed: serde_json::Value = serde_json::from_slice(&grant_body)
1496 .map_err(|_| transfer_error("blob upload grant decode failed", "decode", None))?;
1497 if let Some(u) = parsed.get("url").and_then(|v| v.as_str()) {
1498 return Ok(BlobUploadGrant::Url {
1499 url: u.to_owned(),
1500 url_expires_at_ms: parsed.get("urlExpiresAtMs").and_then(|v| v.as_i64()),
1501 });
1502 }
1503 if parsed.get("present").and_then(|v| v.as_bool()) == Some(true) {
1504 return Ok(BlobUploadGrant::Present);
1505 }
1506 Ok(BlobUploadGrant::None)
1507 }
1508
1509 fn blob_put_url(
1510 &mut self,
1511 url: &str,
1512 bytes: &[u8],
1513 media_type: Option<&str>,
1514 ) -> Result<(), TransportError> {
1515 self.check_request_bytes(bytes.len() as u64)?;
1517 let req = self.finish_request(
1518 self.agent.put(url).header(
1519 "content-type",
1520 media_type.unwrap_or("application/octet-stream"),
1521 ),
1522 true,
1523 )?;
1524 let resp = req
1525 .send(bytes)
1526 .map_err(|e| http_err("signed blob upload failed", e))?;
1527 redirect_refused(&resp)?;
1528 Ok(())
1529 }
1530
1531 fn realtime_connect(&mut self) -> Result<(), TransportError> {
1532 if self.outgoing.is_some() {
1533 if self.reader_stop.load(Ordering::SeqCst)
1534 || self
1535 .reader
1536 .as_ref()
1537 .is_some_and(|reader| reader.is_finished())
1538 {
1539 self.shutdown();
1540 } else {
1541 return Ok(());
1542 }
1543 }
1544 use tungstenite::client::IntoClientRequest;
1550 let mut url = url::Url::parse(&self.ws_url)
1551 .map_err(|_| transfer_error("realtime URL invalid", "request", None))?;
1552 if let Some(client_id) = self.realtime_client_id.as_deref() {
1553 let retained_query: Vec<(String, String)> = url
1554 .query_pairs()
1555 .filter(|(key, _)| key != "clientId")
1556 .map(|(key, value)| (key.into_owned(), value.into_owned()))
1557 .collect();
1558 url.set_query(None);
1559 url.query_pairs_mut()
1560 .extend_pairs(retained_query)
1561 .append_pair("clientId", client_id);
1562 }
1563 let mut request = url
1564 .as_str()
1565 .into_client_request()
1566 .map_err(|error| ws_err("realtime request failed", error))?;
1567 {
1568 let out = request.headers_mut();
1569 for (k, v) in &self.headers {
1570 if let (Ok(name), Ok(value)) = (
1571 tungstenite::http::HeaderName::try_from(k.as_str()),
1572 tungstenite::http::HeaderValue::try_from(v.as_str()),
1573 ) {
1574 out.insert(name, value);
1575 }
1576 }
1577 }
1578 let (mut ws, _resp) = tungstenite::client::connect_with_config(
1579 request,
1580 None,
1581 if self.policy.redirects == RedirectPolicy::Follow && !self.realtime_credentialed()
1587 {
1588 3
1589 } else {
1590 0
1591 },
1592 )
1593 .map_err(|error| ws_err("realtime connection failed", error))?;
1594 let stream = match ws.get_mut() {
1595 MaybeTlsStream::Plain(stream) => stream.try_clone(),
1596 MaybeTlsStream::Rustls(stream) => stream.get_ref().try_clone(),
1597 _ => {
1598 return Err(TransportError::new(
1599 "transport.failed",
1600 "unsupported websocket stream",
1601 ))
1602 }
1603 }
1604 .map_err(|e| io_err("realtime clone failed", e))?;
1605 stream
1606 .set_nonblocking(true)
1607 .map_err(|e| io_err("realtime nonblocking failed", e))?;
1608 let poller = Arc::new(Poller::new().map_err(|e| io_err("realtime poller failed", e))?);
1609 unsafe { poller.add(&stream, Event::readable(0)) }
1611 .map_err(|e| io_err("realtime register failed", e))?;
1612 let registration = SocketRegistration {
1613 poller: Arc::clone(&poller),
1614 stream,
1615 };
1616 let (outgoing, queued) = mpsc::sync_channel::<Outgoing>(1);
1617 self.outgoing = Some(Box::new(SocketSender {
1618 queue: outgoing,
1619 poller,
1620 }));
1621 self.reader_stop = Arc::new(AtomicBool::new(false));
1622 self.round = Arc::new(RoundChannel::default());
1623 let inbound = Arc::clone(&self.inbound);
1624 let stop = Arc::clone(&self.reader_stop);
1625 let round = Arc::clone(&self.round);
1626 self.reader = Some(std::thread::spawn(move || {
1627 let mut events = Events::new();
1628 let mut pending: Option<mpsc::Sender<Result<(), TransportError>>> = None;
1629 let failure = 'io: loop {
1630 if stop.load(Ordering::SeqCst) {
1631 let _ = ws.close(None);
1632 let _ = ws.flush();
1633 break TransportError::new(
1634 "sync.transport_failed",
1635 "realtime disconnected mid-round (§8.7)",
1636 );
1637 }
1638 if pending.is_none() {
1639 if let Ok(outgoing) = queued.try_recv() {
1640 pending = Some(outgoing.completed);
1641 if let Err(error) = ws.write(outgoing.message) {
1643 if !is_would_block(&error) {
1644 break ws_err("realtime write failed", error);
1645 }
1646 }
1647 }
1648 }
1649 let mut drained = false;
1651 for _ in 0..64 {
1652 match ws.read() {
1653 Ok(Message::Text(text)) => {
1654 inbound.push(Inbound::Text(text.to_string()))
1655 }
1656 Ok(Message::Binary(bytes)) => {
1657 if let Some(delta) = round.route_binary(&bytes) {
1658 inbound.push(Inbound::Binary(delta));
1659 }
1660 }
1661 Err(error) if is_would_block(&error) => {
1662 drained = true;
1663 break;
1664 }
1665 Ok(Message::Close(_)) => {
1666 let _ = ws.flush();
1667 break 'io TransportError::new(
1668 "sync.transport_failed",
1669 "realtime disconnected mid-round (§8.7)",
1670 );
1671 }
1672 Err(_) => {
1673 break 'io TransportError::new(
1674 "sync.transport_failed",
1675 "realtime disconnected mid-round (§8.7)",
1676 );
1677 }
1678 Ok(_) => {}
1679 }
1680 }
1681 let interest = match ws.flush() {
1682 Ok(()) => {
1683 if let Some(completed) = pending.take() {
1684 let _ = completed.send(Ok(()));
1685 }
1686 Event::readable(0)
1687 }
1688 Err(error) if is_would_block(&error) => Event::all(0),
1689 Err(error) => break ws_err("realtime flush failed", error),
1690 };
1691 if !drained {
1692 continue;
1693 }
1694 if let Err(error) = registration.poller.modify(®istration.stream, interest) {
1695 break io_err("realtime rearm failed", error);
1696 }
1697 events.clear();
1698 if let Err(error) = registration.poller.wait(&mut events, None) {
1699 if error.kind() != std::io::ErrorKind::Interrupted {
1700 break io_err("realtime wait failed", error);
1701 }
1702 }
1703 };
1704 if let Some(completed) = pending {
1705 let _ = completed.send(Err(failure.clone()));
1706 }
1707 while let Ok(outgoing) = queued.try_recv() {
1708 let _ = outgoing.completed.send(Err(failure.clone()));
1709 }
1710 round.fail_in_flight(failure);
1711 }));
1712 Ok(())
1713 }
1714
1715 fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
1716 self.send_message(Message::Text(text.to_owned().into()))
1717 }
1718
1719 fn realtime_close(&mut self) -> Result<(), TransportError> {
1720 self.shutdown();
1721 Ok(())
1722 }
1723
1724 fn round_deadline(&self) -> Option<Duration> {
1725 NativeTransport::round_deadline(self)
1726 }
1727
1728 fn set_round_deadline(&mut self, deadline: Option<std::time::Instant>) {
1729 NativeTransport::set_round_deadline(self, deadline);
1730 }
1731 }
1732 #[cfg(test)]
1733 mod transfer_tests {
1734 use super::*;
1735 use ureq::unversioned::resolver::DefaultResolver;
1736 use ureq::unversioned::transport::{ConnectionDetails, Connector};
1737
1738 const SECRET: &str = "https://user:password@cdn.example/secret-capability-path?secret-query=value#secret-fragment";
1739
1740 #[derive(Debug)]
1741 struct SecretError;
1742 impl std::fmt::Display for SecretError {
1743 fn fmt(&self, _: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1744 panic!("transport error must never be formatted")
1745 }
1746 }
1747 impl std::error::Error for SecretError {}
1748
1749 #[derive(Debug)]
1750 struct FailingConnector(&'static str);
1751 impl Connector for FailingConnector {
1752 type Out = ();
1753 fn connect(
1754 &self,
1755 _: &ConnectionDetails,
1756 _: Option<()>,
1757 ) -> Result<Option<()>, ureq::Error> {
1758 Err(match self.0 {
1759 "io" => ureq::Error::Io(std::io::Error::new(
1760 std::io::ErrorKind::ConnectionRefused,
1761 format!("404 {SECRET}"),
1762 )),
1763 "tls" => ureq::Error::Tls(SECRET),
1764 "request" => ureq::Error::BadUri(SECRET.into()),
1765 "timeout" => ureq::Error::Timeout(ureq::Timeout::Global),
1766 "404" => ureq::Error::StatusCode(404),
1767 "401" => ureq::Error::StatusCode(401),
1768 "403" => ureq::Error::StatusCode(403),
1769 "503" => ureq::Error::StatusCode(503),
1770 _ => ureq::Error::Other(Box::new(SecretError)),
1771 })
1772 }
1773 }
1774
1775 #[test]
1776 fn injected_http_failures_never_expose_capabilities_or_format_the_cause() {
1777 for kind in [
1778 "io", "tls", "request", "timeout", "unknown", "404", "401", "403", "503",
1779 ] {
1780 for operation in [
1781 "sync",
1782 "remote",
1783 "segment",
1784 "signed_segment",
1785 "blob",
1786 "signed_blob",
1787 "upload",
1788 "grant",
1789 "signed_put",
1790 ] {
1791 let mut transport =
1792 NativeTransport::new("http://127.0.0.1", &serde_json::json!({}), None)
1793 .unwrap();
1794 transport.agent = ureq::Agent::with_parts(
1795 ureq::Agent::config_builder().build(),
1796 FailingConnector(kind),
1797 DefaultResolver::default(),
1798 );
1799 let mut progress = |_| {};
1800 let result = match operation {
1801 "sync" => transport.sync(&[]).map(|_| ()),
1802 "remote" => transport.remote_operation(&[]).map(|_| ()),
1803 "segment" => transport.download_segment(&SegmentRequest { segment_id: "sha256:test".into(), table: "tasks".into(), requested_scopes_json: "{}".into() }, &mut progress).map(|_| ()),
1804 "signed_segment" => transport.fetch_url("http://127.0.0.1/secret-capability-path?secret-query=value#secret-fragment", &mut progress).map(|_| ()),
1805 "blob" => transport.blob_download("sha256:test").map(|_| ()),
1806 "signed_blob" => transport.fetch_blob_url("http://127.0.0.1/secret-capability-path?secret-query=value#secret-fragment").map(|_| ()),
1807 "upload" => transport.blob_upload("sha256:test", &[], None),
1808 "grant" => transport.blob_upload_grant("sha256:test", 0, None).map(|_| ()),
1809 _ => transport.blob_put_url("http://127.0.0.1/secret-capability-path?secret-query=value#secret-fragment", &[], None),
1810 };
1811 let error = result.unwrap_err();
1812 let expected_code = match (operation, kind) {
1813 (_, "timeout") => "transport.timeout",
1814 ("segment" | "signed_segment", _) => "sync.transport_failed",
1815 ("blob", "404") => "blob.not_found",
1816 ("blob", "401") => "sync.auth_required",
1817 ("blob", "403") => "blob.forbidden",
1818 _ => "transport.failed",
1819 };
1820 assert_eq!(error.code, expected_code, "{operation} {kind}");
1821 let details = error.details.unwrap();
1822 assert_eq!(
1823 details["causeKind"],
1824 match kind {
1825 "io" => "connect",
1826 "404" | "401" | "403" | "503" => "status",
1827 other => other,
1828 }
1829 );
1830 assert_eq!(
1831 details.get("httpStatus").and_then(|value| value.as_u64()),
1832 kind.parse::<u64>().ok()
1833 );
1834 let rendered = format!("{}{}", error.message, details);
1835 for secret in [
1836 "http://",
1837 "https://",
1838 "password",
1839 "secret-capability-path",
1840 "secret-query",
1841 "secret-fragment",
1842 ] {
1843 assert!(!rendered.contains(secret), "{operation} {kind}");
1844 }
1845 assert!(details.get("causeMessage").is_none());
1846 assert!(details.get("path").is_none());
1847 }
1848 }
1849 }
1850
1851 #[test]
1852 fn injected_body_and_websocket_errors_do_not_expose_payloads_or_source_chains() {
1853 struct FailingRead;
1854 impl std::io::Read for FailingRead {
1855 fn read(&mut self, _: &mut [u8]) -> std::io::Result<usize> {
1856 Err(std::io::Error::other(SECRET))
1857 }
1858 }
1859 let response = ureq::http::Response::builder()
1860 .status(200)
1861 .body(ureq::Body::builder().reader(FailingRead))
1862 .unwrap();
1863 let error = segment_error(read_body(response, None, None).unwrap_err());
1864 assert_eq!(error.code, "sync.transport_failed");
1865 assert_eq!(error.message, "segment transfer failed");
1866 assert_eq!(
1867 error.details,
1868 Some(serde_json::json!({ "causeKind": "body", "httpStatus": 200 }))
1869 );
1870 for cause in [
1871 tungstenite::Error::Io(std::io::Error::other(SecretError)),
1872 tungstenite::Error::Http(Box::new(
1873 tungstenite::http::Response::builder()
1874 .status(403)
1875 .header("location", SECRET)
1876 .body(Some(SECRET.as_bytes().to_vec()))
1877 .unwrap(),
1878 )),
1879 ] {
1880 let error = ws_err("realtime connection failed", cause);
1881 assert_eq!(error.message, "realtime connection failed");
1882 let details = error.details.unwrap();
1883 assert!(details.get("path").is_none());
1884 assert!(details.get("causeMessage").is_none());
1885 assert!(!details.to_string().contains("secret"));
1886 }
1887 }
1888
1889 #[test]
1890 fn body_read_timeouts_are_classified_through_ureq_wrapping() {
1891 let direct = ureq::Error::Timeout(ureq::Timeout::Global).into_io();
1893 assert!(is_timeout_io(&direct));
1894 let wrapped = ureq::Error::Decompress(
1896 "gzip",
1897 ureq::Error::Timeout(ureq::Timeout::Global).into_io(),
1898 )
1899 .into_io();
1900 assert!(is_timeout_io(&wrapped));
1901 assert!(is_timeout_io(&std::io::Error::new(
1903 std::io::ErrorKind::TimedOut,
1904 "plain"
1905 )));
1906 assert!(!is_timeout_io(&std::io::Error::other("other")));
1907 assert!(!is_timeout_io(&ureq::Error::StatusCode(500).into_io()));
1908 }
1909
1910 #[test]
1911 fn the_configured_request_timeout_is_imposed_on_each_call() {
1912 #[derive(Debug)]
1913 #[allow(clippy::type_complexity)]
1914 struct TimeoutRecording(Arc<Mutex<Vec<(Option<Duration>, bool)>>>);
1915 impl Connector for TimeoutRecording {
1916 type Out = ();
1917 fn connect(
1918 &self,
1919 details: &ConnectionDetails,
1920 _: Option<()>,
1921 ) -> Result<Option<()>, ureq::Error> {
1922 self.0
1923 .lock()
1924 .unwrap()
1925 .push((details.config.timeouts().global, details.request_level));
1926 Err(ureq::Error::ConnectionFailed)
1927 }
1928 }
1929 let seen = Arc::new(Mutex::new(Vec::new()));
1930 let mut transport =
1931 NativeTransport::new("http://127.0.0.1", &serde_json::json!({}), None).unwrap();
1932 transport
1933 .set_policy(HostTransportPolicy {
1934 request_timeout: Some(Duration::from_millis(1234)),
1935 ..Default::default()
1936 })
1937 .unwrap();
1938 transport.agent = ureq::Agent::with_parts(
1939 ureq::Agent::config_builder().build(),
1940 TimeoutRecording(Arc::clone(&seen)),
1941 DefaultResolver::default(),
1942 );
1943 let _ = transport.blob_upload("sha256:test", &[0u8; 4], None);
1944 let seen = seen.lock().unwrap();
1945 assert_eq!(seen.len(), 1);
1946 assert_eq!(
1947 seen[0],
1948 (Some(Duration::from_millis(1234)), true),
1949 "the request-level config carries the policy timeout"
1950 );
1951 }
1952
1953 #[test]
1954 fn body_read_wrapped_timeouts_surface_the_typed_code() {
1955 struct TimeoutRead(bool);
1956 impl std::io::Read for TimeoutRead {
1957 fn read(&mut self, _: &mut [u8]) -> std::io::Result<usize> {
1958 Err(if self.0 {
1959 ureq::Error::Timeout(ureq::Timeout::Global).into_io()
1960 } else {
1961 ureq::Error::Decompress(
1962 "gzip",
1963 ureq::Error::Timeout(ureq::Timeout::Global).into_io(),
1964 )
1965 .into_io()
1966 })
1967 }
1968 }
1969 for compressed in [false, true] {
1970 let response = ureq::http::Response::builder()
1971 .status(200)
1972 .body(ureq::Body::builder().reader(TimeoutRead(compressed)))
1973 .unwrap();
1974 let error = read_body(response, None, None).unwrap_err();
1975 assert_eq!(error.code, "transport.timeout", "{compressed}");
1976 assert_eq!(
1977 error.details,
1978 Some(serde_json::json!({ "causeKind": "timeout" }))
1979 );
1980 }
1981 }
1982
1983 #[test]
1984 fn policy_validation_rejects_zero_and_unrepresentable_bounds() {
1985 assert_eq!(
1986 HostTransportPolicy {
1987 request_timeout: Some(Duration::ZERO),
1988 ..Default::default()
1989 }
1990 .validate()
1991 .unwrap_err(),
1992 "sync.invalid_request: transport config requestTimeoutMs is out of range"
1993 );
1994 assert_eq!(
1995 HostTransportPolicy {
1996 round_deadline: Some(Duration::MAX),
1997 ..Default::default()
1998 }
1999 .validate()
2000 .unwrap_err(),
2001 "sync.invalid_request: transport config roundDeadlineMs is out of range"
2002 );
2003 assert!(HostTransportPolicy {
2004 max_request_bytes: Some(0),
2005 ..Default::default()
2006 }
2007 .validate()
2008 .is_err());
2009 assert!(HostTransportPolicy {
2010 max_response_bytes: Some(0),
2011 ..Default::default()
2012 }
2013 .validate()
2014 .is_err());
2015 assert!(HostTransportPolicy::default().validate().is_ok());
2018 assert_eq!(
2019 HostTransportPolicy::from_config(&serde_json::json!({ "requestTimeoutMs": 0 }))
2020 .unwrap_err(),
2021 "sync.invalid_request: transport config requestTimeoutMs must be a positive integer"
2022 );
2023 }
2024
2025 #[test]
2026 fn set_policy_rejects_invalid_and_preserves_the_current_policy() {
2027 let mut transport =
2028 NativeTransport::new("http://127.0.0.1", &serde_json::json!({}), None).unwrap();
2029 assert_eq!(transport.policy.max_response_bytes, None);
2030 let error = transport
2031 .set_policy(HostTransportPolicy {
2032 max_response_bytes: Some(0),
2033 ..Default::default()
2034 })
2035 .unwrap_err();
2036 assert_eq!(
2037 error,
2038 "sync.invalid_request: transport config maxResponseBytes must be a positive integer"
2039 );
2040 assert_eq!(transport.policy.max_response_bytes, None);
2041 }
2042
2043 #[test]
2044 fn an_expired_round_deadline_is_typed_and_cleared_for_the_next_round() {
2045 let mut transport =
2046 NativeTransport::new("http://127.0.0.1:9", &serde_json::json!({}), None).unwrap();
2047 transport.set_round_deadline(Some(std::time::Instant::now() - Duration::from_secs(1)));
2048 let error = transport.blob_download("sha256:test").unwrap_err();
2049 assert_eq!(error.code, "transport.timeout");
2050 assert_eq!(
2051 error.details,
2052 Some(serde_json::json!({ "causeKind": "round_deadline" }))
2053 );
2054 transport.set_round_deadline(None);
2056 assert!(transport.effective_timeout().is_ok());
2057 }
2058
2059 #[test]
2060 fn an_expired_round_deadline_refuses_before_queuing_a_realtime_send() {
2061 let mut transport =
2062 NativeTransport::new("http://127.0.0.1:9", &serde_json::json!({}), None).unwrap();
2063 transport.set_round_deadline(Some(std::time::Instant::now() - Duration::from_secs(1)));
2064 let error = transport.realtime_send("blocked").unwrap_err();
2068 assert_eq!(error.code, "transport.timeout");
2069 assert_eq!(
2070 error.details,
2071 Some(serde_json::json!({ "causeKind": "round_deadline" }))
2072 );
2073 transport.set_round_deadline(None);
2076 let error = transport.realtime_send("blocked").unwrap_err();
2077 assert_eq!(error.code, "transport.failed");
2078 }
2079
2080 #[test]
2081 fn an_expired_round_deadline_bounds_the_socket_wait() {
2082 let channel = RoundChannel::default();
2083 let error = channel
2084 .wait(Some(std::time::Instant::now() - Duration::from_secs(1)))
2085 .unwrap_err();
2086 assert_eq!(error.code, "transport.timeout");
2087 assert_eq!(
2088 error.details,
2089 Some(serde_json::json!({ "causeKind": "round_deadline" }))
2090 );
2091 }
2092 }
2093}
2094
2095#[cfg(all(test, feature = "native-transport"))]
2096mod tests {
2097 use super::*;
2098 use std::io::{BufRead, BufReader, Write};
2099
2100 #[test]
2101 fn detached_round_uses_the_owned_socket_and_shutdown_releases_it() {
2102 use ssp2::model::{Frame, Message, MsgKind};
2103 use std::sync::mpsc::channel;
2104 for cancelled in [false, true] {
2105 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2106 let base = format!("http://{}", listener.local_addr().unwrap());
2107 let (entered_tx, entered_rx) = channel();
2108 let (release_tx, release_rx) = channel();
2109 let server = std::thread::spawn(move || {
2110 let (socket, _) = listener.accept().unwrap();
2111 let mut socket = tungstenite::accept(socket).unwrap();
2112 let request = socket.read().unwrap().into_data();
2113 assert_eq!(request[0], crate::REALTIME_TAG_ROUND);
2114 assert_eq!(
2115 ssp2::decode_message(&request[1..]).unwrap().msg_kind,
2116 MsgKind::Request
2117 );
2118 entered_tx.send(()).unwrap();
2119 release_rx.recv().unwrap();
2120 if !cancelled {
2121 let response = ssp2::encode_message(&Message {
2122 wire_version: ssp2::decode::WIRE_VERSION,
2123 msg_kind: MsgKind::Response,
2124 frames: vec![Frame::RespHeader {
2125 required_schema_version: None,
2126 latest_schema_version: None,
2127 log_epoch: Some("epoch".into()),
2128 reset_required: Some(true),
2129 }],
2130 });
2131 let mut chunk = vec![crate::REALTIME_TAG_ROUND];
2132 chunk.extend(response);
2133 socket
2134 .send(tungstenite::Message::Binary(chunk.into()))
2135 .unwrap();
2136 }
2137 });
2138 let mut owner =
2139 HostTransport::new_from_config(&serde_json::json!({"baseUrl":base})).unwrap();
2140 owner.realtime_connect_for_client("detached-round").unwrap();
2141 let mut detached = owner.fork_round();
2142 let network = std::thread::spawn(move || {
2143 let request = ssp2::encode_message(&Message {
2144 wire_version: ssp2::decode::WIRE_VERSION,
2145 msg_kind: MsgKind::Request,
2146 frames: vec![
2147 Frame::ReqHeader {
2148 client_id: "detached-round".into(),
2149 schema_version: 1,
2150 log_epoch: None,
2151 },
2152 Frame::PullHeader {
2153 limit_commits: 0,
2154 limit_snapshot_rows: 0,
2155 max_snapshot_pages: 0,
2156 accept: 3,
2157 },
2158 ],
2159 });
2160 detached.realtime_sync(&request)
2161 });
2162 entered_rx.recv().unwrap();
2163 owner.set_headers(vec![("authorization".into(), "Bearer rotated".into())]);
2164 assert!(owner.take_inbound().is_empty());
2165 if cancelled {
2166 owner.shutdown();
2167 }
2168 release_tx.send(()).unwrap();
2169 let response = network.join().unwrap();
2170 if cancelled {
2171 assert!(response.is_err());
2172 } else {
2173 assert_eq!(
2174 ssp2::decode_message(&response.unwrap()).unwrap().msg_kind,
2175 MsgKind::Response
2176 );
2177 owner.shutdown();
2178 }
2179 server.join().unwrap();
2180 }
2181 }
2182
2183 #[test]
2184 fn segment_http_failures_have_named_codes_and_safe_cause_details() {
2185 for signed in [false, true] {
2186 for body_failure in [false, true] {
2187 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2188 let base = format!("http://{}", listener.local_addr().unwrap());
2189 let server = std::thread::spawn(move || {
2190 let (mut socket, _) = listener.accept().unwrap();
2191 let mut reader = BufReader::new(socket.try_clone().unwrap());
2192 loop {
2193 let mut line = String::new();
2194 reader.read_line(&mut line).unwrap();
2195 if line == "\r\n" {
2196 break;
2197 }
2198 assert!(!line.is_empty());
2199 }
2200 socket.write_all(if body_failure {
2201 b"HTTP/1.1 200 OK\r\nContent-Length: 999\r\nConnection: close\r\n\r\nshort"
2202 } else {
2203 b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
2204 }).unwrap();
2205 });
2206 let mut transport =
2207 HostTransport::new_from_config(&serde_json::json!({"baseUrl": base})).unwrap();
2208 let mut progress = |_| {};
2209 let error = if signed {
2210 transport.fetch_url(&format!("{base}/signed?signature=secret"), &mut progress)
2211 } else {
2212 transport.download_segment(
2213 &SegmentRequest {
2214 segment_id: "sha256:test".into(),
2215 table: "tasks".into(),
2216 requested_scopes_json: "{}".into(),
2217 },
2218 &mut progress,
2219 )
2220 }
2221 .unwrap_err();
2222 assert_eq!(error.code, "sync.transport_failed");
2223 assert_eq!(error.message, "segment transfer failed");
2224 let details = error.details.unwrap();
2225 assert!(details.get("path").is_none());
2226 assert!(details.get("causeMessage").is_none());
2227 assert_eq!(
2228 details["causeKind"],
2229 if body_failure { "body" } else { "status" }
2230 );
2231 assert_eq!(details["httpStatus"], if body_failure { 200 } else { 403 });
2232 server.join().unwrap();
2233 }
2234 }
2235 }
2236
2237 #[test]
2238 fn blob_status_classification_uses_the_actual_http_response() {
2239 for (status, expected) in [
2240 (404, "blob.not_found"),
2241 (401, "sync.auth_required"),
2242 (403, "blob.forbidden"),
2243 (500, "transport.failed"),
2244 ] {
2245 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2246 let base = format!("http://{}", listener.local_addr().unwrap());
2247 let server = std::thread::spawn(move || {
2248 let (mut socket, _) = listener.accept().unwrap();
2249 let mut reader = BufReader::new(socket.try_clone().unwrap());
2250 loop {
2251 let mut line = String::new();
2252 reader.read_line(&mut line).unwrap();
2253 if line == "\r\n" {
2254 break;
2255 }
2256 assert!(!line.is_empty());
2257 }
2258 let body = r#"{"code":"blob.not_found","message":"https://host/secret-path?secret-query#secret-fragment","retryable":false}"#;
2259 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();
2260 });
2261 let mut transport =
2262 HostTransport::new_from_config(&serde_json::json!({"baseUrl":base})).unwrap();
2263 let error = transport.blob_download("sha256:test").unwrap_err();
2264 assert_eq!(error.code, expected);
2265 assert_eq!(
2266 error.details,
2267 Some(serde_json::json!({ "causeKind":"status", "httpStatus":status }))
2268 );
2269 assert!(!error.message.contains("secret"));
2270 assert!(!error.message.contains("http"));
2271 server.join().unwrap();
2272 }
2273 }
2274
2275 #[test]
2276 fn refused_segment_connection_has_a_named_code_without_an_invented_status() {
2277 for signed in [false, true] {
2278 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2279 let base = format!("http://{}", listener.local_addr().unwrap());
2280 drop(listener);
2281 let mut transport =
2282 HostTransport::new_from_config(&serde_json::json!({"baseUrl": base})).unwrap();
2283 let mut progress = |_| {};
2284 let error = if signed {
2285 transport.fetch_url(&format!("{base}/signed?signature=secret"), &mut progress)
2286 } else {
2287 transport.download_segment(
2288 &SegmentRequest {
2289 segment_id: "sha256:test".into(),
2290 table: "tasks".into(),
2291 requested_scopes_json: "{}".into(),
2292 },
2293 &mut progress,
2294 )
2295 }
2296 .unwrap_err();
2297 assert_eq!(error.code, "sync.transport_failed");
2298 assert!(error.details.as_ref().unwrap().get("httpStatus").is_none());
2299 }
2300 }
2301
2302 #[test]
2303 fn segment_http_reports_intermediate_bytes_for_direct_and_signed_urls() {
2304 for signed in [false, true] {
2305 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2306 let base = format!("http://{}", listener.local_addr().unwrap());
2307 let server = std::thread::spawn(move || {
2308 let (mut socket, _) = listener.accept().unwrap();
2309 let mut reader = BufReader::new(socket.try_clone().unwrap());
2310 let mut headers = String::new();
2311 loop {
2312 let mut line = String::new();
2313 reader.read_line(&mut line).unwrap();
2314 if line == "\r\n" {
2315 break;
2316 }
2317 assert!(!line.is_empty());
2318 headers.push_str(&line.to_ascii_lowercase());
2319 }
2320 assert_eq!(headers.contains("authorization: bearer test"), !signed);
2321 assert_eq!(headers.contains("x-syncular-scopes:"), !signed);
2322 let bytes = vec![7; 128 * 1024 + 17];
2323 write!(
2324 socket,
2325 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
2326 bytes.len()
2327 )
2328 .unwrap();
2329 socket.write_all(&bytes).unwrap();
2330 });
2331 let mut transport = HostTransport::from_config(
2332 &serde_json::json!({"baseUrl": base, "headers": {"authorization": "Bearer test"}}),
2333 )
2334 .unwrap();
2335 let mut updates = Vec::new();
2336 let mut progress = |bytes| updates.push(bytes);
2337 let bytes = if signed {
2338 transport
2339 .fetch_url(&format!("{base}/signed"), &mut progress)
2340 .unwrap()
2341 } else {
2342 transport
2343 .download_segment(
2344 &SegmentRequest {
2345 segment_id: "sha256:test".into(),
2346 table: "tasks".into(),
2347 requested_scopes_json: "{}".into(),
2348 },
2349 &mut progress,
2350 )
2351 .unwrap()
2352 };
2353 assert_eq!(bytes.len(), 128 * 1024 + 17);
2354 assert!(updates.iter().any(|n| *n > 0 && *n < bytes.len() as u64));
2355 assert_eq!(updates.last(), Some(&(bytes.len() as u64)));
2356 assert!(updates.windows(2).all(|pair| pair[0] < pair[1]));
2357 server.join().unwrap();
2358 }
2359 }
2360
2361 #[test]
2362 fn request_over_cap_is_refused_before_network() {
2363 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2364 listener.set_nonblocking(true).unwrap();
2365 let base = format!("http://{}", listener.local_addr().unwrap());
2366 let mut transport =
2367 HostTransport::from_config(&serde_json::json!({"baseUrl": base, "maxRequestBytes": 4}))
2368 .unwrap();
2369 let error = transport
2370 .blob_upload("sha256:test", &[0u8; 8], None)
2371 .unwrap_err();
2372 assert_eq!(error.code, "transport.request_too_large");
2373 assert_eq!(
2374 error.details,
2375 Some(serde_json::json!({ "limit": 4, "size": 8 }))
2376 );
2377 assert!(
2378 matches!(listener.accept(), Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock),
2379 "the request cap is checked before any connection"
2380 );
2381 }
2382
2383 #[test]
2384 fn credential_bearing_redirects_are_refused_without_contacting_the_destination() {
2385 for credentials in ["headers", "userinfo", "password"] {
2386 for redirects in ["deny", "follow"] {
2387 let destination = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2388 destination.set_nonblocking(true).unwrap();
2389 let location = format!("http://{}/landed", destination.local_addr().unwrap());
2390 let origin = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2391 let host = origin.local_addr().unwrap();
2392 let (base, headers) = match credentials {
2393 "headers" => (
2394 format!("http://{host}"),
2395 serde_json::json!({"authorization": "Bearer secret"}),
2396 ),
2397 "userinfo" => (format!("http://user:secret@{host}"), serde_json::json!({})),
2398 _ => (format!("http://:secret@{host}"), serde_json::json!({})),
2399 };
2400 let server = std::thread::spawn(move || {
2401 let (mut socket, _) = origin.accept().unwrap();
2402 let mut reader = BufReader::new(socket.try_clone().unwrap());
2403 loop {
2404 let mut line = String::new();
2405 reader.read_line(&mut line).unwrap();
2406 if line == "\r\n" {
2407 break;
2408 }
2409 }
2410 let _ = write!(
2411 socket,
2412 "HTTP/1.1 302 Found\r\nLocation: {location}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
2413 );
2414 });
2415 let mut transport = HostTransport::from_config(&serde_json::json!({
2416 "baseUrl": base,
2417 "redirects": redirects,
2418 "headers": headers,
2419 }))
2420 .unwrap();
2421 let error = transport.blob_download("sha256:test").unwrap_err();
2422 assert_eq!(
2423 error.code, "transport.redirect",
2424 "{credentials} {redirects}"
2425 );
2426 let details = error.details.unwrap();
2427 assert_eq!(details["causeKind"], "redirect");
2428 assert_eq!(details["httpStatus"], 302);
2429 assert!(
2430 matches!(destination.accept(), Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock),
2431 "{credentials} {redirects}: the destination is never contacted"
2432 );
2433 server.join().unwrap();
2434 }
2435 }
2436 }
2437
2438 #[test]
2439 fn websocket_handshake_redirects_obey_the_redirect_policy() {
2440 for (credential, redirects, refused) in [
2446 ("headers", "deny", true),
2447 ("headers", "follow", true),
2448 ("userinfo", "follow", true),
2449 ("query", "follow", true),
2450 ("none", "deny", true),
2451 ("none", "follow", false),
2452 ] {
2453 let destination = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2454 let destination_addr = destination.local_addr().unwrap();
2455 let location = format!("ws://{destination_addr}/realtime");
2456 let landed = Arc::new(Mutex::new(Vec::new()));
2460 let landed_writer = Arc::clone(&landed);
2461 let destination_thread = std::thread::spawn(move || loop {
2462 let (mut socket, _) = destination.accept().unwrap();
2463 let mut reader = BufReader::new(socket.try_clone().unwrap());
2464 let mut head = String::new();
2465 loop {
2466 let mut line = String::new();
2467 reader.read_line(&mut line).unwrap();
2468 if line == "\r\n" {
2469 break;
2470 }
2471 head.push_str(&line);
2472 }
2473 let sentinel = head.starts_with("GET /sentinel");
2474 landed_writer.lock().unwrap().push(head);
2475 let _ = write!(
2476 socket,
2477 "HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
2478 );
2479 if sentinel {
2480 break;
2481 }
2482 });
2483 let origin = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2484 let origin_addr = origin.local_addr().unwrap();
2485 let (headers, ws_url) = match credential {
2486 "headers" => (serde_json::json!({"authorization": "Bearer secret"}), None),
2487 "userinfo" => (
2488 serde_json::json!({}),
2489 Some(format!("ws://user:secret@{origin_addr}/realtime")),
2490 ),
2491 "query" => (
2492 serde_json::json!({}),
2493 Some(format!("ws://{origin_addr}/realtime?token=secret")),
2494 ),
2495 _ => (serde_json::json!({}), None),
2496 };
2497 let (head_tx, head_rx) = std::sync::mpsc::channel();
2498 let origin_thread = std::thread::spawn(move || {
2499 let (mut socket, _) = origin.accept().unwrap();
2500 let mut reader = BufReader::new(socket.try_clone().unwrap());
2501 let mut head = String::new();
2502 loop {
2503 let mut line = String::new();
2504 reader.read_line(&mut line).unwrap();
2505 if line == "\r\n" {
2506 break;
2507 }
2508 head.push_str(&line);
2509 }
2510 let _ = head_tx.send(head);
2511 let _ = write!(
2512 socket,
2513 "HTTP/1.1 302 Found\r\nLocation: {location}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
2514 );
2515 });
2516 let mut config = serde_json::json!({
2517 "baseUrl": format!("http://{origin_addr}"),
2518 "headers": headers,
2519 "redirects": redirects,
2520 });
2521 if let Some(ws_url) = &ws_url {
2522 config["wsUrl"] = serde_json::json!(ws_url);
2523 }
2524 let mut transport = HostTransport::from_config(&config).unwrap();
2525 let result = transport.realtime_connect();
2526 let mut sentinel = std::net::TcpStream::connect(destination_addr).unwrap();
2529 let _ = write!(sentinel, "GET /sentinel HTTP/1.1\r\nHost: sentinel\r\n\r\n");
2530 destination_thread.join().unwrap();
2531 let landed = landed.lock().unwrap().clone();
2532 origin_thread.join().unwrap();
2533 if refused {
2534 let error = result.unwrap_err();
2535 assert_eq!(
2536 error.code, "transport.redirect",
2537 "{credential} {redirects}: a refused handshake redirect surfaces the redirect code"
2538 );
2539 let details = error.details.unwrap();
2540 assert_eq!(details["causeKind"], "redirect");
2541 assert_eq!(details["httpStatus"], 302);
2542 assert_eq!(
2543 landed.len(),
2544 1,
2545 "{credential} {redirects}: the redirect target is never contacted: {landed:?}"
2546 );
2547 assert!(landed[0].starts_with("GET /sentinel"), "{landed:?}");
2548 let head = head_rx.recv().unwrap();
2549 if credential == "userinfo" {
2550 assert!(
2551 !head.contains("secret"),
2552 "the handshake request line drops URL userinfo"
2553 );
2554 }
2555 if credential == "query" {
2556 assert!(
2557 head.contains("token=secret"),
2558 "the handshake request line carries the realtime query"
2559 );
2560 }
2561 } else {
2562 let error = result.unwrap_err();
2566 assert_eq!(error.code, "transport.failed", "{credential} {redirects}");
2567 assert_eq!(
2568 error.details.unwrap()["httpStatus"],
2569 400,
2570 "{credential} {redirects}: the redirect target answered"
2571 );
2572 assert_eq!(landed.len(), 2, "{landed:?}");
2573 assert!(landed[0].starts_with("GET /realtime"), "{landed:?}");
2574 }
2575 }
2576 }
2577
2578 const GZIP_BOMB: [u8; 548] = [
2581 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xed, 0xc9, 0xb1, 0x11, 0x80,
2582 0x20, 0x10, 0x00, 0xb0, 0x59, 0x1c, 0x80, 0xa1, 0xfe, 0x90, 0xb3, 0x41, 0xf0, 0x7c, 0x2d,
2583 0xd8, 0xde, 0x09, 0xdc, 0x20, 0x69, 0x93, 0x6b, 0xd4, 0xb7, 0xc7, 0x5d, 0xb2, 0x1d, 0x67,
2584 0x1b, 0x4f, 0xb9, 0x62, 0xf5, 0x19, 0xfb, 0x96, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
2585 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
2586 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
2587 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
2588 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
2589 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
2590 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
2591 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
2592 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
2593 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
2594 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
2595 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
2596 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
2597 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
2598 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
2599 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
2600 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
2601 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
2602 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
2603 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
2604 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
2605 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
2606 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
2607 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
2608 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
2609 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
2610 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
2611 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
2612 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
2613 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
2614 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
2615 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
2616 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0xf8, 0x8b, 0x0f,
2617 0xdb, 0x82, 0xf4, 0xa9, 0x40, 0x0d, 0x03, 0x00,
2618 ];
2619
2620 #[test]
2621 fn decoded_response_cap_counts_decompressed_bytes() {
2622 for cap in [200000u64, 199999] {
2623 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2624 let base = format!("http://{}", listener.local_addr().unwrap());
2625 let server = std::thread::spawn(move || {
2626 let (mut socket, _) = listener.accept().unwrap();
2627 let mut reader = BufReader::new(socket.try_clone().unwrap());
2628 loop {
2629 let mut line = String::new();
2630 reader.read_line(&mut line).unwrap();
2631 if line == "\r\n" {
2632 break;
2633 }
2634 }
2635 let _ = write!(
2636 socket,
2637 "HTTP/1.1 200 OK\r\nContent-Encoding: gzip\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
2638 GZIP_BOMB.len()
2639 );
2640 let _ = socket.write_all(&GZIP_BOMB);
2641 });
2642 let mut transport = HostTransport::from_config(
2643 &serde_json::json!({"baseUrl": base, "maxResponseBytes": cap}),
2644 )
2645 .unwrap();
2646 let result = transport.fetch_blob_url(&format!("{base}/signed"));
2647 if cap == 200000 {
2648 let body = result.expect("a cap equal to the decoded size admits the body");
2649 assert_eq!(body.len(), 200000);
2650 } else {
2651 let error = result.unwrap_err();
2652 assert_eq!(error.code, "transport.response_too_large");
2653 assert_eq!(error.details, Some(serde_json::json!({ "limit": cap })));
2654 }
2655 server.join().unwrap();
2656 }
2657 }
2658}