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