Skip to main content

syncular_client/
native_transport.rs

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