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