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//! Inbound realtime frames land in a shared queue the host drains after each
15//! command. An optional wake callback lets mailbox-driven hosts react without
16//! polling. The socket implementation is deliberately singular: fairness and
17//! wire fixes therefore reach FFI, Tauri, and future Rust hosts together.
18
19use std::sync::{Arc, Mutex};
20use std::time::Duration;
21
22use crate::{
23    BlobDownload, BlobUploadGrant, SegmentFetch, SegmentRequest, Transport, TransportError,
24};
25
26/// Redirect handling for native HTTP requests and the WebSocket handshake.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
28pub enum RedirectPolicy {
29    /// Never follow a 3xx. A redirected request would replay configured
30    /// headers, URL userinfo, or a signed capability to an unverified
31    /// destination, so refusal is the default. A refused redirect surfaces
32    /// `transport.redirect`.
33    #[default]
34    Deny,
35    /// Follow a redirect for a request that carries neither configured
36    /// headers, base-URL userinfo, nor a signed capability URL. A
37    /// credential-bearing request is still refused even under this policy,
38    /// because ureq forwards configured headers and URL userinfo to the
39    /// redirect target, and tungstenite forwards configured headers to a
40    /// WebSocket redirect target. A WebSocket handshake additionally may not
41    /// follow a redirect when its realtime URL carries userinfo or a query
42    /// (a signed capability). Enable only for a deployment whose
43    /// unauthenticated requests may safely move.
44    Follow,
45}
46
47/// Bounds and behavior for the native host transport. `Default` matches the
48/// reference HTTP binding: no deadline and no body cap, with redirects denied.
49#[derive(Debug, Clone, Default)]
50pub struct HostTransportPolicy {
51    /// End-to-end deadline for one request (DNS through response body).
52    pub request_timeout: Option<Duration>,
53    /// One monotonic deadline for a whole sync round: uploads, continuations,
54    /// the main request, and every segment fetch. Anchored once on the round's
55    /// first network call; a continuation never resets it. Independent of the
56    /// per-request deadline, which still bounds each individual call.
57    pub round_deadline: Option<Duration>,
58    /// Largest HTTP request body the transport will send. Checked before any
59    /// network I/O. Does not cap realtime socket buffers.
60    pub max_request_bytes: Option<u64>,
61    /// Largest decoded (post-decompression) HTTP response body the transport
62    /// will accept. Does not cap realtime socket buffers.
63    pub max_response_bytes: Option<u64>,
64    pub redirects: RedirectPolicy,
65}
66
67impl HostTransportPolicy {
68    /// Reject a bound the transport cannot honor: a zero or unrepresentable
69    /// duration, or a zero byte limit. Called on both the JSON and the
70    /// programmatic path, so `Follow`/`Deny` and every numeric field agree.
71    pub fn validate(&self) -> Result<(), String> {
72        let now = std::time::Instant::now();
73        if let Some(timeout) = self.request_timeout {
74            if timeout.is_zero() || now.checked_add(timeout).is_none() {
75                return Err(
76                    "sync.invalid_request: transport config requestTimeoutMs is out of range"
77                        .to_owned(),
78                );
79            }
80        }
81        if let Some(deadline) = self.round_deadline {
82            if deadline.is_zero() || now.checked_add(deadline).is_none() {
83                return Err(
84                    "sync.invalid_request: transport config roundDeadlineMs is out of range"
85                        .to_owned(),
86                );
87            }
88        }
89        if self.max_request_bytes == Some(0) {
90            return Err(
91                "sync.invalid_request: transport config maxRequestBytes must be a positive integer"
92                    .to_owned(),
93            );
94        }
95        if self.max_response_bytes == Some(0) {
96            return Err("sync.invalid_request: transport config maxResponseBytes must be a positive integer".to_owned());
97        }
98        Ok(())
99    }
100
101    /// Parse the policy from the transport JSON config. Absent keys keep their
102    /// defaults; a wrong type or an out-of-range bound is rejected loudly.
103    pub fn from_config(config: &serde_json::Value) -> Result<Self, String> {
104        let bound = |key: &str, message: &'static str| -> Result<Option<u64>, String> {
105            match config.get(key) {
106                None | Some(serde_json::Value::Null) => Ok(None),
107                Some(value) => match value.as_u64() {
108                    Some(number) if number >= 1 => Ok(Some(number)),
109                    _ => Err(message.to_owned()),
110                },
111            }
112        };
113        let redirects = match config.get("redirects") {
114            None | Some(serde_json::Value::Null) => RedirectPolicy::Deny,
115            Some(serde_json::Value::String(value)) if value == "deny" => RedirectPolicy::Deny,
116            Some(serde_json::Value::String(value)) if value == "follow" => RedirectPolicy::Follow,
117            Some(_) => return Err(
118                "sync.invalid_request: transport config redirects must be \"deny\" or \"follow\""
119                    .to_owned(),
120            ),
121        };
122        let policy = Self {
123            request_timeout: bound(
124                "requestTimeoutMs",
125                "sync.invalid_request: transport config requestTimeoutMs must be a positive integer",
126            )?
127            .map(Duration::from_millis),
128            round_deadline: bound(
129                "roundDeadlineMs",
130                "sync.invalid_request: transport config roundDeadlineMs must be a positive integer",
131            )?
132            .map(Duration::from_millis),
133            max_request_bytes: bound(
134                "maxRequestBytes",
135                "sync.invalid_request: transport config maxRequestBytes must be a positive integer",
136            )?,
137            max_response_bytes: bound(
138                "maxResponseBytes",
139                "sync.invalid_request: transport config maxResponseBytes must be a positive integer",
140            )?,
141            redirects,
142        };
143        policy.validate()?;
144        Ok(policy)
145    }
146}
147
148/// One inbound realtime frame buffered for the client's `on_realtime_*`.
149pub enum Inbound {
150    Text(String),
151    Binary(Vec<u8>),
152    /// The realtime connection ended without the host closing it (the peer closed an idle socket, a read or write failed, or a
153    /// send was abandoned). Pushed before any round or send waiter is woken, so the host drains it before it reconnects.
154    Disconnected(TransportError),
155}
156
157/// The shared inbound buffer the WS reader thread fills and the command path
158/// drains. The optional callback carries no data; it only wakes an owning
159/// mailbox so the host can drain the buffer promptly.
160pub struct InboundBuffer {
161    frames: Mutex<Vec<Inbound>>,
162    notify: Option<Arc<dyn Fn() + Send + Sync>>,
163}
164
165impl Default for InboundBuffer {
166    fn default() -> Self {
167        Self {
168            frames: Mutex::new(Vec::new()),
169            notify: None,
170        }
171    }
172}
173
174impl InboundBuffer {
175    pub fn with_notify(notify: Arc<dyn Fn() + Send + Sync>) -> Self {
176        Self {
177            frames: Mutex::new(Vec::new()),
178            notify: Some(notify),
179        }
180    }
181
182    pub fn push(&self, frame: Inbound) {
183        self.frames.lock().expect("inbound lock").push(frame);
184        if let Some(notify) = &self.notify {
185            notify();
186        }
187    }
188    fn take(&self) -> Vec<Inbound> {
189        std::mem::take(&mut *self.frames.lock().expect("inbound lock"))
190    }
191}
192
193// One transport is held per host, so the native arm's size is not worth an
194// indirection; boxing it would change the public variant's payload type.
195#[allow(clippy::large_enum_variant)]
196pub enum HostTransport {
197    /// No network: client-local commands only (dependency-lean default).
198    Null {
199        signed_urls: bool,
200        inbound: Arc<InboundBuffer>,
201    },
202    #[cfg(feature = "native-transport")]
203    Native(native::NativeTransport),
204}
205
206impl HostTransport {
207    /// Build the transport from the `new` config. `{}` (or no `baseUrl`) →
208    /// `Null`; a `baseUrl` under the `native-transport` feature → `Native`.
209    pub fn from_config(config: &serde_json::Value) -> Result<Self, String> {
210        Self::new_from_config(config)
211    }
212
213    /// [`Self::from_config`] for hosts outside this crate (the bench driver)
214    /// that own their inbound-frame drain and need no FFI event queue.
215    pub fn new_from_config(config: &serde_json::Value) -> Result<Self, String> {
216        Self::from_config_with_notify(config, None)
217    }
218
219    /// Build a transport whose realtime reader wakes a mailbox/event loop.
220    /// The callback is invoked after a frame is buffered and never carries
221    /// protocol data itself.
222    pub fn from_config_with_notify(
223        config: &serde_json::Value,
224        notify: Option<Arc<dyn Fn() + Send + Sync>>,
225    ) -> Result<Self, String> {
226        let policy = HostTransportPolicy::from_config(config)?;
227        Self::from_config_with_policy(config, policy, notify)
228    }
229
230    /// Build the transport with an explicit programmatic policy, ignoring any
231    /// policy keys the JSON config carries. `baseUrl` selection is unchanged.
232    pub fn from_config_with_policy(
233        config: &serde_json::Value,
234        policy: HostTransportPolicy,
235        notify: Option<Arc<dyn Fn() + Send + Sync>>,
236    ) -> Result<Self, String> {
237        #[cfg(feature = "native-transport")]
238        {
239            if let Some(base_url) = config.get("baseUrl").and_then(|v| v.as_str()) {
240                return Ok(HostTransport::Native(native::NativeTransport::with_policy(
241                    base_url, config, policy, notify,
242                )?));
243            }
244        }
245        #[cfg(not(feature = "native-transport"))]
246        {
247            policy.validate()?;
248            if config.get("baseUrl").is_some() {
249                return Err(
250                    "this build has no native transport (rebuild with --features native-transport)"
251                        .to_owned(),
252                );
253            }
254        }
255        Ok(HostTransport::Null {
256            signed_urls: false,
257            inbound: Arc::new(match notify {
258                Some(notify) => InboundBuffer::with_notify(notify),
259                None => InboundBuffer::default(),
260            }),
261        })
262    }
263
264    /// Network view for one I/O executor. The mutable owner keeps the reader
265    /// and inbound queue; no SQLite state or new socket is created.
266    pub fn fork_round(&self) -> Self {
267        match self {
268            Self::Null {
269                signed_urls,
270                inbound,
271            } => Self::Null {
272                signed_urls: *signed_urls,
273                inbound: inbound.clone(),
274            },
275            #[cfg(feature = "native-transport")]
276            Self::Native(t) => Self::Native(t.fork_round()),
277        }
278    }
279
280    pub fn set_signed_urls(&mut self, value: bool) {
281        match self {
282            HostTransport::Null { signed_urls, .. } => *signed_urls = value,
283            #[cfg(feature = "native-transport")]
284            HostTransport::Native(t) => t.signed_urls = value,
285        }
286    }
287
288    /// Replace auth/application headers for subsequent HTTP requests and the
289    /// next realtime connection. A live socket retains its handshake headers.
290    pub fn set_headers(&mut self, headers: Vec<(String, String)>) {
291        match self {
292            HostTransport::Null { .. } => drop(headers),
293            #[cfg(feature = "native-transport")]
294            HostTransport::Native(t) => t.set_headers(headers),
295        }
296    }
297
298    /// Replace the transport's bounds for subsequent network calls. A round
299    /// past its first exchange keeps its anchored deadline; every other bound
300    /// takes effect on the next call. An invalid policy is rejected and the
301    /// current policy stays in force.
302    pub fn set_policy(&mut self, policy: HostTransportPolicy) -> Result<(), String> {
303        policy.validate()?;
304        match self {
305            HostTransport::Null { .. } => {}
306            #[cfg(feature = "native-transport")]
307            HostTransport::Native(t) => t.set_policy(policy)?,
308        }
309        Ok(())
310    }
311
312    /// Drain the inbound realtime frames buffered since the last call.
313    pub fn take_inbound(&mut self) -> Vec<Inbound> {
314        match self {
315            HostTransport::Null { inbound, .. } => inbound.take(),
316            #[cfg(feature = "native-transport")]
317            HostTransport::Native(t) => t.inbound.take(),
318        }
319    }
320
321    /// Release the socket/reader thread. Idempotent.
322    pub fn shutdown(&mut self) {
323        match self {
324            HostTransport::Null { .. } => {}
325            #[cfg(feature = "native-transport")]
326            HostTransport::Native(t) => t.shutdown(),
327        }
328    }
329}
330
331fn unavailable(op: &str) -> TransportError {
332    TransportError::new(
333        "transport.unavailable",
334        format!("{op} needs the native transport (build with --features native-transport)"),
335    )
336}
337
338// Without the native transport the `Null` arm ignores every request payload;
339// the params are only consumed by the feature-gated `Native` arm.
340#[cfg_attr(not(feature = "native-transport"), allow(unused_variables))]
341impl Transport for HostTransport {
342    fn sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
343        match self {
344            HostTransport::Null { .. } => Err(unavailable("sync")),
345            #[cfg(feature = "native-transport")]
346            HostTransport::Native(t) => t.sync(request),
347        }
348    }
349
350    fn remote_operation(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
351        match self {
352            HostTransport::Null { .. } => Err(unavailable("remoteOperation")),
353            #[cfg(feature = "native-transport")]
354            HostTransport::Native(t) => t.remote_operation(request),
355        }
356    }
357
358    fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
359        match self {
360            HostTransport::Null { .. } => Err(unavailable("realtimeSync")),
361            #[cfg(feature = "native-transport")]
362            HostTransport::Native(t) => t.realtime_sync(request),
363        }
364    }
365
366    fn download_segment(
367        &mut self,
368        request: &SegmentRequest,
369        on_progress: &mut dyn FnMut(u64),
370    ) -> Result<Vec<u8>, TransportError> {
371        match self {
372            HostTransport::Null { .. } => Err(unavailable("downloadSegment")),
373            #[cfg(feature = "native-transport")]
374            HostTransport::Native(t) => t.download_segment(request, on_progress),
375        }
376    }
377
378    fn supports_url_fetch(&self) -> bool {
379        match self {
380            HostTransport::Null { signed_urls, .. } => *signed_urls,
381            #[cfg(feature = "native-transport")]
382            HostTransport::Native(t) => t.signed_urls,
383        }
384    }
385
386    fn download_segments(
387        &mut self,
388        fetches: &[SegmentFetch],
389        on_progress: &mut (dyn FnMut(usize, u64) + Send),
390    ) -> Vec<Result<Vec<u8>, TransportError>> {
391        match self {
392            HostTransport::Null { .. } => fetches
393                .first()
394                .map(|_| Err(unavailable("downloadSegments")))
395                .into_iter()
396                .collect(),
397            #[cfg(feature = "native-transport")]
398            HostTransport::Native(t) => t.download_segments(fetches, on_progress),
399        }
400    }
401
402    fn fetch_url(
403        &mut self,
404        url: &str,
405        on_progress: &mut dyn FnMut(u64),
406    ) -> Result<Vec<u8>, TransportError> {
407        match self {
408            HostTransport::Null { .. } => Err(unavailable("fetchUrl")),
409            #[cfg(feature = "native-transport")]
410            HostTransport::Native(t) => t.fetch_url(url, on_progress),
411        }
412    }
413
414    fn blob_upload(
415        &mut self,
416        blob_id: &str,
417        bytes: &[u8],
418        media_type: Option<&str>,
419    ) -> Result<(), TransportError> {
420        match self {
421            HostTransport::Null { .. } => Err(unavailable("blobUpload")),
422            #[cfg(feature = "native-transport")]
423            HostTransport::Native(t) => t.blob_upload(blob_id, bytes, media_type),
424        }
425    }
426
427    fn blob_download(&mut self, blob_id: &str) -> Result<BlobDownload, TransportError> {
428        match self {
429            HostTransport::Null { .. } => Err(unavailable("blobDownload")),
430            #[cfg(feature = "native-transport")]
431            HostTransport::Native(t) => t.blob_download(blob_id),
432        }
433    }
434
435    fn fetch_blob_url(&mut self, url: &str) -> Result<Vec<u8>, TransportError> {
436        match self {
437            HostTransport::Null { .. } => Err(unavailable("fetchBlobUrl")),
438            #[cfg(feature = "native-transport")]
439            HostTransport::Native(t) => t.fetch_blob_url(url),
440        }
441    }
442
443    fn blob_upload_grant(
444        &mut self,
445        blob_id: &str,
446        byte_length: u64,
447        media_type: Option<&str>,
448    ) -> Result<BlobUploadGrant, TransportError> {
449        match self {
450            // No grant available ⇒ the client streams through the direct
451            // upload endpoint (§5.9.3 capability, not fallback).
452            HostTransport::Null { .. } => Ok(BlobUploadGrant::None),
453            #[cfg(feature = "native-transport")]
454            HostTransport::Native(t) => t.blob_upload_grant(blob_id, byte_length, media_type),
455        }
456    }
457
458    fn blob_put_url(
459        &mut self,
460        url: &str,
461        bytes: &[u8],
462        media_type: Option<&str>,
463    ) -> Result<(), TransportError> {
464        match self {
465            HostTransport::Null { .. } => Err(unavailable("blobPutUrl")),
466            #[cfg(feature = "native-transport")]
467            HostTransport::Native(t) => t.blob_put_url(url, bytes, media_type),
468        }
469    }
470
471    fn realtime_connect(&mut self) -> Result<(), TransportError> {
472        match self {
473            HostTransport::Null { .. } => Err(unavailable("realtimeConnect")),
474            #[cfg(feature = "native-transport")]
475            HostTransport::Native(t) => {
476                t.set_realtime_client_id(None);
477                t.realtime_connect()
478            }
479        }
480    }
481
482    fn realtime_connect_for_client(&mut self, client_id: &str) -> Result<(), TransportError> {
483        match self {
484            HostTransport::Null { .. } => Err(unavailable("realtimeConnect")),
485            #[cfg(feature = "native-transport")]
486            HostTransport::Native(t) => {
487                t.set_realtime_client_id(Some(client_id));
488                t.realtime_connect()
489            }
490        }
491    }
492
493    fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
494        match self {
495            HostTransport::Null { .. } => Err(unavailable("realtimeSend")),
496            #[cfg(feature = "native-transport")]
497            HostTransport::Native(t) => t.realtime_send(text),
498        }
499    }
500
501    fn realtime_close(&mut self) -> Result<(), TransportError> {
502        match self {
503            HostTransport::Null { .. } => Ok(()),
504            #[cfg(feature = "native-transport")]
505            HostTransport::Native(t) => t.realtime_close(),
506        }
507    }
508
509    fn round_deadline(&self) -> Option<std::time::Duration> {
510        match self {
511            HostTransport::Null { .. } => None,
512            #[cfg(feature = "native-transport")]
513            HostTransport::Native(t) => t.round_deadline(),
514        }
515    }
516
517    fn set_round_deadline(&mut self, deadline: Option<std::time::Instant>) {
518        match self {
519            HostTransport::Null { .. } => {}
520            #[cfg(feature = "native-transport")]
521            HostTransport::Native(t) => t.set_round_deadline(deadline),
522        }
523    }
524}
525
526#[cfg(feature = "native-transport")]
527mod native {
528    //! The real native HTTP + WS transport. HTTP via `ureq` (blocking, no
529    //! async runtime — matches the client's synchronous API); WS via
530    //! `tungstenite`, with a reader thread pushing inbound frames into the
531    //! shared `InboundBuffer`.
532    //!
533    //! Wire contract mirrors the reference HTTP+WS bindings (§1.1, §8.7):
534    //! `POST {baseUrl}/sync` (application/vnd.syncular.sync.v2, `X-Syncular-Scopes`
535    //! not needed here — the native app authenticates via configured headers),
536    //! `GET {baseUrl}/segments/{id}`, `PUT/GET {baseUrl}/blobs/{id}`, and the
537    //! realtime socket at `{wsUrl}` (ws(s):// derived from baseUrl).
538
539    use std::io::{self, Read, Write};
540    use std::net::{TcpStream, ToSocketAddrs};
541    use std::sync::atomic::{AtomicBool, Ordering};
542    use std::sync::{mpsc, Arc, Condvar, Mutex};
543    use std::thread::JoinHandle;
544    use std::time::{Duration, Instant};
545
546    use polling::{Event, Events, Poller};
547    use tungstenite::stream::MaybeTlsStream;
548    use tungstenite::Message;
549
550    use super::{HostTransportPolicy, Inbound, InboundBuffer, RedirectPolicy};
551    use crate::{
552        BlobDownload, BlobUploadGrant, RealtimeRound, RoundInbound, SegmentFetch, SegmentRequest,
553        Transport, TransportError,
554    };
555
556    // Unregister before the cloned socket handle is closed, including on unwind.
557    struct SocketRegistration {
558        poller: Arc<Poller>,
559        stream: TcpStream,
560    }
561
562    impl Drop for SocketRegistration {
563        fn drop(&mut self) {
564            let _ = self.poller.delete(&self.stream);
565        }
566    }
567
568    #[derive(Clone)]
569    struct SocketSender {
570        queue: mpsc::SyncSender<Outgoing>,
571        poller: Arc<Poller>,
572    }
573
574    struct Outgoing {
575        message: Message,
576        completed: mpsc::Sender<Result<(), TransportError>>,
577    }
578
579    /// How long a single response round waits before giving up (§8.7 rounds
580    /// are bounded — bulk rides segments over HTTP). Generous; a stuck socket
581    /// surfaces as a transport failure rather than hanging the caller forever.
582    const ROUND_TIMEOUT: Duration = Duration::from_secs(30);
583    /// The §8.7 round rendezvous shared between the reader thread (which
584    /// demuxes inbound `0x01` chunks into the round via [`RealtimeRound`]) and
585    /// `realtime_sync` (which begins the round, sends the request, and blocks
586    /// here for the reassembled response). The transport-agnostic framing
587    /// logic lives in [`RealtimeRound`] (the lean client crate, shared with
588    /// the Tauri plugin); this struct is just the thread rendezvous.
589    #[derive(Default)]
590    pub(super) struct RoundChannel {
591        state: Mutex<RoundState>,
592        ready: Condvar,
593    }
594
595    #[derive(Default)]
596    struct RoundState {
597        round: RealtimeRound,
598        /// The completed round outcome, taken by `realtime_sync` once set.
599        outcome: Option<Result<Vec<u8>, TransportError>>,
600    }
601
602    impl RoundChannel {
603        /// Begin a round: frame the request (`0x01` tag + envelope) for the
604        /// socket and mark it in flight. Errors if one is already in flight
605        /// (§8.7 one-in-flight, enforced client-side).
606        fn begin(&self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
607            let mut state = self.state.lock().expect("round lock");
608            state.outcome = None;
609            state.round.begin(request)
610        }
611
612        /// Route one inbound binary frame from the reader thread. Returns the
613        /// delta payload to enqueue on the inbound buffer, if any; a completed
614        /// or failed round is stored and the waiting `realtime_sync` woken.
615        fn route_binary(&self, frame: &[u8]) -> Option<Vec<u8>> {
616            let mut state = self.state.lock().expect("round lock");
617            match state.round.route_binary(frame) {
618                Ok(RoundInbound::Delta(body)) => Some(body),
619                Ok(RoundInbound::RoundProgress) | Ok(RoundInbound::Ignored) => None,
620                Ok(RoundInbound::RoundComplete(bytes)) => {
621                    state.outcome = Some(Ok(bytes));
622                    self.ready.notify_all();
623                    None
624                }
625                Err(error) => {
626                    state.outcome = Some(Err(error));
627                    self.ready.notify_all();
628                    None
629                }
630            }
631        }
632
633        /// Fail any in-flight round (socket dropped) and wake the waiter.
634        fn fail_in_flight(&self, error: TransportError) {
635            let mut state = self.state.lock().expect("round lock");
636            if state.round.in_flight() && state.outcome.is_none() {
637                state.round.abort();
638                state.outcome = Some(Err(error));
639                self.ready.notify_all();
640            }
641        }
642
643        /// Block until the round completes, fails, or the binding deadline
644        /// elapses. `round_deadline` is the transport's whole-round deadline,
645        /// intersected with the socket's own `ROUND_TIMEOUT`.
646        fn wait(
647            &self,
648            round_deadline: Option<std::time::Instant>,
649        ) -> Result<Vec<u8>, TransportError> {
650            let mut state = self.state.lock().expect("round lock");
651            let hard = std::time::Instant::now() + ROUND_TIMEOUT;
652            let round_binding = round_deadline.is_some_and(|round| round < hard);
653            let deadline = match round_deadline {
654                Some(round) if round_binding => round,
655                _ => hard,
656            };
657            while state.outcome.is_none() {
658                let now = std::time::Instant::now();
659                if now >= deadline {
660                    state.round.abort();
661                    return Err(if round_binding {
662                        timeout_error("sync round deadline exceeded", "round_deadline")
663                    } else {
664                        TransportError::new(
665                            "sync.transport_failed",
666                            "realtime sync round timed out (§8.7)",
667                        )
668                    });
669                }
670                let (guard, _timeout) = self
671                    .ready
672                    .wait_timeout(state, deadline - now)
673                    .expect("round wait");
674                state = guard;
675            }
676            state.outcome.take().expect("outcome present")
677        }
678    }
679
680    fn transfer_error(
681        message: &'static str,
682        cause_kind: &'static str,
683        http_status: Option<u16>,
684    ) -> TransportError {
685        let mut details = serde_json::json!({ "causeKind": cause_kind });
686        if let Some(status) = http_status {
687            details["httpStatus"] = status.into();
688        }
689        let mut error = TransportError::new("transport.failed", message);
690        error.details = Some(details);
691        error
692    }
693
694    /// A configured deadline elapsed. `cause_kind` distinguishes the
695    /// per-request timeout from the whole-round deadline.
696    fn timeout_error(message: &'static str, cause_kind: &'static str) -> TransportError {
697        let mut error = TransportError::new("transport.timeout", message);
698        error.details = Some(serde_json::json!({ "causeKind": cause_kind }));
699        error
700    }
701
702    /// A 3xx was refused (either by policy or by exhausting the follow budget).
703    fn redirect_error(message: &'static str, http_status: Option<u16>) -> TransportError {
704        let mut details = serde_json::json!({ "causeKind": "redirect" });
705        if let Some(status) = http_status {
706            details["httpStatus"] = status.into();
707        }
708        let mut error = TransportError::new("transport.redirect", message);
709        error.details = Some(details);
710        error
711    }
712
713    /// A 3xx that reached the caller means the redirect was refused (the
714    /// request was credential bearing or the policy denies redirects). ureq
715    /// returns such a response as-is when the ceiling is zero, so the refusal
716    /// is detected here rather than in the error classifier.
717    fn redirect_refused(resp: &ureq::http::Response<ureq::Body>) -> Result<(), TransportError> {
718        let status = resp.status().as_u16();
719        if (300..400).contains(&status) {
720            return Err(redirect_error("redirect refused", Some(status)));
721        }
722        Ok(())
723    }
724
725    /// Classify a body-read timeout through ureq's wrapping chain without
726    /// formatting it: `Error::into_io` boxes a non-io ureq error as
727    /// `io::Error::other`, and the gzip decoder re-wraps a plain io error as
728    /// `Error::Decompress("gzip", e)`. Direct, nested, and plain shapes all
729    /// count.
730    fn is_timeout_io(error: &std::io::Error) -> bool {
731        if error.kind() == std::io::ErrorKind::TimedOut {
732            return true;
733        }
734        let Some(inner) = error.get_ref() else {
735            return false;
736        };
737        let Some(ureq_error) = inner.downcast_ref::<ureq::Error>() else {
738            return false;
739        };
740        match ureq_error {
741            ureq::Error::Timeout(_) => true,
742            ureq::Error::Io(nested) => is_timeout_io(nested),
743            ureq::Error::Decompress(_, nested) => is_timeout_io(nested),
744            _ => false,
745        }
746    }
747
748    fn http_err(message: &'static str, error: ureq::Error) -> TransportError {
749        let (kind, status) = match error {
750            ureq::Error::StatusCode(status) if (300..400).contains(&status) => {
751                return redirect_error(message, Some(status))
752            }
753            ureq::Error::StatusCode(status) => ("status", Some(status)),
754            ureq::Error::Timeout(_) => return timeout_error(message, "timeout"),
755            ureq::Error::Io(error) => return io_err(message, error),
756            ureq::Error::HostNotFound
757            | ureq::Error::ConnectionFailed
758            | ureq::Error::ConnectProxyFailed(_) => ("connect", None),
759            ureq::Error::Tls(_)
760            | ureq::Error::Rustls(_)
761            | ureq::Error::Pem(_)
762            | ureq::Error::TlsRequired => ("tls", None),
763            ureq::Error::BadUri(_)
764            | ureq::Error::Http(_)
765            | ureq::Error::RequireHttpsOnly(_)
766            | ureq::Error::InvalidProxyUrl => ("request", None),
767            ureq::Error::Protocol(_) => ("protocol", None),
768            ureq::Error::BodyExceedsLimit(_)
769            | ureq::Error::BodyStalled
770            | ureq::Error::Decompress(_, _) => ("body", None),
771            ureq::Error::RedirectFailed | ureq::Error::TooManyRedirects => {
772                return redirect_error(message, None)
773            }
774            _ => ("unknown", None),
775        };
776        transfer_error(message, kind, status)
777    }
778
779    fn io_err(message: &'static str, error: std::io::Error) -> TransportError {
780        if is_timeout_io(&error) {
781            return timeout_error(message, "timeout");
782        }
783        let kind = match error.kind() {
784            std::io::ErrorKind::ConnectionRefused
785            | std::io::ErrorKind::ConnectionAborted
786            | std::io::ErrorKind::ConnectionReset
787            | std::io::ErrorKind::NotConnected => "connect",
788            _ => "io",
789        };
790        transfer_error(message, kind, None)
791    }
792
793    fn ws_err(message: &'static str, error: tungstenite::Error) -> TransportError {
794        match error {
795            tungstenite::Error::Io(error) => io_err(message, error),
796            tungstenite::Error::Tls(_) => transfer_error(message, "tls", None),
797            // A 3xx that reached the caller was refused (the policy denies
798            // redirects or the handshake request is credential bearing).
799            tungstenite::Error::Http(response) if response.status().is_redirection() => {
800                redirect_error(message, Some(response.status().as_u16()))
801            }
802            tungstenite::Error::Http(response) => {
803                transfer_error(message, "status", Some(response.status().as_u16()))
804            }
805            tungstenite::Error::Url(_) | tungstenite::Error::HttpFormat(_) => {
806                transfer_error(message, "request", None)
807            }
808            _ => transfer_error(message, "protocol", None),
809        }
810    }
811
812    const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
813
814    fn remaining(deadline: Instant) -> io::Result<Duration> {
815        deadline
816            .checked_duration_since(Instant::now())
817            .filter(|left| !left.is_zero())
818            .ok_or_else(|| {
819                io::Error::new(
820                    io::ErrorKind::TimedOut,
821                    "websocket establishment deadline exceeded",
822                )
823            })
824    }
825
826    struct EstablishingStream {
827        socket: TcpStream,
828        deadline: Option<Instant>,
829    }
830
831    impl EstablishingStream {
832        fn timeout(&self) -> io::Result<Option<Duration>> {
833            self.deadline.map(remaining).transpose()
834        }
835        fn established(&mut self) -> io::Result<TcpStream> {
836            self.deadline = None;
837            self.socket.set_read_timeout(None)?;
838            self.socket.set_write_timeout(None)?;
839            self.socket.try_clone()
840        }
841    }
842
843    fn establishment_io<T>(result: io::Result<T>, establishing: bool) -> io::Result<T> {
844        result.map_err(|error| {
845            if establishing
846                && matches!(
847                    error.kind(),
848                    io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut
849                )
850            {
851                io::Error::new(
852                    io::ErrorKind::TimedOut,
853                    "websocket establishment deadline exceeded",
854                )
855            } else {
856                error
857            }
858        })
859    }
860
861    impl Read for EstablishingStream {
862        fn read(&mut self, bytes: &mut [u8]) -> io::Result<usize> {
863            if self.deadline.is_some() {
864                self.socket.set_read_timeout(self.timeout()?)?;
865            }
866            establishment_io(self.socket.read(bytes), self.deadline.is_some())
867        }
868    }
869    impl Write for EstablishingStream {
870        fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
871            if self.deadline.is_some() {
872                self.socket.set_write_timeout(self.timeout()?)?;
873            }
874            establishment_io(self.socket.write(bytes), self.deadline.is_some())
875        }
876        fn flush(&mut self) -> io::Result<()> {
877            if self.deadline.is_some() {
878                self.socket.set_write_timeout(self.timeout()?)?;
879            }
880            establishment_io(self.socket.flush(), self.deadline.is_some())
881        }
882    }
883
884    fn connect_websocket(
885        request: tungstenite::handshake::client::Request,
886        deadline: Instant,
887    ) -> Result<tungstenite::WebSocket<MaybeTlsStream<EstablishingStream>>, TransportError> {
888        let host = request
889            .uri()
890            .host()
891            .ok_or_else(|| transfer_error("realtime URL invalid", "request", None))?;
892        let host = host.trim_start_matches('[').trim_end_matches(']');
893        let port = match request.uri().scheme_str() {
894            Some("ws") => request.uri().port_u16().unwrap_or(80),
895            Some("wss") => request.uri().port_u16().unwrap_or(443),
896            _ => return Err(transfer_error("realtime URL invalid", "request", None)),
897        };
898        // System DNS has no cancellable deadline. Only resolution runs here:
899        // a late result cannot open a socket or issue an authenticated request.
900        let host = host.to_owned();
901        let (resolved, addresses) = mpsc::sync_channel(1);
902        std::thread::Builder::new()
903            .name("syncular-dns".to_owned())
904            .spawn(move || {
905                let result = (host.as_str(), port)
906                    .to_socket_addrs()
907                    .map(|items| items.collect::<Vec<_>>());
908                let _ = resolved.send(result);
909            })
910            .map_err(|e| io_err("realtime DNS failed", e))?;
911        let addresses = addresses
912            .recv_timeout(remaining(deadline).map_err(|e| io_err("realtime DNS failed", e))?)
913            .map_err(|e| match e {
914                mpsc::RecvTimeoutError::Timeout => {
915                    timeout_error("realtime DNS timed out", "timeout")
916                }
917                mpsc::RecvTimeoutError::Disconnected => {
918                    transfer_error("realtime DNS failed", "io", None)
919                }
920            })?
921            .map_err(|e| io_err("realtime DNS failed", e))?;
922        let mut last_error = io::Error::new(io::ErrorKind::NotFound, "DNS returned no addresses");
923        let mut socket = None;
924        for address in addresses {
925            match TcpStream::connect_timeout(
926                &address,
927                remaining(deadline).map_err(|e| io_err("realtime TCP connect failed", e))?,
928            ) {
929                Ok(stream) => {
930                    socket = Some(stream);
931                    break;
932                }
933                Err(error) => last_error = error,
934            }
935        }
936        let socket = socket.ok_or_else(|| io_err("realtime TCP connect failed", last_error))?;
937        socket
938            .set_nodelay(true)
939            .map_err(|e| io_err("realtime TCP connect failed", e))?;
940        let stream = EstablishingStream {
941            socket,
942            deadline: Some(deadline),
943        };
944        // Same pinned rustls / WebPKI connector, certificate and hostname checks.
945        // Redirects are errors: credentials stay bound to the configured endpoint.
946        let (ws, _) = tungstenite::client_tls_with_config(request, stream, None, None).map_err(
947            |e| match e {
948                tungstenite::HandshakeError::Failure(error) => {
949                    ws_err("realtime connection failed", error)
950                }
951                // The establishment socket is blocking with a deadline, so the
952                // handshake cannot report an interrupted (would-block) state.
953                tungstenite::HandshakeError::Interrupted(_) => {
954                    transfer_error("realtime connection failed", "protocol", None)
955                }
956            },
957        )?;
958        remaining(deadline).map_err(|e| io_err("realtime connection failed", e))?;
959        Ok(ws)
960    }
961
962    #[cfg(test)]
963    mod establishment_tests {
964        use super::*;
965        use std::net::{Shutdown, TcpListener};
966        use tungstenite::client::IntoClientRequest;
967
968        // A peer consumes the initial request/ClientHello, then withholds its
969        // upgrade/TLS response until released. Cleanup also unblocks the old path.
970        fn withheld_response(tls: bool, old: bool) {
971            let listener = TcpListener::bind("127.0.0.1:0").unwrap();
972            let url = format!(
973                "{}://{}/realtime",
974                if tls { "wss" } else { "ws" },
975                listener.local_addr().unwrap()
976            );
977            let (accepted_tx, accepted_rx) = mpsc::channel();
978            let (release_tx, release_rx) = mpsc::channel();
979            let peer = std::thread::spawn(move || {
980                let (mut stream, _) = listener.accept().unwrap();
981                stream
982                    .set_read_timeout(Some(Duration::from_secs(2)))
983                    .unwrap();
984                let mut bytes = [0; 4096];
985                assert!(stream.read(&mut bytes).unwrap() > 0);
986                accepted_tx.send(()).unwrap();
987                release_rx.recv_timeout(Duration::from_secs(3)).unwrap();
988                let _ = stream.shutdown(Shutdown::Both);
989            });
990            let (done_tx, done_rx) = mpsc::channel();
991            let worker = std::thread::spawn(move || {
992                let result = if old {
993                    tungstenite::connect(url)
994                        .map(|_| ())
995                        .map_err(|e| e.to_string())
996                } else {
997                    connect_websocket(
998                        url.into_client_request().unwrap(),
999                        Instant::now() + Duration::from_millis(250),
1000                    )
1001                    .map(|_| ())
1002                    .map_err(|e| format!("{}: {}", e.code, e.message))
1003                };
1004                done_tx.send(result).unwrap();
1005            });
1006            accepted_rx.recv_timeout(Duration::from_secs(2)).unwrap();
1007            if old {
1008                assert!(
1009                    matches!(
1010                        done_rx.recv_timeout(Duration::from_millis(500)),
1011                        Err(mpsc::RecvTimeoutError::Timeout)
1012                    ),
1013                    "original connect unexpectedly had a deadline"
1014                );
1015                release_tx.send(()).unwrap();
1016                assert!(done_rx
1017                    .recv_timeout(Duration::from_secs(2))
1018                    .unwrap()
1019                    .is_err());
1020            } else {
1021                let error = done_rx
1022                    .recv_timeout(Duration::from_secs(2))
1023                    .expect("establishment remained blocked")
1024                    .unwrap_err();
1025                assert_eq!(error, "transport.timeout: realtime connection failed");
1026                eprintln!(
1027                    "withheld {} produces {error}",
1028                    if tls { "TLS" } else { "upgrade" }
1029                );
1030                release_tx.send(()).unwrap();
1031            }
1032            worker.join().unwrap();
1033            peer.join().unwrap();
1034        }
1035
1036        #[test]
1037        fn original_connect_blocks_on_withheld_upgrade_and_tls() {
1038            withheld_response(false, true);
1039            withheld_response(true, true);
1040        }
1041        #[test]
1042        fn establishment_deadline_errors_on_withheld_upgrade_and_tls() {
1043            withheld_response(false, false);
1044            withheld_response(true, false);
1045        }
1046
1047        #[test]
1048        fn slow_upgrade_bytes_cannot_renew_the_absolute_deadline() {
1049            let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1050            let url = format!("ws://{}", listener.local_addr().unwrap());
1051            let peer = std::thread::spawn(move || {
1052                let (mut stream, _) = listener.accept().unwrap();
1053                let mut bytes = [0; 4096];
1054                let _ = stream.read(&mut bytes).unwrap();
1055                for byte in b"HTTP/1.1 101 Switching Protocols\r\n" {
1056                    if stream.write_all(&[*byte]).is_err() {
1057                        break;
1058                    }
1059                    std::thread::sleep(Duration::from_millis(40));
1060                }
1061            });
1062            let started = Instant::now();
1063            assert!(connect_websocket(
1064                url.into_client_request().unwrap(),
1065                started + Duration::from_millis(250)
1066            )
1067            .is_err());
1068            assert!(started.elapsed() < Duration::from_secs(1));
1069            peer.join().unwrap();
1070        }
1071
1072        #[test]
1073        fn saturated_tcp_accept_queue_returns_an_observable_error() {
1074            // Fill a loopback listener's kernel accept queue without accepting.
1075            // No unroutable external host or firewall mutation is needed.
1076            let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1077            let address = listener.local_addr().unwrap();
1078            let mut queued = Vec::new();
1079            loop {
1080                match TcpStream::connect_timeout(&address, Duration::from_millis(50)) {
1081                    Ok(stream) => queued.push(stream),
1082                    Err(error) => {
1083                        assert!(
1084                            matches!(
1085                                error.kind(),
1086                                io::ErrorKind::TimedOut
1087                                    | io::ErrorKind::WouldBlock
1088                                    | io::ErrorKind::ConnectionRefused
1089                                    | io::ErrorKind::ConnectionReset
1090                            ),
1091                            "{error:?}"
1092                        );
1093                        break;
1094                    }
1095                }
1096                assert!(queued.len() < 4096, "listener queue did not saturate");
1097            }
1098            let started = Instant::now();
1099            let url = format!("ws://{address}");
1100            let error = match connect_websocket(
1101                url.into_client_request().unwrap(),
1102                started + Duration::from_millis(250),
1103            ) {
1104                Err(error) => error,
1105                Ok(_) => panic!("full accept queue unexpectedly connected"),
1106            };
1107            assert!(
1108                (error.code == "transport.timeout" || error.code == "transport.failed")
1109                    && (error.message == "realtime TCP connect failed"
1110                        || error.message == "realtime connection failed"),
1111                "{}: {}",
1112                error.code,
1113                error.message
1114            );
1115            assert!(started.elapsed() < Duration::from_secs(2));
1116        }
1117    }
1118
1119    /// Segment fetches keep their own generic surface (`sync.transport_failed`)
1120    /// but must not swallow a typed policy failure: a refused redirect or an
1121    /// exceeded deadline/byte budget survives to the caller.
1122    fn segment_error(mut error: TransportError) -> TransportError {
1123        if error.code == "transport.failed" {
1124            error.code = "sync.transport_failed".into();
1125            error.message = "segment transfer failed".into();
1126        }
1127        error
1128    }
1129
1130    /// Nonblocking I/O needs a readiness notification before it can continue.
1131    fn is_would_block(e: &tungstenite::Error) -> bool {
1132        matches!(
1133            e,
1134            tungstenite::Error::Io(io) if matches!(
1135                io.kind(),
1136                std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
1137            )
1138        )
1139    }
1140
1141    pub struct NativeTransport {
1142        base_url: String,
1143        ws_url: String,
1144        /// Extra request headers (auth, actor/project ids) as (name, value).
1145        headers: Vec<(String, String)>,
1146        /// The base URL carries `user:pass@`, which ureq turns into request
1147        /// credentials; a request to it is credential bearing even without
1148        /// configured headers.
1149        base_url_has_userinfo: bool,
1150        agent: ureq::Agent,
1151        policy: HostTransportPolicy,
1152        /// Absolute whole-round deadline scoped by `set_round_deadline` for
1153        /// one exchange; `None` outside a round or when the policy sets no
1154        /// round budget.
1155        round_deadline_at: Option<std::time::Instant>,
1156        pub signed_urls: bool,
1157        pub inbound: Arc<InboundBuffer>,
1158        /// One I/O thread owns the socket; callers wait for their write to flush.
1159        outgoing: Option<Box<SocketSender>>,
1160        reader: Option<JoinHandle<()>>,
1161        reader_stop: Arc<AtomicBool>,
1162        /// §8.7 round rendezvous, shared with the reader thread.
1163        round: Arc<RoundChannel>,
1164        realtime_client_id: Option<String>,
1165    }
1166
1167    fn build_agent(policy: &HostTransportPolicy) -> ureq::Agent {
1168        ureq::Agent::config_builder()
1169            .timeout_global(policy.request_timeout)
1170            .max_redirects(match policy.redirects {
1171                RedirectPolicy::Deny => 0,
1172                RedirectPolicy::Follow => 10,
1173            })
1174            .max_redirects_will_error(matches!(policy.redirects, RedirectPolicy::Follow))
1175            .redirect_auth_headers(ureq::config::RedirectAuthHeaders::Never)
1176            .build()
1177            .into()
1178    }
1179
1180    fn derive_ws_url(base_url: &str) -> String {
1181        // {scheme}://host/path → ws(s)://host/path/realtime — the reference
1182        // realtime endpoint sits alongside /sync under the mount (§8.7).
1183        let ws = if let Some(rest) = base_url.strip_prefix("https://") {
1184            format!("wss://{rest}")
1185        } else if let Some(rest) = base_url.strip_prefix("http://") {
1186            format!("ws://{rest}")
1187        } else {
1188            base_url.to_owned()
1189        };
1190        let trimmed = ws.trim_end_matches('/');
1191        format!("{trimmed}/realtime")
1192    }
1193
1194    impl NativeTransport {
1195        pub fn fork_round(&self) -> Self {
1196            Self {
1197                base_url: self.base_url.clone(),
1198                ws_url: self.ws_url.clone(),
1199                headers: self.headers.clone(),
1200                base_url_has_userinfo: self.base_url_has_userinfo,
1201                agent: self.agent.clone(),
1202                policy: self.policy.clone(),
1203                round_deadline_at: None,
1204                signed_urls: self.signed_urls,
1205                inbound: self.inbound.clone(),
1206                outgoing: self.outgoing.clone(),
1207                reader: None,
1208                reader_stop: self.reader_stop.clone(),
1209                round: self.round.clone(),
1210                realtime_client_id: self.realtime_client_id.clone(),
1211            }
1212        }
1213
1214        pub fn new(
1215            base_url: &str,
1216            config: &serde_json::Value,
1217            notify: Option<Arc<dyn Fn() + Send + Sync>>,
1218        ) -> Result<Self, String> {
1219            let policy = HostTransportPolicy::from_config(config)?;
1220            Self::with_policy(base_url, config, policy, notify)
1221        }
1222
1223        pub fn with_policy(
1224            base_url: &str,
1225            config: &serde_json::Value,
1226            policy: HostTransportPolicy,
1227            notify: Option<Arc<dyn Fn() + Send + Sync>>,
1228        ) -> Result<Self, String> {
1229            policy.validate()?;
1230            let mut headers = Vec::new();
1231            if let Some(map) = config.get("headers").and_then(|v| v.as_object()) {
1232                for (k, v) in map {
1233                    if let Some(s) = v.as_str() {
1234                        headers.push((k.clone(), s.to_owned()));
1235                    }
1236                }
1237            }
1238            let ws_url = config
1239                .get("wsUrl")
1240                .and_then(|v| v.as_str())
1241                .map(str::to_owned)
1242                .unwrap_or_else(|| derive_ws_url(base_url));
1243            // ureq replays `user:pass@` as Basic auth, so a base URL with
1244            // userinfo is credential bearing even when no headers are set.
1245            let base_url_has_userinfo = url::Url::parse(base_url)
1246                .map(|url| !url.username().is_empty() || url.password().is_some())
1247                .unwrap_or(false);
1248            Ok(NativeTransport {
1249                base_url: base_url.trim_end_matches('/').to_owned(),
1250                ws_url,
1251                headers,
1252                base_url_has_userinfo,
1253                agent: build_agent(&policy),
1254                policy,
1255                round_deadline_at: None,
1256                signed_urls: false,
1257                inbound: Arc::new(match notify {
1258                    Some(notify) => InboundBuffer::with_notify(notify),
1259                    None => InboundBuffer::default(),
1260                }),
1261                outgoing: None,
1262                reader: None,
1263                reader_stop: Arc::new(AtomicBool::new(false)),
1264                round: Arc::new(RoundChannel::default()),
1265                realtime_client_id: None,
1266            })
1267        }
1268
1269        /// Replace the policy for subsequent calls; an invalid policy is
1270        /// rejected and the current policy stays in force. The agent is
1271        /// rebuilt so the new redirect ceiling and timeout defaults apply.
1272        pub fn set_policy(&mut self, policy: HostTransportPolicy) -> Result<(), String> {
1273            policy.validate()?;
1274            self.agent = build_agent(&policy);
1275            self.policy = policy;
1276            Ok(())
1277        }
1278
1279        pub fn round_deadline(&self) -> Option<Duration> {
1280            self.policy.round_deadline
1281        }
1282
1283        pub fn set_round_deadline(&mut self, deadline: Option<std::time::Instant>) {
1284            self.round_deadline_at = deadline;
1285        }
1286
1287        /// End-to-end timeout for the next call: the per-request bound
1288        /// intersected with whatever remains of the round deadline. Fails
1289        /// before any network I/O once the round deadline has passed.
1290        fn effective_timeout(&self) -> Result<Option<Duration>, TransportError> {
1291            let remaining = match self.round_deadline_at {
1292                Some(deadline) => {
1293                    match deadline.checked_duration_since(std::time::Instant::now()) {
1294                        Some(remaining) => Some(remaining),
1295                        None => {
1296                            return Err(timeout_error(
1297                                "sync round deadline exceeded",
1298                                "round_deadline",
1299                            ))
1300                        }
1301                    }
1302                }
1303                None => None,
1304            };
1305            Ok(match (self.policy.request_timeout, remaining) {
1306                (Some(request), Some(round)) => Some(request.min(round)),
1307                (Some(request), None) => Some(request),
1308                (None, Some(round)) => Some(round),
1309                (None, None) => None,
1310            })
1311        }
1312
1313        /// Whether a request to the base URL carries credentials: configured
1314        /// headers, or `user:pass@` userinfo ureq replays as Basic auth.
1315        fn credentialed(&self) -> bool {
1316            !self.headers.is_empty() || self.base_url_has_userinfo
1317        }
1318
1319        /// Apply the per-call bounds to a request: the effective timeout, and
1320        /// a redirect ceiling of zero whenever the request is credential
1321        /// bearing or the policy denies redirects. A credential-bearing
1322        /// request is refused a redirect under every policy, because ureq
1323        /// forwards configured headers to the redirect target.
1324        fn finish_request<B>(
1325            &self,
1326            req: ureq::RequestBuilder<B>,
1327            credentialed: bool,
1328        ) -> Result<ureq::RequestBuilder<B>, TransportError> {
1329            let timeout = self.effective_timeout()?;
1330            let follow = self.policy.redirects == RedirectPolicy::Follow && !credentialed;
1331            Ok(req
1332                .config()
1333                .timeout_global(timeout)
1334                .max_redirects(if follow { 10 } else { 0 })
1335                .max_redirects_will_error(true)
1336                .build())
1337        }
1338
1339        /// Refuse an oversized request body before any network I/O.
1340        fn check_request_bytes(&self, len: u64) -> Result<(), TransportError> {
1341            match self.policy.max_request_bytes {
1342                Some(max) if len > max => {
1343                    let mut error = TransportError::new(
1344                        "transport.request_too_large",
1345                        "request body exceeds the configured transport limit",
1346                    );
1347                    error.details = Some(serde_json::json!({ "limit": max, "size": len }));
1348                    Err(error)
1349                }
1350                _ => Ok(()),
1351            }
1352        }
1353
1354        fn post_sync(&self, path: &str, body: &[u8]) -> Result<Vec<u8>, TransportError> {
1355            self.check_request_bytes(body.len() as u64)?;
1356            let credentialed = self.credentialed();
1357            let url = format!("{}{}", self.base_url, path);
1358            // SSP2 requests carry their own media type (SPEC 1.1); a stock
1359            // server answers 415 to anything else.
1360            let mut req = self
1361                .agent
1362                .post(&url)
1363                .header("content-type", "application/vnd.syncular.sync.v2");
1364            for (k, v) in &self.headers {
1365                req = req.header(k.as_str(), v.as_str());
1366            }
1367            let req = self.finish_request(req, credentialed)?;
1368            let resp = req
1369                .send(body)
1370                .map_err(|e| http_err("sync request failed", e))?;
1371            read_body(resp, None, self.policy.max_response_bytes)
1372        }
1373
1374        fn post_operation(&self, body: &[u8]) -> Result<Vec<u8>, TransportError> {
1375            self.check_request_bytes(body.len() as u64)?;
1376            let credentialed = self.credentialed();
1377            let url = format!("{}/operations", self.base_url);
1378            let mut req = self.agent.post(&url).header(
1379                "content-type",
1380                "application/vnd.syncular.operations.v1+json",
1381            );
1382            for (key, value) in &self.headers {
1383                req = req.header(key.as_str(), value.as_str());
1384            }
1385            let req = self.finish_request(req, credentialed)?;
1386            let response = req
1387                .send(body)
1388                .map_err(|error| http_err("remote operation request failed", error))?;
1389            read_body(response, None, self.policy.max_response_bytes)
1390        }
1391
1392        fn get_bytes(&self, url: &str, with_headers: bool) -> Result<Vec<u8>, TransportError> {
1393            // The only caller is a signed blob URL: the URL is the capability,
1394            // so the request is credential bearing either way.
1395            let mut req = self.agent.get(url);
1396            if with_headers {
1397                for (k, v) in &self.headers {
1398                    req = req.header(k.as_str(), v.as_str());
1399                }
1400            }
1401            let req = self.finish_request(req, true)?;
1402            let resp = req.call().map_err(|e| http_err("URL fetch failed", e))?;
1403            read_body(resp, None, self.policy.max_response_bytes)
1404        }
1405
1406        pub fn set_headers(&mut self, headers: Vec<(String, String)>) {
1407            self.headers = headers;
1408        }
1409
1410        pub fn set_realtime_client_id(&mut self, client_id: Option<&str>) {
1411            self.realtime_client_id = client_id.map(str::to_owned);
1412        }
1413
1414        pub fn shutdown(&mut self) {
1415            self.reader_stop.store(true, Ordering::SeqCst);
1416            // Wake any `realtime_sync` blocked on a round: the socket is going
1417            // away, so the round can never complete (§8.7 mid-round drop).
1418            self.round.fail_in_flight(TransportError::new(
1419                "sync.transport_failed",
1420                "realtime disconnected mid-round (§8.7)",
1421            ));
1422            if let Some(outgoing) = &self.outgoing {
1423                let _ = outgoing.poller.notify();
1424            }
1425            if let Some(handle) = self.reader.take() {
1426                let _ = handle.join();
1427            }
1428            self.outgoing = None;
1429        }
1430
1431        fn send_message(&mut self, message: Message) -> Result<(), TransportError> {
1432            // A whole-round deadline bounds the write wait too: intersect it
1433            // with the socket's own ceiling and report the round budget as a
1434            // typed timeout. The per-request HTTP deadline does not apply to a
1435            // socket send.
1436            let (wait, round_bound) = match self.round_deadline_at {
1437                Some(deadline) => {
1438                    match deadline.checked_duration_since(std::time::Instant::now()) {
1439                        Some(remaining) if remaining < ROUND_TIMEOUT => (remaining, true),
1440                        Some(_) => (ROUND_TIMEOUT, false),
1441                        // The round is already spent: refuse before queuing the
1442                        // frame so a later send cannot deliver it.
1443                        None => {
1444                            return Err(timeout_error("realtime send timed out", "round_deadline"));
1445                        }
1446                    }
1447                }
1448                None => (ROUND_TIMEOUT, false),
1449            };
1450            let result = (|| {
1451                let Some(outgoing) = &self.outgoing else {
1452                    return Err(TransportError::new(
1453                        "transport.failed",
1454                        "realtime not connected",
1455                    ));
1456                };
1457                let (completed, result) = mpsc::channel();
1458                outgoing
1459                    .queue
1460                    .try_send(Outgoing { message, completed })
1461                    .map_err(|_| {
1462                        TransportError::new("transport.failed", "realtime send queue unavailable")
1463                    })?;
1464                outgoing
1465                    .poller
1466                    .notify()
1467                    .map_err(|e| io_err("realtime wake failed", e))?;
1468                result.recv_timeout(wait).map_err(|_| {
1469                    if round_bound {
1470                        timeout_error("realtime send timed out", "round_deadline")
1471                    } else {
1472                        TransportError::new("transport.failed", "realtime send did not complete")
1473                    }
1474                })?
1475            })();
1476            // A failed/timed-out write must not remain eligible for delivery
1477            // behind a later command on the same connection.
1478            if let Err(error) = &result {
1479                self.shutdown();
1480                // Some callers (delta acknowledgement) intentionally ignore a
1481                // send error. It still invalidates the connection for the host.
1482                self.inbound.push(Inbound::Disconnected(error.clone()));
1483            }
1484            result
1485        }
1486    }
1487
1488    fn read_body(
1489        resp: ureq::http::Response<ureq::Body>,
1490        mut progress: Option<&mut dyn FnMut(u64)>,
1491        max: Option<u64>,
1492    ) -> Result<Vec<u8>, TransportError> {
1493        redirect_refused(&resp)?;
1494        use std::io::Read;
1495        let status = resp.status().as_u16();
1496        let mut reader = resp.into_body().into_with_config().reader();
1497        let mut bytes = Vec::new();
1498        let mut chunk = [0u8; 64 * 1024];
1499        let mut reported = 0;
1500        loop {
1501            let n = reader.read(&mut chunk).map_err(|error| {
1502                if is_timeout_io(&error) {
1503                    timeout_error("response body read times out", "timeout")
1504                } else {
1505                    transfer_error("response body read failed", "body", Some(status))
1506                }
1507            })?;
1508            if n == 0 {
1509                break;
1510            }
1511            // The cap counts decoded bytes: ureq's reader decompresses before
1512            // this loop, so a gzip bomb hits the limit before the buffer grows.
1513            if let Some(max) = max {
1514                if bytes.len() as u64 + n as u64 > max {
1515                    return Err(response_too_large(max));
1516                }
1517            }
1518            bytes.extend_from_slice(&chunk[..n]);
1519            if bytes.len() - reported >= 64 * 1024 {
1520                if let Some(callback) = progress.as_mut() {
1521                    callback(bytes.len() as u64);
1522                }
1523                reported = bytes.len();
1524            }
1525        }
1526        if bytes.len() != reported {
1527            if let Some(callback) = progress {
1528                callback(bytes.len() as u64);
1529            }
1530        }
1531        Ok(bytes)
1532    }
1533
1534    fn response_too_large(max: u64) -> TransportError {
1535        let mut error = TransportError::new(
1536            "transport.response_too_large",
1537            "response body exceeds the configured transport limit",
1538        );
1539        error.details = Some(serde_json::json!({ "limit": max }));
1540        error
1541    }
1542
1543    impl Transport for NativeTransport {
1544        fn sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
1545            self.post_sync("/sync", request)
1546        }
1547
1548        fn remote_operation(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
1549            self.post_operation(request)
1550        }
1551
1552        fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
1553            // §8.7 socket round: send the request as a `0x01`-tagged chunk on
1554            // the connected socket and block for the reassembled response
1555            // stream (the reader thread demuxes `0x01` chunks to END, routing
1556            // any `0x00` delta / text that interleaves to the inbound queue).
1557            // When no socket is connected this is the client's "not connected"
1558            // path — the caller (client core) only calls `realtime_sync` while
1559            // `realtime_connected`, and connect established the socket; a
1560            // missing socket here means the round rides HTTP instead (same
1561            // rule as the TS client: `POST /sync` when the socket is absent).
1562            if self.outgoing.is_none() {
1563                return self.post_sync("/sync", request);
1564            }
1565            self.effective_timeout()?;
1566            let framed = self.round.begin(request)?;
1567            if let Err(error) = self.send_message(Message::Binary(framed.into())) {
1568                self.round.fail_in_flight(error);
1569            }
1570            self.round.wait(self.round_deadline_at)
1571        }
1572
1573        fn download_segment(
1574            &mut self,
1575            request: &SegmentRequest,
1576            on_progress: &mut dyn FnMut(u64),
1577        ) -> Result<Vec<u8>, TransportError> {
1578            // The FULL content address (`sha256:<hex>`) is the path param —
1579            // the reference server keys its segment store by it (§5.1) and
1580            // answers `sync.not_found` to a bare hex id. The requested-scopes
1581            // header carries what the pull round granted; the server
1582            // re-authorizes the download against it (§5.5) and answers
1583            // `sync.forbidden` when it is missing.
1584            let url = format!("{}/segments/{}", self.base_url, request.segment_id);
1585            let credentialed = self.credentialed();
1586            let mut req = self
1587                .agent
1588                .get(&url)
1589                .header("x-syncular-scopes", &request.requested_scopes_json);
1590            for (k, v) in &self.headers {
1591                req = req.header(k.as_str(), v.as_str());
1592            }
1593            let req = self.finish_request(req, credentialed)?;
1594            let resp = req
1595                .call()
1596                .map_err(|error| segment_error(http_err("segment request failed", error)))?;
1597            read_body(resp, Some(on_progress), self.policy.max_response_bytes)
1598                .map_err(segment_error)
1599        }
1600
1601        fn supports_url_fetch(&self) -> bool {
1602            self.signed_urls
1603        }
1604
1605        fn download_segments(
1606            &mut self,
1607            fetches: &[SegmentFetch],
1608            on_progress: &mut (dyn FnMut(usize, u64) + Send),
1609        ) -> Vec<Result<Vec<u8>, TransportError>> {
1610            // A segment GET is pure network I/O over immutable transport
1611            // state, so a page list downloads on a bounded pool instead of one
1612            // blocking request after the other: four pages of a paged
1613            // bootstrap overlap (§5.2). `fork_round` hands each worker the same
1614            // agent (a cheap clone) and headers; the round's anchored deadline
1615            // is copied over explicitly, because a fork starts without one.
1616            // Every fetch is attempted; the client stops consuming at the
1617            // first error in wire order.
1618            let workers = (0..fetches.len().min(4))
1619                .map(|_| {
1620                    let mut worker = self.fork_round();
1621                    worker.round_deadline_at = self.round_deadline_at;
1622                    worker
1623                })
1624                .collect::<Vec<_>>();
1625            let slots = fetches.iter().map(|_| Mutex::new(None)).collect::<Vec<_>>();
1626            let progress = Mutex::new(on_progress);
1627            let next = std::sync::atomic::AtomicUsize::new(0);
1628            std::thread::scope(|scope| {
1629                for worker in workers {
1630                    let (slots, progress, next) = (&slots, &progress, &next);
1631                    scope.spawn(move || {
1632                        let mut worker = worker;
1633                        loop {
1634                            let index = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1635                            let Some(fetch) = fetches.get(index) else {
1636                                break;
1637                            };
1638                            let mut report = |bytes: u64| {
1639                                (*progress.lock().expect("segment progress lock"))(index, bytes);
1640                            };
1641                            let result = match fetch {
1642                                SegmentFetch::Direct(request) => {
1643                                    worker.download_segment(request, &mut report)
1644                                }
1645                                SegmentFetch::Url(url) => worker.fetch_url(url, &mut report),
1646                            };
1647                            *slots[index].lock().expect("segment result slot") = Some(result);
1648                        }
1649                    });
1650                }
1651            });
1652            slots
1653                .into_iter()
1654                .map(|slot| slot.into_inner().expect("segment result slot"))
1655                .map(|result| result.expect("every fetch is attempted"))
1656                .collect()
1657        }
1658
1659        fn fetch_url(
1660            &mut self,
1661            url: &str,
1662            on_progress: &mut dyn FnMut(u64),
1663        ) -> Result<Vec<u8>, TransportError> {
1664            // §5.4: the URL is the entire grant — no host credentials attached.
1665            let req = self.finish_request(self.agent.get(url), true)?;
1666            let resp = req
1667                .call()
1668                .map_err(|error| segment_error(http_err("segment request failed", error)))?;
1669            read_body(resp, Some(on_progress), self.policy.max_response_bytes)
1670                .map_err(segment_error)
1671        }
1672
1673        fn blob_upload(
1674            &mut self,
1675            blob_id: &str,
1676            bytes: &[u8],
1677            media_type: Option<&str>,
1678        ) -> Result<(), TransportError> {
1679            // Full `sha256:<hex>` id in the path — the reference server's
1680            // isBlobId check rejects a bare hex id (§5.9.1).
1681            self.check_request_bytes(bytes.len() as u64)?;
1682            let credentialed = self.credentialed();
1683            let url = format!("{}/blobs/{}", self.base_url, blob_id);
1684            let mut req = self.agent.put(&url).header(
1685                "content-type",
1686                media_type.unwrap_or("application/octet-stream"),
1687            );
1688            for (k, v) in &self.headers {
1689                req = req.header(k.as_str(), v.as_str());
1690            }
1691            let req = self.finish_request(req, credentialed)?;
1692            let resp = req
1693                .send(bytes)
1694                .map_err(|e| http_err("blob upload failed", e))?;
1695            redirect_refused(&resp)?;
1696            Ok(())
1697        }
1698
1699        fn blob_download(&mut self, blob_id: &str) -> Result<BlobDownload, TransportError> {
1700            // Full `sha256:<hex>` id in the path — the reference server's
1701            // isBlobId check rejects a bare hex id (§5.9.1).
1702            let credentialed = self.credentialed();
1703            let url = format!("{}/blobs/{}", self.base_url, blob_id);
1704            let mut req = self.agent.get(&url);
1705            for (k, v) in &self.headers {
1706                req = req.header(k.as_str(), v.as_str());
1707            }
1708            let req = self.finish_request(req, credentialed)?;
1709            let resp = req.call().map_err(|cause| {
1710                let status = match &cause {
1711                    ureq::Error::StatusCode(status) => Some(*status),
1712                    _ => None,
1713                };
1714                let mut error = http_err("blob download failed", cause);
1715                match status {
1716                    Some(404) => {
1717                        error.code = "blob.not_found".into();
1718                        error.message = "blob not found".into();
1719                    }
1720                    Some(403) => {
1721                        error.code = "blob.forbidden".into();
1722                        error.message = "blob download forbidden".into();
1723                    }
1724                    Some(401) => {
1725                        error.code = "sync.auth_required".into();
1726                        error.message = "host authentication required".into();
1727                    }
1728                    _ => {}
1729                }
1730                error
1731            })?;
1732            // §5.9.5 always-issue: a JSON body carries a presigned `url`; an
1733            // octet-stream body is inline bytes.
1734            let is_json = resp
1735                .headers()
1736                .get(ureq::http::header::CONTENT_TYPE)
1737                .and_then(|value| value.to_str().ok())
1738                .is_some_and(|value| value.contains("application/json"));
1739            let body = read_body(resp, None, self.policy.max_response_bytes)?;
1740            if is_json {
1741                if let Ok(parsed) = serde_json::from_slice::<serde_json::Value>(&body) {
1742                    if let Some(u) = parsed.get("url").and_then(|v| v.as_str()) {
1743                        return Ok(BlobDownload::Url {
1744                            url: u.to_owned(),
1745                            url_expires_at_ms: parsed
1746                                .get("urlExpiresAtMs")
1747                                .and_then(|v| v.as_i64()),
1748                        });
1749                    }
1750                }
1751            }
1752            Ok(BlobDownload::Bytes(body))
1753        }
1754
1755        fn fetch_blob_url(&mut self, url: &str) -> Result<Vec<u8>, TransportError> {
1756            // §5.9.5: the URL is the entire grant — no host credentials.
1757            self.get_bytes(url, false)
1758        }
1759
1760        fn blob_upload_grant(
1761            &mut self,
1762            blob_id: &str,
1763            byte_length: u64,
1764            media_type: Option<&str>,
1765        ) -> Result<BlobUploadGrant, TransportError> {
1766            let url = format!("{}/blobs/{}/upload-grant", self.base_url, blob_id);
1767            let body = serde_json::json!({
1768                "byteLength": byte_length,
1769                "mediaType": media_type,
1770            })
1771            .to_string();
1772            self.check_request_bytes(body.len() as u64)?;
1773            let credentialed = self.credentialed();
1774            let mut req = self
1775                .agent
1776                .post(&url)
1777                .header("content-type", "application/json");
1778            for (k, v) in &self.headers {
1779                req = req.header(k.as_str(), v.as_str());
1780            }
1781            let req = self.finish_request(req, credentialed)?;
1782            let resp = req
1783                .send(body)
1784                .map_err(|e| http_err("blob upload grant request failed", e))?;
1785            let grant_body = read_body(resp, None, self.policy.max_response_bytes)?;
1786            let parsed: serde_json::Value = serde_json::from_slice(&grant_body)
1787                .map_err(|_| transfer_error("blob upload grant decode failed", "decode", None))?;
1788            if let Some(u) = parsed.get("url").and_then(|v| v.as_str()) {
1789                return Ok(BlobUploadGrant::Url {
1790                    url: u.to_owned(),
1791                    url_expires_at_ms: parsed.get("urlExpiresAtMs").and_then(|v| v.as_i64()),
1792                });
1793            }
1794            if parsed.get("present").and_then(|v| v.as_bool()) == Some(true) {
1795                return Ok(BlobUploadGrant::Present);
1796            }
1797            Ok(BlobUploadGrant::None)
1798        }
1799
1800        fn blob_put_url(
1801            &mut self,
1802            url: &str,
1803            bytes: &[u8],
1804            media_type: Option<&str>,
1805        ) -> Result<(), TransportError> {
1806            // §5.9.3: the presigned URL is the entire grant — no host auth.
1807            self.check_request_bytes(bytes.len() as u64)?;
1808            let req = self.finish_request(
1809                self.agent.put(url).header(
1810                    "content-type",
1811                    media_type.unwrap_or("application/octet-stream"),
1812                ),
1813                true,
1814            )?;
1815            let resp = req
1816                .send(bytes)
1817                .map_err(|e| http_err("signed blob upload failed", e))?;
1818            redirect_refused(&resp)?;
1819            Ok(())
1820        }
1821
1822        fn realtime_connect(&mut self) -> Result<(), TransportError> {
1823            if self.outgoing.is_some() {
1824                if self.reader_stop.load(Ordering::SeqCst)
1825                    || self
1826                        .reader
1827                        .as_ref()
1828                        .is_some_and(|reader| reader.is_finished())
1829                {
1830                    self.shutdown();
1831                } else {
1832                    return Ok(());
1833                }
1834            }
1835            // Build the client request VIA `IntoClientRequest` so tungstenite
1836            // fills the mandatory handshake headers (Host / Connection /
1837            // Upgrade / Sec-WebSocket-Version / Sec-WebSocket-Key); a
1838            // hand-built `http::Request` is taken as-is and would omit them.
1839            // Then layer the configured auth/actor headers on top.
1840            use tungstenite::client::IntoClientRequest;
1841            let mut url = url::Url::parse(&self.ws_url)
1842                .map_err(|_| transfer_error("realtime URL invalid", "request", None))?;
1843            if let Some(client_id) = self.realtime_client_id.as_deref() {
1844                let retained_query: Vec<(String, String)> = url
1845                    .query_pairs()
1846                    .filter(|(key, _)| key != "clientId")
1847                    .map(|(key, value)| (key.into_owned(), value.into_owned()))
1848                    .collect();
1849                url.set_query(None);
1850                url.query_pairs_mut()
1851                    .extend_pairs(retained_query)
1852                    .append_pair("clientId", client_id);
1853            }
1854            let mut request = url
1855                .as_str()
1856                .into_client_request()
1857                .map_err(|error| ws_err("realtime request failed", error))?;
1858            {
1859                let out = request.headers_mut();
1860                for (k, v) in &self.headers {
1861                    if let (Ok(name), Ok(value)) = (
1862                        tungstenite::http::HeaderName::try_from(k.as_str()),
1863                        tungstenite::http::HeaderValue::try_from(v.as_str()),
1864                    ) {
1865                        out.insert(name, value);
1866                    }
1867                }
1868            }
1869            // The bounded establishment never follows a redirect, so a transport configured with
1870            // `RedirectPolicy::Follow` fails here instead of having the policy silently ignored.
1871            if self.policy.redirects == RedirectPolicy::Follow {
1872                return Err(redirect_error(
1873                    "realtime establishment does not follow redirects",
1874                    None,
1875                ));
1876            }
1877            let mut ws = connect_websocket(request, Instant::now() + CONNECT_TIMEOUT)?;
1878            let stream = match ws.get_mut() {
1879                MaybeTlsStream::Plain(stream) => stream.established(),
1880                MaybeTlsStream::Rustls(stream) => stream.get_mut().established(),
1881                _ => {
1882                    return Err(TransportError::new(
1883                        "transport.failed",
1884                        "unsupported websocket stream",
1885                    ))
1886                }
1887            }
1888            .map_err(|e| io_err("realtime clone failed", e))?;
1889            stream
1890                .set_nonblocking(true)
1891                .map_err(|e| io_err("realtime nonblocking failed", e))?;
1892            let poller = Arc::new(Poller::new().map_err(|e| io_err("realtime poller failed", e))?);
1893            // SAFETY: SocketRegistration owns this handle and unregisters it before drop.
1894            unsafe { poller.add(&stream, Event::readable(0)) }
1895                .map_err(|e| io_err("realtime register failed", e))?;
1896            let registration = SocketRegistration {
1897                poller: Arc::clone(&poller),
1898                stream,
1899            };
1900            let (outgoing, queued) = mpsc::sync_channel::<Outgoing>(1);
1901            self.outgoing = Some(Box::new(SocketSender {
1902                queue: outgoing,
1903                poller,
1904            }));
1905            self.reader_stop = Arc::new(AtomicBool::new(false));
1906            self.round = Arc::new(RoundChannel::default());
1907            let inbound = Arc::clone(&self.inbound);
1908            let stop = Arc::clone(&self.reader_stop);
1909            let round = Arc::clone(&self.round);
1910            self.reader = Some(std::thread::spawn(move || {
1911                let mut events = Events::new();
1912                let mut pending: Option<mpsc::Sender<Result<(), TransportError>>> = None;
1913                let failure = 'io: loop {
1914                    if stop.load(Ordering::SeqCst) {
1915                        let _ = ws.close(None);
1916                        let _ = ws.flush();
1917                        break TransportError::new(
1918                            "sync.transport_failed",
1919                            "realtime disconnected mid-round (§8.7)",
1920                        );
1921                    }
1922                    if pending.is_none() {
1923                        if let Ok(outgoing) = queued.try_recv() {
1924                            pending = Some(outgoing.completed);
1925                            // WouldBlock retains the frame in tungstenite's write buffer.
1926                            if let Err(error) = ws.write(outgoing.message) {
1927                                if !is_would_block(&error) {
1928                                    break ws_err("realtime write failed", error);
1929                                }
1930                            }
1931                        }
1932                    }
1933                    // Bound receive work so a continuously readable peer cannot starve sends.
1934                    let mut drained = false;
1935                    for _ in 0..64 {
1936                        match ws.read() {
1937                            Ok(Message::Text(text)) => {
1938                                inbound.push(Inbound::Text(text.to_string()))
1939                            }
1940                            Ok(Message::Binary(bytes)) => {
1941                                if let Some(delta) = round.route_binary(&bytes) {
1942                                    inbound.push(Inbound::Binary(delta));
1943                                }
1944                            }
1945                            Err(error) if is_would_block(&error) => {
1946                                drained = true;
1947                                break;
1948                            }
1949                            Ok(Message::Close(_)) => {
1950                                let _ = ws.flush();
1951                                break 'io TransportError::new(
1952                                    "sync.transport_failed",
1953                                    "realtime disconnected mid-round (§8.7)",
1954                                );
1955                            }
1956                            Err(_) => {
1957                                break 'io TransportError::new(
1958                                    "sync.transport_failed",
1959                                    "realtime disconnected mid-round (§8.7)",
1960                                );
1961                            }
1962                            Ok(_) => {}
1963                        }
1964                    }
1965                    let interest = match ws.flush() {
1966                        Ok(()) => {
1967                            if let Some(completed) = pending.take() {
1968                                let _ = completed.send(Ok(()));
1969                            }
1970                            Event::readable(0)
1971                        }
1972                        Err(error) if is_would_block(&error) => Event::all(0),
1973                        Err(error) => break ws_err("realtime flush failed", error),
1974                    };
1975                    if !drained {
1976                        continue;
1977                    }
1978                    if let Err(error) = registration.poller.modify(&registration.stream, interest) {
1979                        break io_err("realtime rearm failed", error);
1980                    }
1981                    events.clear();
1982                    if let Err(error) = registration.poller.wait(&mut events, None) {
1983                        if error.kind() != std::io::ErrorKind::Interrupted {
1984                            break io_err("realtime wait failed", error);
1985                        }
1986                    }
1987                };
1988                // Publish before waking a round/send waiter: the host must
1989                // drain this old connection's event before it can reconnect.
1990                // An idle close has no round waiter at all. Explicit shutdown
1991                // is owned by its caller and must not leave a stale close.
1992                if !stop.load(Ordering::SeqCst) {
1993                    inbound.push(Inbound::Disconnected(failure.clone()));
1994                }
1995                if let Some(completed) = pending {
1996                    let _ = completed.send(Err(failure.clone()));
1997                }
1998                while let Ok(outgoing) = queued.try_recv() {
1999                    let _ = outgoing.completed.send(Err(failure.clone()));
2000                }
2001                round.fail_in_flight(failure);
2002            }));
2003            Ok(())
2004        }
2005
2006        fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
2007            self.send_message(Message::Text(text.to_owned().into()))
2008        }
2009
2010        fn realtime_close(&mut self) -> Result<(), TransportError> {
2011            self.shutdown();
2012            Ok(())
2013        }
2014
2015        fn round_deadline(&self) -> Option<Duration> {
2016            NativeTransport::round_deadline(self)
2017        }
2018
2019        fn set_round_deadline(&mut self, deadline: Option<std::time::Instant>) {
2020            NativeTransport::set_round_deadline(self, deadline);
2021        }
2022    }
2023    #[cfg(test)]
2024    mod transfer_tests {
2025        use super::*;
2026        use ureq::unversioned::resolver::DefaultResolver;
2027        use ureq::unversioned::transport::{ConnectionDetails, Connector};
2028
2029        const SECRET: &str = "https://user:password@cdn.example/secret-capability-path?secret-query=value#secret-fragment";
2030
2031        #[derive(Debug)]
2032        struct SecretError;
2033        impl std::fmt::Display for SecretError {
2034            fn fmt(&self, _: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2035                panic!("transport error must never be formatted")
2036            }
2037        }
2038        impl std::error::Error for SecretError {}
2039
2040        #[derive(Debug)]
2041        struct FailingConnector(&'static str);
2042        impl Connector for FailingConnector {
2043            type Out = ();
2044            fn connect(
2045                &self,
2046                _: &ConnectionDetails,
2047                _: Option<()>,
2048            ) -> Result<Option<()>, ureq::Error> {
2049                Err(match self.0 {
2050                    "io" => ureq::Error::Io(std::io::Error::new(
2051                        std::io::ErrorKind::ConnectionRefused,
2052                        format!("404 {SECRET}"),
2053                    )),
2054                    "tls" => ureq::Error::Tls(SECRET),
2055                    "request" => ureq::Error::BadUri(SECRET.into()),
2056                    "timeout" => ureq::Error::Timeout(ureq::Timeout::Global),
2057                    "404" => ureq::Error::StatusCode(404),
2058                    "401" => ureq::Error::StatusCode(401),
2059                    "403" => ureq::Error::StatusCode(403),
2060                    "503" => ureq::Error::StatusCode(503),
2061                    _ => ureq::Error::Other(Box::new(SecretError)),
2062                })
2063            }
2064        }
2065
2066        #[test]
2067        fn injected_http_failures_never_expose_capabilities_or_format_the_cause() {
2068            for kind in [
2069                "io", "tls", "request", "timeout", "unknown", "404", "401", "403", "503",
2070            ] {
2071                for operation in [
2072                    "sync",
2073                    "remote",
2074                    "segment",
2075                    "signed_segment",
2076                    "blob",
2077                    "signed_blob",
2078                    "upload",
2079                    "grant",
2080                    "signed_put",
2081                ] {
2082                    let mut transport =
2083                        NativeTransport::new("http://127.0.0.1", &serde_json::json!({}), None)
2084                            .unwrap();
2085                    transport.agent = ureq::Agent::with_parts(
2086                        ureq::Agent::config_builder().build(),
2087                        FailingConnector(kind),
2088                        DefaultResolver::default(),
2089                    );
2090                    let mut progress = |_| {};
2091                    let result = match operation {
2092                        "sync" => transport.sync(&[]).map(|_| ()),
2093                        "remote" => transport.remote_operation(&[]).map(|_| ()),
2094                        "segment" => transport.download_segment(&SegmentRequest { segment_id: "sha256:test".into(), table: "tasks".into(), requested_scopes_json: "{}".into() }, &mut progress).map(|_| ()),
2095                        "signed_segment" => transport.fetch_url("http://127.0.0.1/secret-capability-path?secret-query=value#secret-fragment", &mut progress).map(|_| ()),
2096                        "blob" => transport.blob_download("sha256:test").map(|_| ()),
2097                        "signed_blob" => transport.fetch_blob_url("http://127.0.0.1/secret-capability-path?secret-query=value#secret-fragment").map(|_| ()),
2098                        "upload" => transport.blob_upload("sha256:test", &[], None),
2099                        "grant" => transport.blob_upload_grant("sha256:test", 0, None).map(|_| ()),
2100                        _ => transport.blob_put_url("http://127.0.0.1/secret-capability-path?secret-query=value#secret-fragment", &[], None),
2101                    };
2102                    let error = result.unwrap_err();
2103                    let expected_code = match (operation, kind) {
2104                        (_, "timeout") => "transport.timeout",
2105                        ("segment" | "signed_segment", _) => "sync.transport_failed",
2106                        ("blob", "404") => "blob.not_found",
2107                        ("blob", "401") => "sync.auth_required",
2108                        ("blob", "403") => "blob.forbidden",
2109                        _ => "transport.failed",
2110                    };
2111                    assert_eq!(error.code, expected_code, "{operation} {kind}");
2112                    let details = error.details.unwrap();
2113                    assert_eq!(
2114                        details["causeKind"],
2115                        match kind {
2116                            "io" => "connect",
2117                            "404" | "401" | "403" | "503" => "status",
2118                            other => other,
2119                        }
2120                    );
2121                    assert_eq!(
2122                        details.get("httpStatus").and_then(|value| value.as_u64()),
2123                        kind.parse::<u64>().ok()
2124                    );
2125                    let rendered = format!("{}{}", error.message, details);
2126                    for secret in [
2127                        "http://",
2128                        "https://",
2129                        "password",
2130                        "secret-capability-path",
2131                        "secret-query",
2132                        "secret-fragment",
2133                    ] {
2134                        assert!(!rendered.contains(secret), "{operation} {kind}");
2135                    }
2136                    assert!(details.get("causeMessage").is_none());
2137                    assert!(details.get("path").is_none());
2138                }
2139            }
2140        }
2141
2142        #[test]
2143        fn injected_body_and_websocket_errors_do_not_expose_payloads_or_source_chains() {
2144            struct FailingRead;
2145            impl std::io::Read for FailingRead {
2146                fn read(&mut self, _: &mut [u8]) -> std::io::Result<usize> {
2147                    Err(std::io::Error::other(SECRET))
2148                }
2149            }
2150            let response = ureq::http::Response::builder()
2151                .status(200)
2152                .body(ureq::Body::builder().reader(FailingRead))
2153                .unwrap();
2154            let error = segment_error(read_body(response, None, None).unwrap_err());
2155            assert_eq!(error.code, "sync.transport_failed");
2156            assert_eq!(error.message, "segment transfer failed");
2157            assert_eq!(
2158                error.details,
2159                Some(serde_json::json!({ "causeKind": "body", "httpStatus": 200 }))
2160            );
2161            for cause in [
2162                tungstenite::Error::Io(std::io::Error::other(SecretError)),
2163                tungstenite::Error::Http(Box::new(
2164                    tungstenite::http::Response::builder()
2165                        .status(403)
2166                        .header("location", SECRET)
2167                        .body(Some(SECRET.as_bytes().to_vec()))
2168                        .unwrap(),
2169                )),
2170            ] {
2171                let error = ws_err("realtime connection failed", cause);
2172                assert_eq!(error.message, "realtime connection failed");
2173                let details = error.details.unwrap();
2174                assert!(details.get("path").is_none());
2175                assert!(details.get("causeMessage").is_none());
2176                assert!(!details.to_string().contains("secret"));
2177            }
2178        }
2179
2180        #[test]
2181        fn body_read_timeouts_are_classified_through_ureq_wrapping() {
2182            // Direct: Error::into_io boxes a non-io ureq error as io::Error::other.
2183            let direct = ureq::Error::Timeout(ureq::Timeout::Global).into_io();
2184            assert!(is_timeout_io(&direct));
2185            // Compressed: gzip wraps the io error again as Error::Decompress.
2186            let wrapped = ureq::Error::Decompress(
2187                "gzip",
2188                ureq::Error::Timeout(ureq::Timeout::Global).into_io(),
2189            )
2190            .into_io();
2191            assert!(is_timeout_io(&wrapped));
2192            // A plain socket timeout and a non-timeout are handled too.
2193            assert!(is_timeout_io(&std::io::Error::new(
2194                std::io::ErrorKind::TimedOut,
2195                "plain"
2196            )));
2197            assert!(!is_timeout_io(&std::io::Error::other("other")));
2198            assert!(!is_timeout_io(&ureq::Error::StatusCode(500).into_io()));
2199        }
2200
2201        #[test]
2202        fn the_configured_request_timeout_is_imposed_on_each_call() {
2203            #[derive(Debug)]
2204            #[allow(clippy::type_complexity)]
2205            struct TimeoutRecording(Arc<Mutex<Vec<(Option<Duration>, bool)>>>);
2206            impl Connector for TimeoutRecording {
2207                type Out = ();
2208                fn connect(
2209                    &self,
2210                    details: &ConnectionDetails,
2211                    _: Option<()>,
2212                ) -> Result<Option<()>, ureq::Error> {
2213                    self.0
2214                        .lock()
2215                        .unwrap()
2216                        .push((details.config.timeouts().global, details.request_level));
2217                    Err(ureq::Error::ConnectionFailed)
2218                }
2219            }
2220            let seen = Arc::new(Mutex::new(Vec::new()));
2221            let mut transport =
2222                NativeTransport::new("http://127.0.0.1", &serde_json::json!({}), None).unwrap();
2223            transport
2224                .set_policy(HostTransportPolicy {
2225                    request_timeout: Some(Duration::from_millis(1234)),
2226                    ..Default::default()
2227                })
2228                .unwrap();
2229            transport.agent = ureq::Agent::with_parts(
2230                ureq::Agent::config_builder().build(),
2231                TimeoutRecording(Arc::clone(&seen)),
2232                DefaultResolver::default(),
2233            );
2234            let _ = transport.blob_upload("sha256:test", &[0u8; 4], None);
2235            let seen = seen.lock().unwrap();
2236            assert_eq!(seen.len(), 1);
2237            assert_eq!(
2238                seen[0],
2239                (Some(Duration::from_millis(1234)), true),
2240                "the request-level config carries the policy timeout"
2241            );
2242        }
2243
2244        #[test]
2245        fn body_read_wrapped_timeouts_surface_the_typed_code() {
2246            struct TimeoutRead(bool);
2247            impl std::io::Read for TimeoutRead {
2248                fn read(&mut self, _: &mut [u8]) -> std::io::Result<usize> {
2249                    Err(if self.0 {
2250                        ureq::Error::Timeout(ureq::Timeout::Global).into_io()
2251                    } else {
2252                        ureq::Error::Decompress(
2253                            "gzip",
2254                            ureq::Error::Timeout(ureq::Timeout::Global).into_io(),
2255                        )
2256                        .into_io()
2257                    })
2258                }
2259            }
2260            for compressed in [false, true] {
2261                let response = ureq::http::Response::builder()
2262                    .status(200)
2263                    .body(ureq::Body::builder().reader(TimeoutRead(compressed)))
2264                    .unwrap();
2265                let error = read_body(response, None, None).unwrap_err();
2266                assert_eq!(error.code, "transport.timeout", "{compressed}");
2267                assert_eq!(
2268                    error.details,
2269                    Some(serde_json::json!({ "causeKind": "timeout" }))
2270                );
2271            }
2272        }
2273
2274        #[test]
2275        fn policy_validation_rejects_zero_and_unrepresentable_bounds() {
2276            assert_eq!(
2277                HostTransportPolicy {
2278                    request_timeout: Some(Duration::ZERO),
2279                    ..Default::default()
2280                }
2281                .validate()
2282                .unwrap_err(),
2283                "sync.invalid_request: transport config requestTimeoutMs is out of range"
2284            );
2285            assert_eq!(
2286                HostTransportPolicy {
2287                    round_deadline: Some(Duration::MAX),
2288                    ..Default::default()
2289                }
2290                .validate()
2291                .unwrap_err(),
2292                "sync.invalid_request: transport config roundDeadlineMs is out of range"
2293            );
2294            assert!(HostTransportPolicy {
2295                max_request_bytes: Some(0),
2296                ..Default::default()
2297            }
2298            .validate()
2299            .is_err());
2300            assert!(HostTransportPolicy {
2301                max_response_bytes: Some(0),
2302                ..Default::default()
2303            }
2304            .validate()
2305            .is_err());
2306            // A programmatic zero or an unrepresentable duration matches the
2307            // JSON rejection instead of panicking at `Instant::now() + budget`.
2308            assert!(HostTransportPolicy::default().validate().is_ok());
2309            assert_eq!(
2310                HostTransportPolicy::from_config(&serde_json::json!({ "requestTimeoutMs": 0 }))
2311                    .unwrap_err(),
2312                "sync.invalid_request: transport config requestTimeoutMs must be a positive integer"
2313            );
2314        }
2315
2316        #[test]
2317        fn set_policy_rejects_invalid_and_preserves_the_current_policy() {
2318            let mut transport =
2319                NativeTransport::new("http://127.0.0.1", &serde_json::json!({}), None).unwrap();
2320            assert_eq!(transport.policy.max_response_bytes, None);
2321            let error = transport
2322                .set_policy(HostTransportPolicy {
2323                    max_response_bytes: Some(0),
2324                    ..Default::default()
2325                })
2326                .unwrap_err();
2327            assert_eq!(
2328                error,
2329                "sync.invalid_request: transport config maxResponseBytes must be a positive integer"
2330            );
2331            assert_eq!(transport.policy.max_response_bytes, None);
2332        }
2333
2334        #[test]
2335        fn an_expired_round_deadline_is_typed_and_cleared_for_the_next_round() {
2336            let mut transport =
2337                NativeTransport::new("http://127.0.0.1:9", &serde_json::json!({}), None).unwrap();
2338            transport.set_round_deadline(Some(std::time::Instant::now() - Duration::from_secs(1)));
2339            let error = transport.blob_download("sha256:test").unwrap_err();
2340            assert_eq!(error.code, "transport.timeout");
2341            assert_eq!(
2342                error.details,
2343                Some(serde_json::json!({ "causeKind": "round_deadline" }))
2344            );
2345            // Clearing the scope leaves no stale deadline for the next round.
2346            transport.set_round_deadline(None);
2347            assert!(transport.effective_timeout().is_ok());
2348        }
2349
2350        #[test]
2351        fn an_expired_round_deadline_refuses_before_queuing_a_realtime_send() {
2352            let mut transport =
2353                NativeTransport::new("http://127.0.0.1:9", &serde_json::json!({}), None).unwrap();
2354            transport.set_round_deadline(Some(std::time::Instant::now() - Duration::from_secs(1)));
2355            // The refusal precedes the connection check, so a not-connected
2356            // transport reports the typed round timeout and leaves no frame
2357            // queued behind it for a later send.
2358            let error = transport.realtime_send("blocked").unwrap_err();
2359            assert_eq!(error.code, "transport.timeout");
2360            assert_eq!(
2361                error.details,
2362                Some(serde_json::json!({ "causeKind": "round_deadline" }))
2363            );
2364            // With the deadline cleared the same call reaches the connection
2365            // check instead.
2366            transport.set_round_deadline(None);
2367            let error = transport.realtime_send("blocked").unwrap_err();
2368            assert_eq!(error.code, "transport.failed");
2369        }
2370
2371        #[test]
2372        fn an_expired_round_deadline_bounds_the_socket_wait() {
2373            let channel = RoundChannel::default();
2374            let error = channel
2375                .wait(Some(std::time::Instant::now() - Duration::from_secs(1)))
2376                .unwrap_err();
2377            assert_eq!(error.code, "transport.timeout");
2378            assert_eq!(
2379                error.details,
2380                Some(serde_json::json!({ "causeKind": "round_deadline" }))
2381            );
2382        }
2383    }
2384}
2385
2386#[cfg(all(test, feature = "native-transport"))]
2387mod tests {
2388    use super::*;
2389    use std::io::{BufRead, BufReader, Write};
2390
2391    #[test]
2392    fn detached_round_uses_the_owned_socket_and_shutdown_releases_it() {
2393        use ssp2::model::{Frame, Message, MsgKind};
2394        use std::sync::mpsc::channel;
2395        for cancelled in [false, true] {
2396            let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2397            let base = format!("http://{}", listener.local_addr().unwrap());
2398            let (entered_tx, entered_rx) = channel();
2399            let (release_tx, release_rx) = channel();
2400            let server = std::thread::spawn(move || {
2401                let (socket, _) = listener.accept().unwrap();
2402                let mut socket = tungstenite::accept(socket).unwrap();
2403                let request = socket.read().unwrap().into_data();
2404                assert_eq!(request[0], crate::REALTIME_TAG_ROUND);
2405                assert_eq!(
2406                    ssp2::decode_message(&request[1..]).unwrap().msg_kind,
2407                    MsgKind::Request
2408                );
2409                entered_tx.send(()).unwrap();
2410                release_rx.recv().unwrap();
2411                if !cancelled {
2412                    let response = ssp2::encode_message(&Message {
2413                        wire_version: ssp2::decode::WIRE_VERSION,
2414                        msg_kind: MsgKind::Response,
2415                        frames: vec![Frame::RespHeader {
2416                            required_schema_version: None,
2417                            latest_schema_version: None,
2418                            log_epoch: Some("epoch".into()),
2419                            reset_required: Some(true),
2420                        }],
2421                    });
2422                    let mut chunk = vec![crate::REALTIME_TAG_ROUND];
2423                    chunk.extend(response);
2424                    socket
2425                        .send(tungstenite::Message::Binary(chunk.into()))
2426                        .unwrap();
2427                }
2428            });
2429            let mut owner =
2430                HostTransport::new_from_config(&serde_json::json!({"baseUrl":base})).unwrap();
2431            owner.realtime_connect_for_client("detached-round").unwrap();
2432            let mut detached = owner.fork_round();
2433            let network = std::thread::spawn(move || {
2434                let request = ssp2::encode_message(&Message {
2435                    wire_version: ssp2::decode::WIRE_VERSION,
2436                    msg_kind: MsgKind::Request,
2437                    frames: vec![
2438                        Frame::ReqHeader {
2439                            client_id: "detached-round".into(),
2440                            schema_version: 1,
2441                            log_epoch: None,
2442                        },
2443                        Frame::PullHeader {
2444                            limit_commits: 0,
2445                            limit_snapshot_rows: 0,
2446                            max_snapshot_pages: 0,
2447                            accept: 3,
2448                        },
2449                    ],
2450                });
2451                detached.realtime_sync(&request)
2452            });
2453            entered_rx.recv().unwrap();
2454            owner.set_headers(vec![("authorization".into(), "Bearer rotated".into())]);
2455            assert!(owner.take_inbound().is_empty());
2456            if cancelled {
2457                owner.shutdown();
2458            }
2459            release_tx.send(()).unwrap();
2460            let response = network.join().unwrap();
2461            if cancelled {
2462                assert!(response.is_err());
2463            } else {
2464                assert_eq!(
2465                    ssp2::decode_message(&response.unwrap()).unwrap().msg_kind,
2466                    MsgKind::Response
2467                );
2468                owner.shutdown();
2469            }
2470            server.join().unwrap();
2471        }
2472    }
2473
2474    #[test]
2475    fn segment_http_failures_have_named_codes_and_safe_cause_details() {
2476        for signed in [false, true] {
2477            for body_failure in [false, true] {
2478                let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2479                let base = format!("http://{}", listener.local_addr().unwrap());
2480                let server = std::thread::spawn(move || {
2481                    let (mut socket, _) = listener.accept().unwrap();
2482                    let mut reader = BufReader::new(socket.try_clone().unwrap());
2483                    loop {
2484                        let mut line = String::new();
2485                        reader.read_line(&mut line).unwrap();
2486                        if line == "\r\n" {
2487                            break;
2488                        }
2489                        assert!(!line.is_empty());
2490                    }
2491                    socket.write_all(if body_failure {
2492                        b"HTTP/1.1 200 OK\r\nContent-Length: 999\r\nConnection: close\r\n\r\nshort"
2493                    } else {
2494                        b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
2495                    }).unwrap();
2496                });
2497                let mut transport =
2498                    HostTransport::new_from_config(&serde_json::json!({"baseUrl": base})).unwrap();
2499                let mut progress = |_| {};
2500                let error = if signed {
2501                    transport.fetch_url(&format!("{base}/signed?signature=secret"), &mut progress)
2502                } else {
2503                    transport.download_segment(
2504                        &SegmentRequest {
2505                            segment_id: "sha256:test".into(),
2506                            table: "tasks".into(),
2507                            requested_scopes_json: "{}".into(),
2508                        },
2509                        &mut progress,
2510                    )
2511                }
2512                .unwrap_err();
2513                assert_eq!(error.code, "sync.transport_failed");
2514                assert_eq!(error.message, "segment transfer failed");
2515                let details = error.details.unwrap();
2516                assert!(details.get("path").is_none());
2517                assert!(details.get("causeMessage").is_none());
2518                assert_eq!(
2519                    details["causeKind"],
2520                    if body_failure { "body" } else { "status" }
2521                );
2522                assert_eq!(details["httpStatus"], if body_failure { 200 } else { 403 });
2523                server.join().unwrap();
2524            }
2525        }
2526    }
2527
2528    #[test]
2529    fn blob_status_classification_uses_the_actual_http_response() {
2530        for (status, expected) in [
2531            (404, "blob.not_found"),
2532            (401, "sync.auth_required"),
2533            (403, "blob.forbidden"),
2534            (500, "transport.failed"),
2535        ] {
2536            let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2537            let base = format!("http://{}", listener.local_addr().unwrap());
2538            let server = std::thread::spawn(move || {
2539                let (mut socket, _) = listener.accept().unwrap();
2540                let mut reader = BufReader::new(socket.try_clone().unwrap());
2541                loop {
2542                    let mut line = String::new();
2543                    reader.read_line(&mut line).unwrap();
2544                    if line == "\r\n" {
2545                        break;
2546                    }
2547                    assert!(!line.is_empty());
2548                }
2549                let body = r#"{"code":"blob.not_found","message":"https://host/secret-path?secret-query#secret-fragment","retryable":false}"#;
2550                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();
2551            });
2552            let mut transport =
2553                HostTransport::new_from_config(&serde_json::json!({"baseUrl":base})).unwrap();
2554            let error = transport.blob_download("sha256:test").unwrap_err();
2555            assert_eq!(error.code, expected);
2556            assert_eq!(
2557                error.details,
2558                Some(serde_json::json!({ "causeKind":"status", "httpStatus":status }))
2559            );
2560            assert!(!error.message.contains("secret"));
2561            assert!(!error.message.contains("http"));
2562            server.join().unwrap();
2563        }
2564    }
2565
2566    #[test]
2567    fn refused_segment_connection_has_a_named_code_without_an_invented_status() {
2568        for signed in [false, true] {
2569            let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2570            let base = format!("http://{}", listener.local_addr().unwrap());
2571            drop(listener);
2572            let mut transport =
2573                HostTransport::new_from_config(&serde_json::json!({"baseUrl": base})).unwrap();
2574            let mut progress = |_| {};
2575            let error = if signed {
2576                transport.fetch_url(&format!("{base}/signed?signature=secret"), &mut progress)
2577            } else {
2578                transport.download_segment(
2579                    &SegmentRequest {
2580                        segment_id: "sha256:test".into(),
2581                        table: "tasks".into(),
2582                        requested_scopes_json: "{}".into(),
2583                    },
2584                    &mut progress,
2585                )
2586            }
2587            .unwrap_err();
2588            assert_eq!(error.code, "sync.transport_failed");
2589            assert!(error.details.as_ref().unwrap().get("httpStatus").is_none());
2590        }
2591    }
2592
2593    #[test]
2594    fn segment_http_reports_intermediate_bytes_for_direct_and_signed_urls() {
2595        for signed in [false, true] {
2596            let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2597            let base = format!("http://{}", listener.local_addr().unwrap());
2598            let server = std::thread::spawn(move || {
2599                let (mut socket, _) = listener.accept().unwrap();
2600                let mut reader = BufReader::new(socket.try_clone().unwrap());
2601                let mut headers = String::new();
2602                loop {
2603                    let mut line = String::new();
2604                    reader.read_line(&mut line).unwrap();
2605                    if line == "\r\n" {
2606                        break;
2607                    }
2608                    assert!(!line.is_empty());
2609                    headers.push_str(&line.to_ascii_lowercase());
2610                }
2611                assert_eq!(headers.contains("authorization: bearer test"), !signed);
2612                assert_eq!(headers.contains("x-syncular-scopes:"), !signed);
2613                let bytes = vec![7; 128 * 1024 + 17];
2614                write!(
2615                    socket,
2616                    "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
2617                    bytes.len()
2618                )
2619                .unwrap();
2620                socket.write_all(&bytes).unwrap();
2621            });
2622            let mut transport = HostTransport::from_config(
2623                &serde_json::json!({"baseUrl": base, "headers": {"authorization": "Bearer test"}}),
2624            )
2625            .unwrap();
2626            let mut updates = Vec::new();
2627            let mut progress = |bytes| updates.push(bytes);
2628            let bytes = if signed {
2629                transport
2630                    .fetch_url(&format!("{base}/signed"), &mut progress)
2631                    .unwrap()
2632            } else {
2633                transport
2634                    .download_segment(
2635                        &SegmentRequest {
2636                            segment_id: "sha256:test".into(),
2637                            table: "tasks".into(),
2638                            requested_scopes_json: "{}".into(),
2639                        },
2640                        &mut progress,
2641                    )
2642                    .unwrap()
2643            };
2644            assert_eq!(bytes.len(), 128 * 1024 + 17);
2645            assert!(updates.iter().any(|n| *n > 0 && *n < bytes.len() as u64));
2646            assert_eq!(updates.last(), Some(&(bytes.len() as u64)));
2647            assert!(updates.windows(2).all(|pair| pair[0] < pair[1]));
2648            server.join().unwrap();
2649        }
2650    }
2651
2652    #[test]
2653    fn request_over_cap_is_refused_before_network() {
2654        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2655        listener.set_nonblocking(true).unwrap();
2656        let base = format!("http://{}", listener.local_addr().unwrap());
2657        let mut transport =
2658            HostTransport::from_config(&serde_json::json!({"baseUrl": base, "maxRequestBytes": 4}))
2659                .unwrap();
2660        let error = transport
2661            .blob_upload("sha256:test", &[0u8; 8], None)
2662            .unwrap_err();
2663        assert_eq!(error.code, "transport.request_too_large");
2664        assert_eq!(
2665            error.details,
2666            Some(serde_json::json!({ "limit": 4, "size": 8 }))
2667        );
2668        assert!(
2669            matches!(listener.accept(), Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock),
2670            "the request cap is checked before any connection"
2671        );
2672    }
2673
2674    #[test]
2675    fn credential_bearing_redirects_are_refused_without_contacting_the_destination() {
2676        for credentials in ["headers", "userinfo", "password"] {
2677            for redirects in ["deny", "follow"] {
2678                let destination = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2679                destination.set_nonblocking(true).unwrap();
2680                let location = format!("http://{}/landed", destination.local_addr().unwrap());
2681                let origin = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2682                let host = origin.local_addr().unwrap();
2683                let (base, headers) = match credentials {
2684                    "headers" => (
2685                        format!("http://{host}"),
2686                        serde_json::json!({"authorization": "Bearer secret"}),
2687                    ),
2688                    "userinfo" => (format!("http://user:secret@{host}"), serde_json::json!({})),
2689                    _ => (format!("http://:secret@{host}"), serde_json::json!({})),
2690                };
2691                let server = std::thread::spawn(move || {
2692                    let (mut socket, _) = origin.accept().unwrap();
2693                    let mut reader = BufReader::new(socket.try_clone().unwrap());
2694                    loop {
2695                        let mut line = String::new();
2696                        reader.read_line(&mut line).unwrap();
2697                        if line == "\r\n" {
2698                            break;
2699                        }
2700                    }
2701                    let _ = write!(
2702                        socket,
2703                        "HTTP/1.1 302 Found\r\nLocation: {location}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
2704                    );
2705                });
2706                let mut transport = HostTransport::from_config(&serde_json::json!({
2707                    "baseUrl": base,
2708                    "redirects": redirects,
2709                    "headers": headers,
2710                }))
2711                .unwrap();
2712                let error = transport.blob_download("sha256:test").unwrap_err();
2713                assert_eq!(
2714                    error.code, "transport.redirect",
2715                    "{credentials} {redirects}"
2716                );
2717                let details = error.details.unwrap();
2718                assert_eq!(details["causeKind"], "redirect");
2719                assert_eq!(details["httpStatus"], 302);
2720                assert!(
2721                    matches!(destination.accept(), Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock),
2722                    "{credentials} {redirects}: the destination is never contacted"
2723                );
2724                server.join().unwrap();
2725            }
2726        }
2727    }
2728
2729    #[test]
2730    fn websocket_handshake_redirects_obey_the_redirect_policy() {
2731        // The handshake request carries the configured headers and the realtime
2732        // URL's query, so a redirect response is an error and never replays
2733        // them to the Location. The Location points at a second listener so
2734        // the test can prove it was never contacted. A `follow` policy cannot
2735        // establish at all and fails before any connection (see
2736        // `tests/realtime_disconnect.rs`).
2737        for credential in ["headers", "userinfo", "query", "none"] {
2738            let redirects = "deny";
2739            let destination = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2740            let destination_addr = destination.local_addr().unwrap();
2741            let location = format!("ws://{destination_addr}/realtime");
2742            // The redirect target answers every handshake with 400 and stops
2743            // on an explicit sentinel connection, so a leaked redirect fails
2744            // the assertions below instead of hanging the test.
2745            let landed = Arc::new(Mutex::new(Vec::new()));
2746            let landed_writer = Arc::clone(&landed);
2747            let destination_thread = std::thread::spawn(move || loop {
2748                let (mut socket, _) = destination.accept().unwrap();
2749                let mut reader = BufReader::new(socket.try_clone().unwrap());
2750                let mut head = String::new();
2751                loop {
2752                    let mut line = String::new();
2753                    reader.read_line(&mut line).unwrap();
2754                    if line == "\r\n" {
2755                        break;
2756                    }
2757                    head.push_str(&line);
2758                }
2759                let sentinel = head.starts_with("GET /sentinel");
2760                landed_writer.lock().unwrap().push(head);
2761                let _ = write!(
2762                    socket,
2763                    "HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
2764                );
2765                if sentinel {
2766                    break;
2767                }
2768            });
2769            let origin = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2770            let origin_addr = origin.local_addr().unwrap();
2771            let (headers, ws_url) = match credential {
2772                "headers" => (serde_json::json!({"authorization": "Bearer secret"}), None),
2773                "userinfo" => (
2774                    serde_json::json!({}),
2775                    Some(format!("ws://user:secret@{origin_addr}/realtime")),
2776                ),
2777                "query" => (
2778                    serde_json::json!({}),
2779                    Some(format!("ws://{origin_addr}/realtime?token=secret")),
2780                ),
2781                _ => (serde_json::json!({}), None),
2782            };
2783            let (head_tx, head_rx) = std::sync::mpsc::channel();
2784            let origin_thread = std::thread::spawn(move || {
2785                let (mut socket, _) = origin.accept().unwrap();
2786                let mut reader = BufReader::new(socket.try_clone().unwrap());
2787                let mut head = String::new();
2788                loop {
2789                    let mut line = String::new();
2790                    reader.read_line(&mut line).unwrap();
2791                    if line == "\r\n" {
2792                        break;
2793                    }
2794                    head.push_str(&line);
2795                }
2796                let _ = head_tx.send(head);
2797                let _ = write!(
2798                    socket,
2799                    "HTTP/1.1 302 Found\r\nLocation: {location}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
2800                );
2801            });
2802            let mut config = serde_json::json!({
2803                "baseUrl": format!("http://{origin_addr}"),
2804                "headers": headers,
2805                "redirects": redirects,
2806            });
2807            if let Some(ws_url) = &ws_url {
2808                config["wsUrl"] = serde_json::json!(ws_url);
2809            }
2810            let mut transport = HostTransport::from_config(&config).unwrap();
2811            let result = transport.realtime_connect();
2812            // Stop the responder even when the redirect was refused; the
2813            // sentinel is the only connection it may have seen.
2814            let mut sentinel = std::net::TcpStream::connect(destination_addr).unwrap();
2815            let _ = write!(sentinel, "GET /sentinel HTTP/1.1\r\nHost: sentinel\r\n\r\n");
2816            destination_thread.join().unwrap();
2817            let landed = landed.lock().unwrap().clone();
2818            origin_thread.join().unwrap();
2819            let error = result.unwrap_err();
2820            assert_eq!(
2821                error.code, "transport.redirect",
2822                "{credential}: a refused handshake redirect surfaces the redirect code"
2823            );
2824            let details = error.details.unwrap();
2825            assert_eq!(details["causeKind"], "redirect");
2826            assert_eq!(details["httpStatus"], 302);
2827            assert_eq!(
2828                landed.len(),
2829                1,
2830                "{credential}: the redirect target is never contacted: {landed:?}"
2831            );
2832            assert!(landed[0].starts_with("GET /sentinel"), "{landed:?}");
2833            let head = head_rx.recv().unwrap();
2834            if credential == "userinfo" {
2835                assert!(
2836                    !head.contains("secret"),
2837                    "the handshake request line drops URL userinfo"
2838                );
2839            }
2840            if credential == "query" {
2841                assert!(
2842                    head.contains("token=secret"),
2843                    "the handshake request line carries the realtime query"
2844                );
2845            }
2846        }
2847    }
2848
2849    // 200000 decoded bytes in 548 compressed bytes: a decompression bomb the
2850    // decoded cap must stop before the buffer grows.
2851    const GZIP_BOMB: [u8; 548] = [
2852        0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xed, 0xc9, 0xb1, 0x11, 0x80,
2853        0x20, 0x10, 0x00, 0xb0, 0x59, 0x1c, 0x80, 0xa1, 0xfe, 0x90, 0xb3, 0x41, 0xf0, 0x7c, 0x2d,
2854        0xd8, 0xde, 0x09, 0xdc, 0x20, 0x69, 0x93, 0x6b, 0xd4, 0xb7, 0xc7, 0x5d, 0xb2, 0x1d, 0x67,
2855        0x1b, 0x4f, 0xb9, 0x62, 0xf5, 0x19, 0xfb, 0x96, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
2856        0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
2857        0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
2858        0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
2859        0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
2860        0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
2861        0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
2862        0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
2863        0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
2864        0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
2865        0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
2866        0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
2867        0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
2868        0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
2869        0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
2870        0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
2871        0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
2872        0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
2873        0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
2874        0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
2875        0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
2876        0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
2877        0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
2878        0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
2879        0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
2880        0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
2881        0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
2882        0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
2883        0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
2884        0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
2885        0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
2886        0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08,
2887        0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0x10, 0x42, 0x08, 0x21, 0x84, 0xf8, 0x8b, 0x0f,
2888        0xdb, 0x82, 0xf4, 0xa9, 0x40, 0x0d, 0x03, 0x00,
2889    ];
2890
2891    #[test]
2892    fn decoded_response_cap_counts_decompressed_bytes() {
2893        for cap in [200000u64, 199999] {
2894            let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2895            let base = format!("http://{}", listener.local_addr().unwrap());
2896            let server = std::thread::spawn(move || {
2897                let (mut socket, _) = listener.accept().unwrap();
2898                let mut reader = BufReader::new(socket.try_clone().unwrap());
2899                loop {
2900                    let mut line = String::new();
2901                    reader.read_line(&mut line).unwrap();
2902                    if line == "\r\n" {
2903                        break;
2904                    }
2905                }
2906                let _ = write!(
2907                    socket,
2908                    "HTTP/1.1 200 OK\r\nContent-Encoding: gzip\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
2909                    GZIP_BOMB.len()
2910                );
2911                let _ = socket.write_all(&GZIP_BOMB);
2912            });
2913            let mut transport = HostTransport::from_config(
2914                &serde_json::json!({"baseUrl": base, "maxResponseBytes": cap}),
2915            )
2916            .unwrap();
2917            let result = transport.fetch_blob_url(&format!("{base}/signed"));
2918            if cap == 200000 {
2919                let body = result.expect("a cap equal to the decoded size admits the body");
2920                assert_eq!(body.len(), 200000);
2921            } else {
2922                let error = result.unwrap_err();
2923                assert_eq!(error.code, "transport.response_too_large");
2924                assert_eq!(error.details, Some(serde_json::json!({ "limit": cap })));
2925            }
2926            server.join().unwrap();
2927        }
2928    }
2929}