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