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};
20
21use crate::{BlobDownload, BlobUploadGrant, SegmentRequest, Transport, TransportError};
22
23/// One inbound realtime frame buffered for the client's `on_realtime_*`.
24pub enum Inbound {
25    Text(String),
26    Binary(Vec<u8>),
27}
28
29/// The shared inbound buffer the WS reader thread fills and the command path
30/// drains. The optional callback carries no data; it only wakes an owning
31/// mailbox so the host can drain the buffer promptly.
32pub struct InboundBuffer {
33    frames: Mutex<Vec<Inbound>>,
34    notify: Option<Arc<dyn Fn() + Send + Sync>>,
35}
36
37impl Default for InboundBuffer {
38    fn default() -> Self {
39        Self {
40            frames: Mutex::new(Vec::new()),
41            notify: None,
42        }
43    }
44}
45
46impl InboundBuffer {
47    pub fn with_notify(notify: Arc<dyn Fn() + Send + Sync>) -> Self {
48        Self {
49            frames: Mutex::new(Vec::new()),
50            notify: Some(notify),
51        }
52    }
53
54    pub fn push(&self, frame: Inbound) {
55        self.frames.lock().expect("inbound lock").push(frame);
56        if let Some(notify) = &self.notify {
57            notify();
58        }
59    }
60    fn take(&self) -> Vec<Inbound> {
61        std::mem::take(&mut *self.frames.lock().expect("inbound lock"))
62    }
63}
64
65pub enum HostTransport {
66    /// No network: client-local commands only (dependency-lean default).
67    Null {
68        signed_urls: bool,
69        inbound: Arc<InboundBuffer>,
70    },
71    #[cfg(feature = "native-transport")]
72    Native(native::NativeTransport),
73}
74
75impl HostTransport {
76    /// Build the transport from the `new` config. `{}` (or no `baseUrl`) →
77    /// `Null`; a `baseUrl` under the `native-transport` feature → `Native`.
78    pub fn from_config(config: &serde_json::Value) -> Result<Self, String> {
79        Self::new_from_config(config)
80    }
81
82    /// [`Self::from_config`] for hosts outside this crate (the bench driver)
83    /// that own their inbound-frame drain and need no FFI event queue.
84    pub fn new_from_config(config: &serde_json::Value) -> Result<Self, String> {
85        Self::from_config_with_notify(config, None)
86    }
87
88    /// Build a transport whose realtime reader wakes a mailbox/event loop.
89    /// The callback is invoked after a frame is buffered and never carries
90    /// protocol data itself.
91    pub fn from_config_with_notify(
92        config: &serde_json::Value,
93        notify: Option<Arc<dyn Fn() + Send + Sync>>,
94    ) -> Result<Self, String> {
95        #[cfg(feature = "native-transport")]
96        {
97            if let Some(base_url) = config.get("baseUrl").and_then(|v| v.as_str()) {
98                return Ok(HostTransport::Native(native::NativeTransport::new(
99                    base_url, config, notify,
100                )?));
101            }
102        }
103        #[cfg(not(feature = "native-transport"))]
104        {
105            if config.get("baseUrl").is_some() {
106                return Err(
107                    "this build has no native transport (rebuild with --features native-transport)"
108                        .to_owned(),
109                );
110            }
111        }
112        Ok(HostTransport::Null {
113            signed_urls: false,
114            inbound: Arc::new(match notify {
115                Some(notify) => InboundBuffer::with_notify(notify),
116                None => InboundBuffer::default(),
117            }),
118        })
119    }
120
121    pub fn set_signed_urls(&mut self, value: bool) {
122        match self {
123            HostTransport::Null { signed_urls, .. } => *signed_urls = value,
124            #[cfg(feature = "native-transport")]
125            HostTransport::Native(t) => t.signed_urls = value,
126        }
127    }
128
129    /// Replace auth/application headers for subsequent HTTP requests and the
130    /// next realtime connection. A live socket retains its handshake headers.
131    pub fn set_headers(&mut self, headers: Vec<(String, String)>) {
132        match self {
133            HostTransport::Null { .. } => drop(headers),
134            #[cfg(feature = "native-transport")]
135            HostTransport::Native(t) => t.set_headers(headers),
136        }
137    }
138
139    /// Drain the inbound realtime frames buffered since the last call.
140    pub fn take_inbound(&mut self) -> Vec<Inbound> {
141        match self {
142            HostTransport::Null { inbound, .. } => inbound.take(),
143            #[cfg(feature = "native-transport")]
144            HostTransport::Native(t) => t.inbound.take(),
145        }
146    }
147
148    /// Release the socket/reader thread. Idempotent.
149    pub fn shutdown(&mut self) {
150        match self {
151            HostTransport::Null { .. } => {}
152            #[cfg(feature = "native-transport")]
153            HostTransport::Native(t) => t.shutdown(),
154        }
155    }
156}
157
158fn unavailable(op: &str) -> TransportError {
159    TransportError::new(
160        "transport.unavailable",
161        format!("{op} needs the native transport (build with --features native-transport)"),
162    )
163}
164
165// Without the native transport the `Null` arm ignores every request payload;
166// the params are only consumed by the feature-gated `Native` arm.
167#[cfg_attr(not(feature = "native-transport"), allow(unused_variables))]
168impl Transport for HostTransport {
169    fn sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
170        match self {
171            HostTransport::Null { .. } => Err(unavailable("sync")),
172            #[cfg(feature = "native-transport")]
173            HostTransport::Native(t) => t.sync(request),
174        }
175    }
176
177    fn remote_operation(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
178        match self {
179            HostTransport::Null { .. } => Err(unavailable("remoteOperation")),
180            #[cfg(feature = "native-transport")]
181            HostTransport::Native(t) => t.remote_operation(request),
182        }
183    }
184
185    fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
186        match self {
187            HostTransport::Null { .. } => Err(unavailable("realtimeSync")),
188            #[cfg(feature = "native-transport")]
189            HostTransport::Native(t) => t.realtime_sync(request),
190        }
191    }
192
193    fn download_segment(
194        &mut self,
195        request: &SegmentRequest,
196        on_progress: &mut dyn FnMut(u64),
197    ) -> Result<Vec<u8>, TransportError> {
198        match self {
199            HostTransport::Null { .. } => Err(unavailable("downloadSegment")),
200            #[cfg(feature = "native-transport")]
201            HostTransport::Native(t) => t.download_segment(request, on_progress),
202        }
203    }
204
205    fn supports_url_fetch(&self) -> bool {
206        match self {
207            HostTransport::Null { signed_urls, .. } => *signed_urls,
208            #[cfg(feature = "native-transport")]
209            HostTransport::Native(t) => t.signed_urls,
210        }
211    }
212
213    fn fetch_url(
214        &mut self,
215        url: &str,
216        on_progress: &mut dyn FnMut(u64),
217    ) -> Result<Vec<u8>, TransportError> {
218        match self {
219            HostTransport::Null { .. } => Err(unavailable("fetchUrl")),
220            #[cfg(feature = "native-transport")]
221            HostTransport::Native(t) => t.fetch_url(url, on_progress),
222        }
223    }
224
225    fn blob_upload(
226        &mut self,
227        blob_id: &str,
228        bytes: &[u8],
229        media_type: Option<&str>,
230    ) -> Result<(), TransportError> {
231        match self {
232            HostTransport::Null { .. } => Err(unavailable("blobUpload")),
233            #[cfg(feature = "native-transport")]
234            HostTransport::Native(t) => t.blob_upload(blob_id, bytes, media_type),
235        }
236    }
237
238    fn blob_download(&mut self, blob_id: &str) -> Result<BlobDownload, TransportError> {
239        match self {
240            HostTransport::Null { .. } => Err(unavailable("blobDownload")),
241            #[cfg(feature = "native-transport")]
242            HostTransport::Native(t) => t.blob_download(blob_id),
243        }
244    }
245
246    fn fetch_blob_url(&mut self, url: &str) -> Result<Vec<u8>, TransportError> {
247        match self {
248            HostTransport::Null { .. } => Err(unavailable("fetchBlobUrl")),
249            #[cfg(feature = "native-transport")]
250            HostTransport::Native(t) => t.fetch_blob_url(url),
251        }
252    }
253
254    fn blob_upload_grant(
255        &mut self,
256        blob_id: &str,
257        byte_length: u64,
258        media_type: Option<&str>,
259    ) -> Result<BlobUploadGrant, TransportError> {
260        match self {
261            // No grant available ⇒ the client streams through the direct
262            // upload endpoint (§5.9.3 capability, not fallback).
263            HostTransport::Null { .. } => Ok(BlobUploadGrant::None),
264            #[cfg(feature = "native-transport")]
265            HostTransport::Native(t) => t.blob_upload_grant(blob_id, byte_length, media_type),
266        }
267    }
268
269    fn blob_put_url(
270        &mut self,
271        url: &str,
272        bytes: &[u8],
273        media_type: Option<&str>,
274    ) -> Result<(), TransportError> {
275        match self {
276            HostTransport::Null { .. } => Err(unavailable("blobPutUrl")),
277            #[cfg(feature = "native-transport")]
278            HostTransport::Native(t) => t.blob_put_url(url, bytes, media_type),
279        }
280    }
281
282    fn realtime_connect(&mut self) -> Result<(), TransportError> {
283        match self {
284            HostTransport::Null { .. } => Err(unavailable("realtimeConnect")),
285            #[cfg(feature = "native-transport")]
286            HostTransport::Native(t) => {
287                t.set_realtime_client_id(None);
288                t.realtime_connect()
289            }
290        }
291    }
292
293    fn realtime_connect_for_client(&mut self, client_id: &str) -> Result<(), TransportError> {
294        match self {
295            HostTransport::Null { .. } => Err(unavailable("realtimeConnect")),
296            #[cfg(feature = "native-transport")]
297            HostTransport::Native(t) => {
298                t.set_realtime_client_id(Some(client_id));
299                t.realtime_connect()
300            }
301        }
302    }
303
304    fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
305        match self {
306            HostTransport::Null { .. } => Err(unavailable("realtimeSend")),
307            #[cfg(feature = "native-transport")]
308            HostTransport::Native(t) => t.realtime_send(text),
309        }
310    }
311
312    fn realtime_close(&mut self) -> Result<(), TransportError> {
313        match self {
314            HostTransport::Null { .. } => Ok(()),
315            #[cfg(feature = "native-transport")]
316            HostTransport::Native(t) => t.realtime_close(),
317        }
318    }
319}
320
321#[cfg(feature = "native-transport")]
322mod native {
323    //! The real native HTTP + WS transport. HTTP via `ureq` (blocking, no
324    //! async runtime — matches the client's synchronous API); WS via
325    //! `tungstenite`, with a reader thread pushing inbound frames into the
326    //! shared `InboundBuffer`.
327    //!
328    //! Wire contract mirrors the reference HTTP+WS bindings (§1.1, §8.7):
329    //! `POST {baseUrl}/sync` (application/vnd.syncular.sync.v2, `X-Syncular-Scopes`
330    //! not needed here — the native app authenticates via configured headers),
331    //! `GET {baseUrl}/segments/{id}`, `PUT/GET {baseUrl}/blobs/{id}`, and the
332    //! realtime socket at `{wsUrl}` (ws(s):// derived from baseUrl).
333
334    use std::net::TcpStream;
335    use std::sync::atomic::{AtomicBool, Ordering};
336    use std::sync::{mpsc, Arc, Condvar, Mutex};
337    use std::thread::JoinHandle;
338    use std::time::Duration;
339
340    use polling::{Event, Events, Poller};
341    use tungstenite::stream::MaybeTlsStream;
342    use tungstenite::Message;
343
344    use super::{Inbound, InboundBuffer};
345    use crate::{
346        BlobDownload, BlobUploadGrant, RealtimeRound, RoundInbound, SegmentRequest, Transport,
347        TransportError,
348    };
349
350    // Unregister before the cloned socket handle is closed, including on unwind.
351    struct SocketRegistration {
352        poller: Arc<Poller>,
353        stream: TcpStream,
354    }
355
356    impl Drop for SocketRegistration {
357        fn drop(&mut self) {
358            let _ = self.poller.delete(&self.stream);
359        }
360    }
361
362    struct SocketSender {
363        queue: mpsc::SyncSender<Outgoing>,
364        poller: Arc<Poller>,
365    }
366
367    struct Outgoing {
368        message: Message,
369        completed: mpsc::Sender<Result<(), TransportError>>,
370    }
371
372    /// How long a single response round waits before giving up (§8.7 rounds
373    /// are bounded — bulk rides segments over HTTP). Generous; a stuck socket
374    /// surfaces as a transport failure rather than hanging the caller forever.
375    const ROUND_TIMEOUT: Duration = Duration::from_secs(30);
376    /// The §8.7 round rendezvous shared between the reader thread (which
377    /// demuxes inbound `0x01` chunks into the round via [`RealtimeRound`]) and
378    /// `realtime_sync` (which begins the round, sends the request, and blocks
379    /// here for the reassembled response). The transport-agnostic framing
380    /// logic lives in [`RealtimeRound`] (the lean client crate, shared with
381    /// the Tauri plugin); this struct is just the thread rendezvous.
382    #[derive(Default)]
383    pub(super) struct RoundChannel {
384        state: Mutex<RoundState>,
385        ready: Condvar,
386    }
387
388    #[derive(Default)]
389    struct RoundState {
390        round: RealtimeRound,
391        /// The completed round outcome, taken by `realtime_sync` once set.
392        outcome: Option<Result<Vec<u8>, TransportError>>,
393    }
394
395    impl RoundChannel {
396        /// Begin a round: frame the request (`0x01` tag + envelope) for the
397        /// socket and mark it in flight. Errors if one is already in flight
398        /// (§8.7 one-in-flight, enforced client-side).
399        fn begin(&self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
400            let mut state = self.state.lock().expect("round lock");
401            state.outcome = None;
402            state.round.begin(request)
403        }
404
405        /// Route one inbound binary frame from the reader thread. Returns the
406        /// delta payload to enqueue on the inbound buffer, if any; a completed
407        /// or failed round is stored and the waiting `realtime_sync` woken.
408        fn route_binary(&self, frame: &[u8]) -> Option<Vec<u8>> {
409            let mut state = self.state.lock().expect("round lock");
410            match state.round.route_binary(frame) {
411                Ok(RoundInbound::Delta(body)) => Some(body),
412                Ok(RoundInbound::RoundProgress) | Ok(RoundInbound::Ignored) => None,
413                Ok(RoundInbound::RoundComplete(bytes)) => {
414                    state.outcome = Some(Ok(bytes));
415                    self.ready.notify_all();
416                    None
417                }
418                Err(error) => {
419                    state.outcome = Some(Err(error));
420                    self.ready.notify_all();
421                    None
422                }
423            }
424        }
425
426        /// Fail any in-flight round (socket dropped) and wake the waiter.
427        fn fail_in_flight(&self, error: TransportError) {
428            let mut state = self.state.lock().expect("round lock");
429            if state.round.in_flight() && state.outcome.is_none() {
430                state.round.abort();
431                state.outcome = Some(Err(error));
432                self.ready.notify_all();
433            }
434        }
435
436        /// Block until the round completes, fails, or `ROUND_TIMEOUT` elapses.
437        fn wait(&self) -> Result<Vec<u8>, TransportError> {
438            let mut state = self.state.lock().expect("round lock");
439            let deadline = std::time::Instant::now() + ROUND_TIMEOUT;
440            while state.outcome.is_none() {
441                let now = std::time::Instant::now();
442                if now >= deadline {
443                    state.round.abort();
444                    return Err(TransportError::new(
445                        "sync.transport_failed",
446                        "realtime sync round timed out (§8.7)",
447                    ));
448                }
449                let (guard, _timeout) = self
450                    .ready
451                    .wait_timeout(state, deadline - now)
452                    .expect("round wait");
453                state = guard;
454            }
455            state.outcome.take().expect("outcome present")
456        }
457    }
458
459    fn http_err(op: &str, e: impl std::fmt::Display) -> TransportError {
460        TransportError::new("transport.failed", format!("{op}: {e}"))
461    }
462
463    fn segment_error(
464        path: Option<&str>,
465        http_status: Option<u16>,
466        cause: impl std::fmt::Display,
467    ) -> TransportError {
468        let mut details = serde_json::json!({ "causeMessage": cause.to_string() });
469        if let Some(path) = path {
470            details["path"] = path.into();
471        }
472        if let Some(status) = http_status {
473            details["httpStatus"] = status.into();
474        }
475        let mut error = TransportError::new("sync.transport_failed", "segment transfer failed");
476        error.details = Some(details);
477        error
478    }
479
480    /// Nonblocking I/O needs a readiness notification before it can continue.
481    fn is_would_block(e: &tungstenite::Error) -> bool {
482        matches!(
483            e,
484            tungstenite::Error::Io(io) if matches!(
485                io.kind(),
486                std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
487            )
488        )
489    }
490
491    pub struct NativeTransport {
492        base_url: String,
493        ws_url: String,
494        /// Extra request headers (auth, actor/project ids) as (name, value).
495        headers: Vec<(String, String)>,
496        agent: ureq::Agent,
497        pub signed_urls: bool,
498        pub inbound: Arc<InboundBuffer>,
499        /// One I/O thread owns the socket; callers wait for their write to flush.
500        outgoing: Option<Box<SocketSender>>,
501        reader: Option<JoinHandle<()>>,
502        reader_stop: Arc<AtomicBool>,
503        /// §8.7 round rendezvous, shared with the reader thread.
504        round: Arc<RoundChannel>,
505        realtime_client_id: Option<String>,
506    }
507
508    fn derive_ws_url(base_url: &str) -> String {
509        // {scheme}://host/path → ws(s)://host/path/realtime — the reference
510        // realtime endpoint sits alongside /sync under the mount (§8.7).
511        let ws = if let Some(rest) = base_url.strip_prefix("https://") {
512            format!("wss://{rest}")
513        } else if let Some(rest) = base_url.strip_prefix("http://") {
514            format!("ws://{rest}")
515        } else {
516            base_url.to_owned()
517        };
518        let trimmed = ws.trim_end_matches('/');
519        format!("{trimmed}/realtime")
520    }
521
522    impl NativeTransport {
523        pub fn new(
524            base_url: &str,
525            config: &serde_json::Value,
526            notify: Option<Arc<dyn Fn() + Send + Sync>>,
527        ) -> Result<Self, String> {
528            let mut headers = Vec::new();
529            if let Some(map) = config.get("headers").and_then(|v| v.as_object()) {
530                for (k, v) in map {
531                    if let Some(s) = v.as_str() {
532                        headers.push((k.clone(), s.to_owned()));
533                    }
534                }
535            }
536            let ws_url = config
537                .get("wsUrl")
538                .and_then(|v| v.as_str())
539                .map(str::to_owned)
540                .unwrap_or_else(|| derive_ws_url(base_url));
541            Ok(NativeTransport {
542                base_url: base_url.trim_end_matches('/').to_owned(),
543                ws_url,
544                headers,
545                agent: ureq::Agent::new_with_defaults(),
546                signed_urls: false,
547                inbound: Arc::new(match notify {
548                    Some(notify) => InboundBuffer::with_notify(notify),
549                    None => InboundBuffer::default(),
550                }),
551                outgoing: None,
552                reader: None,
553                reader_stop: Arc::new(AtomicBool::new(false)),
554                round: Arc::new(RoundChannel::default()),
555                realtime_client_id: None,
556            })
557        }
558
559        fn post_sync(&self, path: &str, body: &[u8]) -> Result<Vec<u8>, TransportError> {
560            let url = format!("{}{}", self.base_url, path);
561            // SSP2 requests carry their own media type (SPEC 1.1); a stock
562            // server answers 415 to anything else.
563            let mut req = self
564                .agent
565                .post(&url)
566                .header("content-type", "application/vnd.syncular.sync.v2");
567            for (k, v) in &self.headers {
568                req = req.header(k.as_str(), v.as_str());
569            }
570            let resp = req.send(body).map_err(|e| http_err("POST", e))?;
571            read_body(resp, None)
572        }
573
574        fn post_operation(&self, body: &[u8]) -> Result<Vec<u8>, TransportError> {
575            let url = format!("{}/operations", self.base_url);
576            let mut req = self.agent.post(&url).header(
577                "content-type",
578                "application/vnd.syncular.operations.v1+json",
579            );
580            for (key, value) in &self.headers {
581                req = req.header(key.as_str(), value.as_str());
582            }
583            let response = req
584                .send(body)
585                .map_err(|error| http_err("POST operation", error))?;
586            read_body(response, None)
587        }
588
589        fn get_bytes(&self, url: &str, with_headers: bool) -> Result<Vec<u8>, TransportError> {
590            let mut req = self.agent.get(url);
591            if with_headers {
592                for (k, v) in &self.headers {
593                    req = req.header(k.as_str(), v.as_str());
594                }
595            }
596            let resp = req.call().map_err(|e| http_err("GET", e))?;
597            read_body(resp, None)
598        }
599
600        pub fn set_headers(&mut self, headers: Vec<(String, String)>) {
601            self.headers = headers;
602        }
603
604        pub fn set_realtime_client_id(&mut self, client_id: Option<&str>) {
605            self.realtime_client_id = client_id.map(str::to_owned);
606        }
607
608        pub fn shutdown(&mut self) {
609            self.reader_stop.store(true, Ordering::SeqCst);
610            // Wake any `realtime_sync` blocked on a round: the socket is going
611            // away, so the round can never complete (§8.7 mid-round drop).
612            self.round.fail_in_flight(TransportError::new(
613                "sync.transport_failed",
614                "realtime disconnected mid-round (§8.7)",
615            ));
616            if let Some(outgoing) = &self.outgoing {
617                let _ = outgoing.poller.notify();
618            }
619            if let Some(handle) = self.reader.take() {
620                let _ = handle.join();
621            }
622            self.outgoing = None;
623        }
624
625        fn send_message(&mut self, message: Message) -> Result<(), TransportError> {
626            let result = (|| {
627                let Some(outgoing) = &self.outgoing else {
628                    return Err(TransportError::new(
629                        "transport.failed",
630                        "realtime not connected",
631                    ));
632                };
633                let (completed, result) = mpsc::channel();
634                outgoing
635                    .queue
636                    .try_send(Outgoing { message, completed })
637                    .map_err(|_| {
638                        TransportError::new("transport.failed", "realtime send queue unavailable")
639                    })?;
640                outgoing
641                    .poller
642                    .notify()
643                    .map_err(|e| http_err("ws wake", e))?;
644                result.recv_timeout(ROUND_TIMEOUT).map_err(|_| {
645                    TransportError::new("transport.failed", "realtime send did not complete")
646                })?
647            })();
648            // A failed/timed-out write must not remain eligible for delivery
649            // behind a later command on the same connection.
650            if result.is_err() {
651                self.shutdown();
652            }
653            result
654        }
655    }
656
657    fn read_body(
658        resp: ureq::http::Response<ureq::Body>,
659        mut progress: Option<&mut dyn FnMut(u64)>,
660    ) -> Result<Vec<u8>, TransportError> {
661        use std::io::Read;
662        let mut reader = resp.into_body().into_with_config().reader();
663        let mut bytes = Vec::new();
664        let mut chunk = [0u8; 64 * 1024];
665        let mut reported = 0;
666        loop {
667            let n = reader.read(&mut chunk).map_err(|e| http_err("read", e))?;
668            if n == 0 {
669                break;
670            }
671            bytes.extend_from_slice(&chunk[..n]);
672            if bytes.len() - reported >= 64 * 1024 {
673                if let Some(callback) = progress.as_mut() {
674                    callback(bytes.len() as u64);
675                }
676                reported = bytes.len();
677            }
678        }
679        if bytes.len() != reported {
680            if let Some(callback) = progress {
681                callback(bytes.len() as u64);
682            }
683        }
684        Ok(bytes)
685    }
686
687    impl Transport for NativeTransport {
688        fn sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
689            self.post_sync("/sync", request)
690        }
691
692        fn remote_operation(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
693            self.post_operation(request)
694        }
695
696        fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
697            // §8.7 socket round: send the request as a `0x01`-tagged chunk on
698            // the connected socket and block for the reassembled response
699            // stream (the reader thread demuxes `0x01` chunks to END, routing
700            // any `0x00` delta / text that interleaves to the inbound queue).
701            // When no socket is connected this is the client's "not connected"
702            // path — the caller (client core) only calls `realtime_sync` while
703            // `realtime_connected`, and connect established the socket; a
704            // missing socket here means the round rides HTTP instead (same
705            // rule as the TS client: `POST /sync` when the socket is absent).
706            if self.outgoing.is_none() {
707                return self.post_sync("/sync", request);
708            }
709            let framed = self.round.begin(request)?;
710            if let Err(error) = self.send_message(Message::Binary(framed.into())) {
711                self.round.fail_in_flight(error);
712            }
713            self.round.wait()
714        }
715
716        fn download_segment(
717            &mut self,
718            request: &SegmentRequest,
719            on_progress: &mut dyn FnMut(u64),
720        ) -> Result<Vec<u8>, TransportError> {
721            // The FULL content address (`sha256:<hex>`) is the path param —
722            // the reference server keys its segment store by it (§5.1) and
723            // answers `sync.not_found` to a bare hex id. The requested-scopes
724            // header carries what the pull round granted; the server
725            // re-authorizes the download against it (§5.5) and answers
726            // `sync.forbidden` when it is missing.
727            let url = format!("{}/segments/{}", self.base_url, request.segment_id);
728            let parsed = url::Url::parse(&url).map_err(|error| segment_error(None, None, error))?;
729            let mut req = self
730                .agent
731                .get(&url)
732                .header("x-syncular-scopes", &request.requested_scopes_json);
733            for (k, v) in &self.headers {
734                req = req.header(k.as_str(), v.as_str());
735            }
736            let resp = req.call().map_err(|error| {
737                let status = match &error {
738                    ureq::Error::StatusCode(status) => Some(*status),
739                    _ => None,
740                };
741                segment_error(Some(parsed.path()), status, error)
742            })?;
743            let status = resp.status().as_u16();
744            read_body(resp, Some(on_progress))
745                .map_err(|error| segment_error(Some(parsed.path()), Some(status), error.message))
746        }
747
748        fn supports_url_fetch(&self) -> bool {
749            self.signed_urls
750        }
751
752        fn fetch_url(
753            &mut self,
754            url: &str,
755            on_progress: &mut dyn FnMut(u64),
756        ) -> Result<Vec<u8>, TransportError> {
757            // §5.4: the URL is the entire grant — no host credentials attached.
758            let parsed = url::Url::parse(url).map_err(|error| segment_error(None, None, error))?;
759            let resp = self.agent.get(url).call().map_err(|error| {
760                let status = match &error {
761                    ureq::Error::StatusCode(status) => Some(*status),
762                    _ => None,
763                };
764                segment_error(Some(parsed.path()), status, error)
765            })?;
766            let status = resp.status().as_u16();
767            read_body(resp, Some(on_progress))
768                .map_err(|error| segment_error(Some(parsed.path()), Some(status), error.message))
769        }
770
771        fn blob_upload(
772            &mut self,
773            blob_id: &str,
774            bytes: &[u8],
775            media_type: Option<&str>,
776        ) -> Result<(), TransportError> {
777            // Full `sha256:<hex>` id in the path — the reference server's
778            // isBlobId check rejects a bare hex id (§5.9.1).
779            let url = format!("{}/blobs/{}", self.base_url, blob_id);
780            let mut req = self.agent.put(&url).header(
781                "content-type",
782                media_type.unwrap_or("application/octet-stream"),
783            );
784            for (k, v) in &self.headers {
785                req = req.header(k.as_str(), v.as_str());
786            }
787            req.send(bytes).map_err(|e| http_err("PUT blob", e))?;
788            Ok(())
789        }
790
791        fn blob_download(&mut self, blob_id: &str) -> Result<BlobDownload, TransportError> {
792            // Full `sha256:<hex>` id in the path — the reference server's
793            // isBlobId check rejects a bare hex id (§5.9.1).
794            let url = format!("{}/blobs/{}", self.base_url, blob_id);
795            let mut req = self.agent.get(&url);
796            for (k, v) in &self.headers {
797                req = req.header(k.as_str(), v.as_str());
798            }
799            let resp = req.call().map_err(|e| {
800                let e = http_err("GET blob", e);
801                // Preserve a blob.* semantics hint (§5.9.5) for the caller.
802                if e.code == "transport.failed" {
803                    TransportError::new("blob.not_found", e.message)
804                } else {
805                    e
806                }
807            })?;
808            // §5.9.5 always-issue: a JSON body carries a presigned `url`; an
809            // octet-stream body is inline bytes.
810            let is_json = resp
811                .headers()
812                .get(ureq::http::header::CONTENT_TYPE)
813                .and_then(|value| value.to_str().ok())
814                .is_some_and(|value| value.contains("application/json"));
815            let body = read_body(resp, None)?;
816            if is_json {
817                if let Ok(parsed) = serde_json::from_slice::<serde_json::Value>(&body) {
818                    if let Some(u) = parsed.get("url").and_then(|v| v.as_str()) {
819                        return Ok(BlobDownload::Url {
820                            url: u.to_owned(),
821                            url_expires_at_ms: parsed
822                                .get("urlExpiresAtMs")
823                                .and_then(|v| v.as_i64()),
824                        });
825                    }
826                }
827            }
828            Ok(BlobDownload::Bytes(body))
829        }
830
831        fn fetch_blob_url(&mut self, url: &str) -> Result<Vec<u8>, TransportError> {
832            // §5.9.5: the URL is the entire grant — no host credentials.
833            self.get_bytes(url, false)
834        }
835
836        fn blob_upload_grant(
837            &mut self,
838            blob_id: &str,
839            byte_length: u64,
840            media_type: Option<&str>,
841        ) -> Result<BlobUploadGrant, TransportError> {
842            let url = format!("{}/blobs/{}/upload-grant", self.base_url, blob_id);
843            let mut req = self
844                .agent
845                .post(&url)
846                .header("content-type", "application/json");
847            for (k, v) in &self.headers {
848                req = req.header(k.as_str(), v.as_str());
849            }
850            let body = serde_json::json!({
851                "byteLength": byte_length,
852                "mediaType": media_type,
853            });
854            let resp = req
855                .send(body.to_string())
856                .map_err(|e| http_err("POST upload-grant", e))?;
857            let grant_body = read_body(resp, None)?;
858            let parsed: serde_json::Value = serde_json::from_slice(&grant_body)
859                .map_err(|e| TransportError::new("transport.failed", format!("read grant: {e}")))?;
860            if let Some(u) = parsed.get("url").and_then(|v| v.as_str()) {
861                return Ok(BlobUploadGrant::Url {
862                    url: u.to_owned(),
863                    url_expires_at_ms: parsed.get("urlExpiresAtMs").and_then(|v| v.as_i64()),
864                });
865            }
866            if parsed.get("present").and_then(|v| v.as_bool()) == Some(true) {
867                return Ok(BlobUploadGrant::Present);
868            }
869            Ok(BlobUploadGrant::None)
870        }
871
872        fn blob_put_url(
873            &mut self,
874            url: &str,
875            bytes: &[u8],
876            media_type: Option<&str>,
877        ) -> Result<(), TransportError> {
878            // §5.9.3: the presigned URL is the entire grant — no host auth.
879            let req = self.agent.put(url).header(
880                "content-type",
881                media_type.unwrap_or("application/octet-stream"),
882            );
883            req.send(bytes).map_err(|e| http_err("PUT blob url", e))?;
884            Ok(())
885        }
886
887        fn realtime_connect(&mut self) -> Result<(), TransportError> {
888            if self.outgoing.is_some() {
889                return Ok(());
890            }
891            // Build the client request VIA `IntoClientRequest` so tungstenite
892            // fills the mandatory handshake headers (Host / Connection /
893            // Upgrade / Sec-WebSocket-Version / Sec-WebSocket-Key); a
894            // hand-built `http::Request` is taken as-is and would omit them.
895            // Then layer the configured auth/actor headers on top.
896            use tungstenite::client::IntoClientRequest;
897            let mut url = url::Url::parse(&self.ws_url)
898                .map_err(|e| TransportError::new("transport.failed", format!("ws url: {e}")))?;
899            if let Some(client_id) = self.realtime_client_id.as_deref() {
900                let retained_query: Vec<(String, String)> = url
901                    .query_pairs()
902                    .filter(|(key, _)| key != "clientId")
903                    .map(|(key, value)| (key.into_owned(), value.into_owned()))
904                    .collect();
905                url.set_query(None);
906                url.query_pairs_mut()
907                    .extend_pairs(retained_query)
908                    .append_pair("clientId", client_id);
909            }
910            let mut request = url
911                .as_str()
912                .into_client_request()
913                .map_err(|e| TransportError::new("transport.failed", format!("ws url: {e}")))?;
914            {
915                let out = request.headers_mut();
916                for (k, v) in &self.headers {
917                    if let (Ok(name), Ok(value)) = (
918                        tungstenite::http::HeaderName::try_from(k.as_str()),
919                        tungstenite::http::HeaderValue::try_from(v.as_str()),
920                    ) {
921                        out.insert(name, value);
922                    }
923                }
924            }
925            let (mut ws, _resp) = tungstenite::connect(request)
926                .map_err(|e| TransportError::new("transport.failed", format!("ws connect: {e}")))?;
927            let stream = match ws.get_mut() {
928                MaybeTlsStream::Plain(stream) => stream.try_clone(),
929                MaybeTlsStream::Rustls(stream) => stream.get_ref().try_clone(),
930                _ => {
931                    return Err(TransportError::new(
932                        "transport.failed",
933                        "unsupported websocket stream",
934                    ))
935                }
936            }
937            .map_err(|e| http_err("ws clone", e))?;
938            stream
939                .set_nonblocking(true)
940                .map_err(|e| http_err("ws nonblocking", e))?;
941            let poller = Arc::new(Poller::new().map_err(|e| http_err("ws poller", e))?);
942            // SAFETY: SocketRegistration owns this handle and unregisters it before drop.
943            unsafe { poller.add(&stream, Event::readable(0)) }
944                .map_err(|e| http_err("ws register", e))?;
945            let registration = SocketRegistration {
946                poller: Arc::clone(&poller),
947                stream,
948            };
949            let (outgoing, queued) = mpsc::sync_channel::<Outgoing>(1);
950            self.outgoing = Some(Box::new(SocketSender {
951                queue: outgoing,
952                poller,
953            }));
954            self.reader_stop.store(false, Ordering::SeqCst);
955            let inbound = Arc::clone(&self.inbound);
956            let stop = Arc::clone(&self.reader_stop);
957            let round = Arc::clone(&self.round);
958            self.reader = Some(std::thread::spawn(move || {
959                let mut events = Events::new();
960                let mut pending: Option<mpsc::Sender<Result<(), TransportError>>> = None;
961                let failure = 'io: loop {
962                    if stop.load(Ordering::SeqCst) {
963                        let _ = ws.close(None);
964                        let _ = ws.flush();
965                        break TransportError::new(
966                            "sync.transport_failed",
967                            "realtime disconnected mid-round (§8.7)",
968                        );
969                    }
970                    if pending.is_none() {
971                        if let Ok(outgoing) = queued.try_recv() {
972                            pending = Some(outgoing.completed);
973                            // WouldBlock retains the frame in tungstenite's write buffer.
974                            if let Err(error) = ws.write(outgoing.message) {
975                                if !is_would_block(&error) {
976                                    break http_err("ws write", error);
977                                }
978                            }
979                        }
980                    }
981                    // Bound receive work so a continuously readable peer cannot starve sends.
982                    let mut drained = false;
983                    for _ in 0..64 {
984                        match ws.read() {
985                            Ok(Message::Text(text)) => {
986                                inbound.push(Inbound::Text(text.to_string()))
987                            }
988                            Ok(Message::Binary(bytes)) => {
989                                if let Some(delta) = round.route_binary(&bytes) {
990                                    inbound.push(Inbound::Binary(delta));
991                                }
992                            }
993                            Err(error) if is_would_block(&error) => {
994                                drained = true;
995                                break;
996                            }
997                            Ok(Message::Close(_)) => {
998                                let _ = ws.flush();
999                                break 'io TransportError::new(
1000                                    "sync.transport_failed",
1001                                    "realtime disconnected mid-round (§8.7)",
1002                                );
1003                            }
1004                            Err(_) => {
1005                                break 'io TransportError::new(
1006                                    "sync.transport_failed",
1007                                    "realtime disconnected mid-round (§8.7)",
1008                                );
1009                            }
1010                            Ok(_) => {}
1011                        }
1012                    }
1013                    let interest = match ws.flush() {
1014                        Ok(()) => {
1015                            if let Some(completed) = pending.take() {
1016                                let _ = completed.send(Ok(()));
1017                            }
1018                            Event::readable(0)
1019                        }
1020                        Err(error) if is_would_block(&error) => Event::all(0),
1021                        Err(error) => break http_err("ws flush", error),
1022                    };
1023                    if !drained {
1024                        continue;
1025                    }
1026                    if let Err(error) = registration.poller.modify(&registration.stream, interest) {
1027                        break http_err("ws rearm", error);
1028                    }
1029                    events.clear();
1030                    if let Err(error) = registration.poller.wait(&mut events, None) {
1031                        if error.kind() != std::io::ErrorKind::Interrupted {
1032                            break http_err("ws wait", error);
1033                        }
1034                    }
1035                };
1036                if let Some(completed) = pending {
1037                    let _ = completed.send(Err(failure.clone()));
1038                }
1039                while let Ok(outgoing) = queued.try_recv() {
1040                    let _ = outgoing.completed.send(Err(failure.clone()));
1041                }
1042                round.fail_in_flight(failure);
1043            }));
1044            Ok(())
1045        }
1046
1047        fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
1048            self.send_message(Message::Text(text.to_owned().into()))
1049        }
1050
1051        fn realtime_close(&mut self) -> Result<(), TransportError> {
1052            self.shutdown();
1053            Ok(())
1054        }
1055    }
1056}
1057
1058#[cfg(all(test, feature = "native-transport"))]
1059mod tests {
1060    use super::*;
1061    use std::io::{BufRead, BufReader, Write};
1062
1063    #[test]
1064    fn segment_http_failures_have_named_codes_and_request_details() {
1065        for signed in [false, true] {
1066            for body_failure in [false, true] {
1067                let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1068                let base = format!("http://{}", listener.local_addr().unwrap());
1069                let server = std::thread::spawn(move || {
1070                    let (mut socket, _) = listener.accept().unwrap();
1071                    let mut reader = BufReader::new(socket.try_clone().unwrap());
1072                    loop {
1073                        let mut line = String::new();
1074                        reader.read_line(&mut line).unwrap();
1075                        if line == "\r\n" {
1076                            break;
1077                        }
1078                        assert!(!line.is_empty());
1079                    }
1080                    socket.write_all(if body_failure {
1081                        b"HTTP/1.1 200 OK\r\nContent-Length: 999\r\nConnection: close\r\n\r\nshort"
1082                    } else {
1083                        b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
1084                    }).unwrap();
1085                });
1086                let mut transport =
1087                    HostTransport::new_from_config(&serde_json::json!({"baseUrl": base})).unwrap();
1088                let mut progress = |_| {};
1089                let error = if signed {
1090                    transport.fetch_url(&format!("{base}/signed?signature=secret"), &mut progress)
1091                } else {
1092                    transport.download_segment(
1093                        &SegmentRequest {
1094                            segment_id: "sha256:test".into(),
1095                            table: "tasks".into(),
1096                            requested_scopes_json: "{}".into(),
1097                        },
1098                        &mut progress,
1099                    )
1100                }
1101                .unwrap_err();
1102                assert_eq!(error.code, "sync.transport_failed");
1103                assert_eq!(error.message, "segment transfer failed");
1104                let details = error.details.unwrap();
1105                assert_eq!(
1106                    details["path"],
1107                    if signed {
1108                        "/signed"
1109                    } else {
1110                        "/segments/sha256:test"
1111                    }
1112                );
1113                assert_eq!(details["httpStatus"], if body_failure { 200 } else { 403 });
1114                server.join().unwrap();
1115            }
1116        }
1117    }
1118
1119    #[test]
1120    fn refused_segment_connection_has_a_named_code_without_an_invented_status() {
1121        for signed in [false, true] {
1122            let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1123            let base = format!("http://{}", listener.local_addr().unwrap());
1124            drop(listener);
1125            let mut transport =
1126                HostTransport::new_from_config(&serde_json::json!({"baseUrl": base})).unwrap();
1127            let mut progress = |_| {};
1128            let error = if signed {
1129                transport.fetch_url(&format!("{base}/signed?signature=secret"), &mut progress)
1130            } else {
1131                transport.download_segment(
1132                    &SegmentRequest {
1133                        segment_id: "sha256:test".into(),
1134                        table: "tasks".into(),
1135                        requested_scopes_json: "{}".into(),
1136                    },
1137                    &mut progress,
1138                )
1139            }
1140            .unwrap_err();
1141            assert_eq!(error.code, "sync.transport_failed");
1142            assert!(error.details.as_ref().unwrap().get("httpStatus").is_none());
1143        }
1144    }
1145
1146    #[test]
1147    fn segment_http_reports_intermediate_bytes_for_direct_and_signed_urls() {
1148        for signed in [false, true] {
1149            let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1150            let base = format!("http://{}", listener.local_addr().unwrap());
1151            let server = std::thread::spawn(move || {
1152                let (mut socket, _) = listener.accept().unwrap();
1153                let mut reader = BufReader::new(socket.try_clone().unwrap());
1154                let mut headers = String::new();
1155                loop {
1156                    let mut line = String::new();
1157                    reader.read_line(&mut line).unwrap();
1158                    if line == "\r\n" {
1159                        break;
1160                    }
1161                    assert!(!line.is_empty());
1162                    headers.push_str(&line.to_ascii_lowercase());
1163                }
1164                assert_eq!(headers.contains("authorization: bearer test"), !signed);
1165                assert_eq!(headers.contains("x-syncular-scopes:"), !signed);
1166                let bytes = vec![7; 128 * 1024 + 17];
1167                write!(
1168                    socket,
1169                    "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
1170                    bytes.len()
1171                )
1172                .unwrap();
1173                socket.write_all(&bytes).unwrap();
1174            });
1175            let mut transport = HostTransport::from_config(
1176                &serde_json::json!({"baseUrl": base, "headers": {"authorization": "Bearer test"}}),
1177            )
1178            .unwrap();
1179            let mut updates = Vec::new();
1180            let mut progress = |bytes| updates.push(bytes);
1181            let bytes = if signed {
1182                transport
1183                    .fetch_url(&format!("{base}/signed"), &mut progress)
1184                    .unwrap()
1185            } else {
1186                transport
1187                    .download_segment(
1188                        &SegmentRequest {
1189                            segment_id: "sha256:test".into(),
1190                            table: "tasks".into(),
1191                            requested_scopes_json: "{}".into(),
1192                        },
1193                        &mut progress,
1194                    )
1195                    .unwrap()
1196            };
1197            assert_eq!(bytes.len(), 128 * 1024 + 17);
1198            assert!(updates.iter().any(|n| *n > 0 && *n < bytes.len() as u64));
1199            assert_eq!(updates.last(), Some(&(bytes.len() as u64)));
1200            assert!(updates.windows(2).all(|pair| pair[0] < pair[1]));
1201            server.join().unwrap();
1202        }
1203    }
1204}