Skip to main content

vgi_rpc/
external.rs

1//! External-location batches.
2//!
3//! Large result / stream batches can be uploaded to an external object
4//! store (S3 / GCS / in-memory) and replaced on the wire with a
5//! zero-row "pointer" batch carrying only metadata:
6//!
7//!   `vgi_rpc.location`         → URL (HTTPS by default).
8//!   `vgi_rpc.location.sha256`  → lowercase hex SHA-256 of the raw IPC bytes.
9//!   `vgi_rpc.location.source`  → debug annotation (filled when resolved).
10//!
11//! The remote payload is an Arrow IPC stream containing one batch with the
12//! original data. When compression is set, that stream is zstd-encoded
13//! before upload — the hash is over the raw (uncompressed) IPC bytes so
14//! integrity checks remain stable across compression changes.
15//!
16//! The crate stays storage-agnostic: users register an [`ExternalStorage`]
17//! implementation and a [`Fetcher`] for resolution. The companion
18//! `vgi-rpc-s3` and `vgi-rpc-gcs` crates ship ready-made backends.
19
20use std::net::{IpAddr, ToSocketAddrs};
21use std::sync::Arc;
22
23use arrow_array::RecordBatch;
24use arrow_schema::{Schema, SchemaRef};
25use sha2::{Digest, Sha256};
26
27use crate::errors::{Result, RpcError};
28use crate::metadata::{LOCATION_FETCH_MS_KEY, LOCATION_KEY, LOCATION_SHA256_KEY};
29use crate::wire::{bytes_to_hex, empty_batch, md_get, write_one_batch_as, Metadata, StreamReader};
30
31thread_local! {
32    /// Bytes uploaded to external storage during the call in flight.
33    ///
34    /// Externalised payloads never appear in the response body — only a
35    /// pointer batch does — so they are invisible to any accounting done at
36    /// the transport, and for egress they are usually the larger number by
37    /// orders of magnitude. Incremented at the single upload choke point in
38    /// [`maybe_externalize_batch`], so a new upload path cannot drift from
39    /// the total; scoped and read by [`ExternalizedScope`].
40    static EXTERNALIZED_BYTES: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
41}
42
43/// Per-call accounting scope for externalised uploads.
44///
45/// Reads the total on [`finish`](Self::finish) and restores whatever the
46/// enclosing scope had accumulated, so a nested dispatch cannot swallow its
47/// caller's count. Uploads run synchronously on the dispatch thread (the HTTP
48/// path enters them via `block_in_place`), which is what makes a thread-local
49/// the right carrier here — but it also means the scope must not straddle an
50/// `.await`.
51pub struct ExternalizedScope {
52    outer: u64,
53}
54
55impl ExternalizedScope {
56    /// Begin counting this call's uploads from zero.
57    pub fn new() -> Self {
58        let outer = EXTERNALIZED_BYTES.with(|c| c.replace(0));
59        Self { outer }
60    }
61
62    /// Bytes uploaded within this scope; restores the enclosing total.
63    pub fn finish(self) -> u64 {
64        EXTERNALIZED_BYTES.with(|c| c.replace(self.outer))
65    }
66}
67
68impl Default for ExternalizedScope {
69    fn default() -> Self {
70        Self::new()
71    }
72}
73
74/// Optional body compression for externalized payloads.
75#[derive(Clone, Copy, Debug, PartialEq, Eq)]
76pub enum Compression {
77    None,
78    /// zstd at the given level (1..=22).
79    Zstd(i32),
80}
81
82/// Result of uploading a payload to external storage.
83#[derive(Clone, Debug)]
84pub struct UploadResult {
85    /// Caller-fetchable URL for the payload (typically a pre-signed URL).
86    pub url: String,
87    /// SHA-256 of the **raw** IPC bytes, hex-lowercased.
88    pub sha256: String,
89}
90
91/// Pluggable storage backend. Implementations upload Arrow IPC bytes and
92/// return a fetchable URL.
93///
94/// Kept synchronous to preserve compatibility with the current pipe/unix
95/// dispatch loop; async-only backends should offload via a blocking
96/// thread pool.
97pub trait ExternalStorage: Send + Sync {
98    fn upload(&self, ipc_bytes: &[u8], compression: Compression) -> Result<UploadResult>;
99}
100
101// ---------------------------------------------------------------------------
102// Public `__upload_url__` wire contract
103// ---------------------------------------------------------------------------
104//
105// An intermediary (proxy, gateway) that terminates or serves the upload-URL
106// flow needs the method name and both schemas. They are published here — rather
107// than duplicated as private literals in the server and client — so the contract
108// has exactly one definition. Mirrors Python's `vgi_rpc.http` re-exports.
109
110/// The RPC method name of the upload-URL flow (`POST /__upload_url__/init`).
111pub const UPLOAD_URL_METHOD: &str = "__upload_url__";
112
113/// Upper bound on the `count` an `__upload_url__` request may ask for. The
114/// server clamps to `[1, MAX_UPLOAD_URL_COUNT]`.
115pub const MAX_UPLOAD_URL_COUNT: i64 = 100;
116
117/// Parameter schema of an `__upload_url__` request: a single `count` int64.
118pub fn upload_url_params_schema() -> SchemaRef {
119    use arrow_schema::{DataType, Field};
120    Arc::new(Schema::new(vec![Field::new(
121        "count",
122        DataType::Int64,
123        true,
124    )]))
125}
126
127/// Response schema of an `__upload_url__` reply: one row per generated URL pair.
128pub fn upload_url_response_schema() -> SchemaRef {
129    use arrow_schema::{DataType, Field, TimeUnit};
130    Arc::new(Schema::new(vec![
131        Field::new("upload_url", DataType::Utf8, false),
132        Field::new("download_url", DataType::Utf8, false),
133        Field::new(
134            "expires_at",
135            DataType::Timestamp(TimeUnit::Microsecond, Some("UTC".into())),
136            false,
137        ),
138    ]))
139}
140
141/// Pre-signed URL pair for client-side data upload.
142///
143/// `expires_at` is a Unix-epoch microseconds timestamp (UTC). Mirrors
144/// `vgi_rpc.external.UploadUrl`.
145#[derive(Clone, Debug)]
146pub struct UploadUrl {
147    pub upload_url: String,
148    pub download_url: String,
149    /// Expiration time as microseconds since the Unix epoch (UTC).
150    pub expires_at_micros: i64,
151}
152
153/// Generates pre-signed upload URL pairs for client-vended uploads.
154///
155/// Mirror of Python `vgi_rpc.external.UploadUrlProvider`. Implementations
156/// must be thread-safe — `generate_upload_url()` may be called concurrently
157/// from different request handlers.
158pub trait UploadUrlProvider: Send + Sync {
159    fn generate_upload_url(&self) -> Result<UploadUrl>;
160}
161
162/// Callback verifying an external location URL. Called on both the
163/// upload path (post-signing) and the fetch path (pre-download). Return
164/// `Err` to reject the URL with a typed [`RpcError`].
165pub type UrlValidator = Arc<dyn Fn(&str) -> Result<()> + Send + Sync>;
166
167/// Run a URL validator while ensuring a rejection cannot echo signed URL
168/// credentials through its message or traceback.
169pub fn validate_external_url(validator: &UrlValidator, raw: &str) -> Result<()> {
170    validator(raw).map_err(|mut err| {
171        let redacted = redact_external_url(raw);
172        err.message = err.message.replace(raw, &redacted);
173        err.traceback = err.traceback.replace(raw, &redacted);
174        err
175    })
176}
177
178fn redact_external_url(raw: &str) -> String {
179    let Ok(mut parsed) = url::Url::parse(raw) else {
180        return "<invalid external URL>".to_string();
181    };
182    let _ = parsed.set_username("");
183    let _ = parsed.set_password(None);
184    parsed.set_query(None);
185    parsed.set_fragment(None);
186    parsed.to_string()
187}
188
189/// Pluggable fetcher used to resolve pointer batches back into data.
190///
191/// Takes a URL (and the declared compression) and returns the still-encoded
192/// payload bytes. `vgi-rpc` ships an HTTPS fetcher; tests plug in an
193/// in-memory implementation.
194///
195/// `max_bytes` is a hard ceiling on the number of bytes the fetcher may
196/// read from the remote — implementations **must** abort once it is
197/// exceeded rather than buffering an unbounded response into memory. A
198/// hostile or compromised storage URL would otherwise OOM the process
199/// before decompression's own cap is ever reached.
200pub trait Fetcher: Send + Sync {
201    fn fetch(&self, url: &str, compression: Compression, max_bytes: usize) -> Result<Vec<u8>>;
202
203    /// Fetch with the resolver's complete safety policy. Implementations that
204    /// follow redirects should override this method, validate every target
205    /// before issuing the next request, and return the response's actual
206    /// content coding. The default preserves redirect-free custom fetchers.
207    fn fetch_with_policy(
208        &self,
209        url: &str,
210        compression: Compression,
211        max_bytes: usize,
212        validator: &UrlValidator,
213        _max_redirects: usize,
214    ) -> Result<FetchedPayload> {
215        validate_external_url(validator, url)?;
216        self.fetch(url, compression, max_bytes)
217            .map(|bytes| FetchedPayload { bytes, compression })
218    }
219}
220
221/// Encoded bytes returned by a fetcher plus the coding observed on that
222/// response. This lets inbound pointers use the storage response's coding
223/// instead of assuming it matches the local writer configuration.
224pub struct FetchedPayload {
225    pub bytes: Vec<u8>,
226    pub compression: Compression,
227}
228
229/// Externalization configuration.
230#[derive(Clone)]
231pub struct ExternalLocationConfig {
232    /// Payload size in bytes at which a batch is eligible for
233    /// externalization. Smaller batches stay inline. Default 1 MiB.
234    pub threshold_bytes: usize,
235    /// Compression applied to the IPC bytes before upload. Default
236    /// `Compression::None`.
237    pub compression: Compression,
238    /// Upload backend. Required when externalization is enabled.
239    pub storage: Arc<dyn ExternalStorage>,
240    /// Resolver used on the read side. Required when resolving inbound
241    /// pointer batches.
242    pub fetcher: Arc<dyn Fetcher>,
243    /// URL validator run on both the upload (post-signing) and fetch
244    /// (pre-download) paths. Defaults to [`safe_https_validator`], which
245    /// rejects non-`https` URLs and internal/non-routable hosts. Reject
246    /// a URL by returning `Err`.
247    pub url_validator: UrlValidator,
248    /// Hard ceiling on the post-decompression size of a fetched
249    /// payload. Zstd frames carry their decompressed size in the
250    /// header and `zstd::decode_all` would otherwise trust it
251    /// eagerly — a small malicious payload claiming gigabytes of
252    /// output would OOM the client. Default 1 GiB. Set to `usize::MAX`
253    /// to disable.
254    pub max_decompressed_bytes: usize,
255    /// Hard ceiling on bytes received from storage before decompression.
256    /// Default 1 GiB, matching the historical combined ceiling.
257    pub max_encoded_bytes: usize,
258    /// Maximum number of redirect hops. Default 5.
259    pub max_redirects: usize,
260}
261
262impl std::fmt::Debug for ExternalLocationConfig {
263    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
264        f.debug_struct("ExternalLocationConfig")
265            .field("threshold_bytes", &self.threshold_bytes)
266            .field("compression", &self.compression)
267            .finish_non_exhaustive()
268    }
269}
270
271/// Helper: scheme-only HTTPS validator.
272///
273/// **Insufficient for untrusted input** — it accepts `https://localhost`,
274/// `https://169.254.169.254` (cloud metadata), and any internal-network
275/// host. Use it only when the set of external-location URLs is fully
276/// trusted (e.g. URLs your own backend minted). For anything that can
277/// carry a client-supplied location, use [`safe_https_validator`].
278pub fn https_only_validator() -> UrlValidator {
279    Arc::new(|url: &str| {
280        if url.starts_with("https://") {
281            Ok(())
282        } else {
283            Err(RpcError::value_error(
284                "external location URL must be https://",
285            ))
286        }
287    })
288}
289
290/// Helper: default-deny HTTPS validator with SSRF protection.
291///
292/// Requires the `https` scheme, then rejects the URL when its host is —
293/// or resolves to — a loopback, private, link-local, unique-local,
294/// carrier-grade-NAT, broadcast, documentation, or unspecified address.
295/// This is the default for [`ExternalLocationConfig::new`] because the
296/// unary HTTP path resolves a *client-supplied* `vgi_rpc.location`
297/// server-side; without this a client could pivot the server into
298/// fetching `https://169.254.169.254/...` or an internal service.
299///
300/// Note: a hostname is resolved here and again at fetch time, so a
301/// DNS-rebinding attacker could still slip through the gap. Pair this
302/// with a redirect-free, size-capped fetcher (the bundled `HttpFetcher`
303/// is both) and, for high-assurance deployments, an egress firewall.
304pub fn safe_https_validator() -> UrlValidator {
305    Arc::new(|raw: &str| {
306        let url = url::Url::parse(raw)
307            .map_err(|e| RpcError::value_error(format!("invalid external location URL: {e}")))?;
308        if url.scheme() != "https" {
309            return Err(RpcError::value_error(
310                "external location URL must be https://",
311            ));
312        }
313        let host = url
314            .host()
315            .ok_or_else(|| RpcError::value_error("external location URL has no host"))?;
316        match host {
317            url::Host::Ipv4(ip) => reject_unsafe_ip(IpAddr::V4(ip)),
318            url::Host::Ipv6(ip) => reject_unsafe_ip(IpAddr::V6(ip)),
319            url::Host::Domain(name) => {
320                let lname = name.to_ascii_lowercase();
321                if lname == "localhost" || lname.ends_with(".localhost") {
322                    return Err(RpcError::value_error(
323                        "external location host is not publicly routable",
324                    ));
325                }
326                let port = url.port_or_known_default().unwrap_or(443);
327                let addrs = (name, port).to_socket_addrs().map_err(|e| {
328                    RpcError::value_error(format!("external location host does not resolve: {e}"))
329                })?;
330                let mut saw_any = false;
331                for sa in addrs {
332                    saw_any = true;
333                    reject_unsafe_ip(sa.ip())?;
334                }
335                if !saw_any {
336                    return Err(RpcError::value_error(
337                        "external location host does not resolve",
338                    ));
339                }
340                Ok(())
341            }
342        }
343    })
344}
345
346/// Reject an IP address that is not safe for the server to dial: any
347/// loopback / private / link-local / unique-local / CGNAT / broadcast /
348/// documentation / unspecified / multicast address.
349fn reject_unsafe_ip(ip: IpAddr) -> Result<()> {
350    let unsafe_addr = match ip {
351        IpAddr::V4(v4) => {
352            let o = v4.octets();
353            v4.is_loopback()
354                || v4.is_private()
355                || v4.is_link_local()
356                || v4.is_unspecified()
357                || v4.is_broadcast()
358                || v4.is_multicast()
359                || v4.is_documentation()
360                // 100.64.0.0/10 — carrier-grade NAT.
361                || (o[0] == 100 && (o[1] & 0xc0) == 0x40)
362        }
363        IpAddr::V6(v6) => {
364            let seg0 = v6.segments()[0];
365            v6.is_loopback()
366                || v6.is_unspecified()
367                || v6.is_multicast()
368                // fc00::/7 — unique local.
369                || (seg0 & 0xfe00) == 0xfc00
370                // fe80::/10 — link local.
371                || (seg0 & 0xffc0) == 0xfe80
372                // IPv4-mapped (::ffff:0:0/96) — classify the embedded v4.
373                || v6
374                    .to_ipv4_mapped()
375                    .map(|m| reject_unsafe_ip(IpAddr::V4(m)).is_err())
376                    .unwrap_or(false)
377        }
378    };
379    if unsafe_addr {
380        return Err(RpcError::value_error(
381            "external location host resolves to a non-routable / internal address",
382        ));
383    }
384    Ok(())
385}
386
387/// Helper: accept any URL (useful for local tests + MinIO).
388pub fn any_url_validator() -> UrlValidator {
389    Arc::new(|_: &str| Ok(()))
390}
391
392impl ExternalLocationConfig {
393    pub fn new(storage: Arc<dyn ExternalStorage>, fetcher: Arc<dyn Fetcher>) -> Self {
394        Self {
395            threshold_bytes: 1024 * 1024,
396            compression: Compression::None,
397            storage,
398            fetcher,
399            url_validator: safe_https_validator(),
400            max_decompressed_bytes: 1024 * 1024 * 1024,
401            max_encoded_bytes: 1024 * 1024 * 1024,
402            max_redirects: 5,
403        }
404    }
405
406    pub fn with_threshold_bytes(mut self, n: usize) -> Self {
407        self.threshold_bytes = n;
408        self
409    }
410
411    pub fn with_compression(mut self, c: Compression) -> Self {
412        self.compression = c;
413        self
414    }
415
416    pub fn with_url_validator(mut self, v: UrlValidator) -> Self {
417        self.url_validator = v;
418        self
419    }
420
421    /// Override the hard ceiling on post-decompression payload size.
422    /// Pass `usize::MAX` to disable. Default is 1 GiB.
423    pub fn with_max_decompressed_bytes(mut self, n: usize) -> Self {
424        self.max_decompressed_bytes = n;
425        self
426    }
427
428    /// Override the hard ceiling on encoded bytes read from storage.
429    pub fn with_max_encoded_bytes(mut self, n: usize) -> Self {
430        self.max_encoded_bytes = n;
431        self
432    }
433
434    /// Override the maximum number of redirect hops.
435    pub fn with_max_redirects(mut self, n: usize) -> Self {
436        self.max_redirects = n;
437        self
438    }
439}
440
441// ---------------------------------------------------------------------------
442// Serialize a batch as an IPC stream with no custom metadata.
443// ---------------------------------------------------------------------------
444
445/// Serialize one record batch as a complete IPC stream (schema + batch + EOS).
446pub fn serialize_batch_to_ipc(batch: &RecordBatch) -> Result<Vec<u8>> {
447    // External payloads carry the raw data only; the pointer batch on
448    // the outside owns the metadata. Pass `None` to omit any
449    // `custom_metadata` field on the wire.
450    write_one_batch_as(batch, batch.schema().as_ref(), None)
451}
452
453/// Read back an IPC stream containing a single batch.
454/// Fetch, decompress, and integrity-check an external-location pointer's
455/// payload, returning the raw inner IPC stream bytes. The inner stream may
456/// contain **multiple** batches (e.g. a peer that externalizes a whole
457/// per-iteration output — logs followed by the data batch), so callers that
458/// need log/exception handling should process the returned bytes as a full
459/// response stream rather than assuming a single batch.
460///
461/// Returns `Ok(None)` when `metadata` carries no `vgi_rpc.location` pointer.
462pub fn fetch_external_ipc_bytes(
463    metadata: &Metadata,
464    cfg: &ExternalLocationConfig,
465) -> Result<Option<Vec<u8>>> {
466    let Some(url) = md_get(metadata, LOCATION_KEY) else {
467        return Ok(None);
468    };
469    let fetched = cfg.fetcher.fetch_with_policy(
470        url,
471        cfg.compression,
472        cfg.max_encoded_bytes,
473        &cfg.url_validator,
474        cfg.max_redirects,
475    )?;
476    let ipc_bytes = decompress(
477        &fetched.bytes,
478        fetched.compression,
479        cfg.max_decompressed_bytes,
480    )?;
481    if let Some(expected) = md_get(metadata, LOCATION_SHA256_KEY) {
482        let actual = sha256_hex(&ipc_bytes);
483        if expected != actual.as_str() {
484            return Err(RpcError::runtime_error(format!(
485                "external location checksum: SHA-256 mismatch (expected {expected}, got {actual})"
486            )));
487        }
488    }
489    Ok(Some(ipc_bytes))
490}
491
492pub fn deserialize_single_batch(ipc_bytes: &[u8]) -> Result<RecordBatch> {
493    Ok(deserialize_single_batch_with_metadata(ipc_bytes)?.0)
494}
495
496/// Like [`deserialize_single_batch`] but also returns the batch's per-message
497/// custom metadata — some peers carry keys (e.g. the stream-state token) on the
498/// externalized inner batch rather than the outer pointer.
499pub fn deserialize_single_batch_with_metadata(ipc_bytes: &[u8]) -> Result<(RecordBatch, Metadata)> {
500    let mut r = StreamReader::new(ipc_bytes)?;
501    r.read_next()?
502        .ok_or_else(|| RpcError::runtime_error("external batch stream is empty"))
503}
504
505fn sha256_hex(bytes: &[u8]) -> String {
506    bytes_to_hex(&Sha256::digest(bytes))
507}
508
509/// Zstd's decoder allocates its history window independently from emitted
510/// output. Bound that window to the smallest power of two covering the output
511/// cap, with a 512 KiB floor for zstd's streaming level-1 profile and a 2 GiB
512/// maximum.
513fn zstd_window_log_for_limit(max_size: usize) -> u32 {
514    const INTEROPERABLE_WINDOW_LOG_FLOOR: u32 = 19;
515    let bounded = max_size.max(1 << INTEROPERABLE_WINDOW_LOG_FLOOR);
516    let ceil_log = usize::BITS - bounded.saturating_sub(1).leading_zeros();
517    ceil_log.clamp(INTEROPERABLE_WINDOW_LOG_FLOOR, 31)
518}
519
520fn compress(ipc_bytes: &[u8], compression: Compression) -> Result<Vec<u8>> {
521    match compression {
522        Compression::None => Ok(ipc_bytes.to_vec()),
523        Compression::Zstd(level) => {
524            // Use the bulk API and explicitly include the decompressed
525            // size in the frame header. Python's `zstandard.ZstdDecompressor`
526            // requires `Content-Size` to be present when decompressing
527            // a single frame in one shot.
528            let mut enc = zstd::bulk::Compressor::new(level)
529                .map_err(|e| RpcError::runtime_error(format!("zstd encoder: {e}")))?;
530            enc.set_parameter(zstd::stream::raw::CParameter::ContentSizeFlag(true))
531                .map_err(|e| RpcError::runtime_error(format!("zstd contentsize: {e}")))?;
532            enc.compress(ipc_bytes)
533                .map_err(|e| RpcError::runtime_error(format!("zstd encode: {e}")))
534        }
535    }
536}
537
538fn decompress(bytes: &[u8], compression: Compression, max_size: usize) -> Result<Vec<u8>> {
539    match compression {
540        Compression::None => {
541            if bytes.len() > max_size {
542                return Err(RpcError::runtime_error(format!(
543                    "external payload {} bytes exceeds max_decompressed_bytes={max_size}",
544                    bytes.len()
545                )));
546            }
547            Ok(bytes.to_vec())
548        }
549        // Stream-decode and stop if we exceed the cap. Avoids trusting
550        // the zstd frame header's declared decompressed size (which
551        // `decode_all` would otherwise allocate eagerly), blocking a
552        // remote OOM via a tiny payload claiming gigabytes of output.
553        Compression::Zstd(_) => {
554            use std::io::Read;
555            let mut decoder = zstd::Decoder::new(bytes)
556                .map_err(|e| RpcError::runtime_error(format!("zstd decode: {e}")))?;
557            decoder
558                .window_log_max(zstd_window_log_for_limit(max_size))
559                .map_err(|e| RpcError::runtime_error(format!("zstd window limit: {e}")))?;
560            let mut out = Vec::new();
561            let mut buf = [0u8; 64 * 1024];
562            loop {
563                let n = decoder
564                    .read(&mut buf)
565                    .map_err(|e| RpcError::runtime_error(format!("zstd decode: {e}")))?;
566                if n == 0 {
567                    break;
568                }
569                if out.len() + n > max_size {
570                    return Err(RpcError::runtime_error(format!(
571                        "zstd decode: output exceeds max_decompressed_bytes={max_size}"
572                    )));
573                }
574                out.extend_from_slice(&buf[..n]);
575            }
576            Ok(out)
577        }
578    }
579}
580
581// ---------------------------------------------------------------------------
582// Server-side: externalize large batches
583// ---------------------------------------------------------------------------
584
585/// Pointer-batch schema — a zero-field, zero-row batch. The Python
586/// canonical externalizes into an empty-schema batch with location
587/// metadata; we match that so the on-wire bytes are identical.
588pub fn pointer_schema() -> SchemaRef {
589    Arc::new(Schema::empty())
590}
591
592/// A batch that has been serialized (and compressed) for external storage
593/// but **not yet uploaded**.
594///
595/// Splitting the prepare step out of [`maybe_externalize_batch`] is what
596/// makes an operator cap (`max_externalized_response_bytes`) enforceable
597/// *before* the bytes leave the process: the exact payload is already in
598/// hand, so a response that would violate the cap can be refused without
599/// paying for the storage round trip. Nothing observable happens until
600/// [`upload_prepared`] is called, so dropping a `PreparedExternal` is a
601/// clean abort.
602pub struct PreparedExternal {
603    /// Raw (pre-compression) IPC bytes — what the cap is measured in.
604    raw_len: usize,
605    /// The bytes that will actually be uploaded.
606    payload: Vec<u8>,
607    /// SHA-256 of the **raw** IPC bytes.
608    sha: String,
609    /// Zero-row pointer batch matching the source batch's schema.
610    ptr: RecordBatch,
611    /// Caller metadata with any stale location keys already stripped.
612    md: Metadata,
613}
614
615impl PreparedExternal {
616    /// Size of this payload as `max_externalized_response_bytes` measures
617    /// it: the **raw** IPC bytes, captured *before* external compression.
618    ///
619    /// Pre-compression on purpose, and identical whether or not
620    /// [`ExternalLocationConfig::with_compression`] is in effect. Python's
621    /// `maybe_externalize_batch` returns exactly this quantity for cap
622    /// accounting (`raw_size = original_bytes ...`, taken before
623    /// `_codec_compress`), and TypeScript compares an uncompressed batch
624    /// size too. Charging the compressed upload instead would make one
625    /// configuration mean different things on different ports, and would
626    /// silently loosen the cap by the compression ratio — which for the
627    /// repetitive payloads that provoke externalisation is enormous.
628    ///
629    /// The access-log counter (`DispatchInfo::externalized_bytes`) stays on
630    /// the compressed number: that one answers "what left the machine",
631    /// this one answers "what did the operator allow".
632    ///
633    /// One deliberate deviation from the reference: Python's *pre-flight*
634    /// predicts with `batch.get_total_buffer_size()` and then charges the
635    /// raw IPC count, so its two numbers are only approximately equal.
636    /// Rust has already serialized by this point (the threshold gate needs
637    /// the IPC length anyway), so the pre-flight and the charge are the
638    /// same number and cannot disagree. Same units, same
639    /// "uncompressed size of the data" semantics, no estimate error.
640    pub fn cap_bytes(&self) -> usize {
641        self.raw_len
642    }
643}
644
645/// Serialize `batch` for external storage without uploading it.
646///
647/// Returns `None` under exactly the conditions [`maybe_externalize_batch`]
648/// declines to externalize (empty batch, or IPC bytes below the configured
649/// threshold), so a caller that pre-flights with this and then uploads sees
650/// the same decision it would have gotten from the one-shot helper.
651///
652/// `declared_schema` is the schema of the **enclosing** IPC stream — the one
653/// the peer already read and will validate the fetched payload against. It
654/// is not always `batch.schema()`: a worker may emit a batch that differs
655/// from its stream's declared schema in nullability, dictionary encoding or
656/// schema metadata, and inline delivery hides that completely (see
657/// [`crate::wire::write_one_batch_as`]). Declaring the batch's own schema on
658/// the uploaded payload is what turns such a difference into a client-side
659/// `Schema mismatch` the moment externalisation is switched on.
660pub fn prepare_externalize_batch(
661    batch: &RecordBatch,
662    declared_schema: &Schema,
663    inline_metadata: Option<&Metadata>,
664    cfg: &ExternalLocationConfig,
665) -> Result<Option<PreparedExternal>> {
666    if batch.num_rows() == 0 {
667        return Ok(None);
668    }
669    // Build pointer metadata, merging the caller-supplied metadata first.
670    // The location keys are added by `upload_prepared`, which is the only
671    // place that knows the URL.
672    let mut md: Metadata = inline_metadata.cloned().unwrap_or_default();
673    md.remove(LOCATION_KEY);
674    md.remove(LOCATION_SHA256_KEY);
675    md.remove(LOCATION_FETCH_MS_KEY);
676
677    // The caller's metadata is written on BOTH sides of the indirection:
678    // inside the uploaded stream and on the pointer batch. Ports disagree
679    // about which one carries per-batch keys — Python's resolver returns the
680    // *inner* batch's metadata and discards the pointer's, while this crate's
681    // resolver merges both. Writing it twice is what makes a key that must
682    // survive externalisation (the exchange cursor
683    // `vgi_rpc.stream_state#b64`, `vgi_batch_index`, partition values)
684    // survive for either client. The duplicate costs a few bytes and the
685    // values are identical, so a merge in any order agrees.
686    let ipc_bytes = write_one_batch_as(
687        batch,
688        declared_schema,
689        if md.is_empty() { None } else { Some(&md) },
690    )?;
691    if ipc_bytes.len() < cfg.threshold_bytes {
692        return Ok(None);
693    }
694    let raw_len = ipc_bytes.len();
695    let sha = sha256_hex(&ipc_bytes);
696    let payload = compress(&ipc_bytes, cfg.compression)?;
697
698    // Pointer batch: zero-row but matching the enclosing stream's schema,
699    // matching Python's `make_external_location_batch` shape so the
700    // client's IPC reader sees a consistent column count — and so the
701    // schema it validates the fetched payload against is the one the
702    // payload declares.
703    let ptr = empty_batch(declared_schema)?;
704    Ok(Some(PreparedExternal {
705        raw_len,
706        payload,
707        sha,
708        ptr,
709        md,
710    }))
711}
712
713/// Upload a [`PreparedExternal`] and return the pointer batch + metadata.
714///
715/// This is the single upload choke point: the externalised-bytes counter
716/// read by [`ExternalizedScope`] is incremented here, so a new call site
717/// cannot make the total drift from reality.
718pub fn upload_prepared(
719    prepared: PreparedExternal,
720    cfg: &ExternalLocationConfig,
721) -> Result<(RecordBatch, Metadata)> {
722    let PreparedExternal {
723        payload,
724        sha,
725        ptr,
726        mut md,
727        ..
728    } = prepared;
729    EXTERNALIZED_BYTES.with(|c| c.set(c.get() + payload.len() as u64));
730    let upload = cfg.storage.upload(&payload, cfg.compression)?;
731    // Validator runs over the final URL.
732    validate_external_url(&cfg.url_validator, &upload.url)?;
733    md.insert(LOCATION_KEY.to_string(), upload.url);
734    md.insert(LOCATION_SHA256_KEY.to_string(), sha);
735    Ok((ptr, md))
736}
737
738/// Decide whether to externalize `batch`; return the pointer (zero-row)
739/// batch + pointer metadata when yes, else `None`. The original batch is
740/// left untouched so the caller can emit it inline.
741///
742/// `inline_metadata` — optional custom metadata the caller wants to
743/// attach alongside the location keys (merged; location keys win).
744///
745/// Callers that must enforce a byte cap should use
746/// [`prepare_externalize_batch`] + [`upload_prepared`] instead, which lets
747/// them see the payload size before the upload happens.
748///
749/// `declared_schema` is the enclosing IPC stream's schema; see
750/// [`prepare_externalize_batch`].
751pub fn maybe_externalize_batch(
752    batch: &RecordBatch,
753    declared_schema: &Schema,
754    inline_metadata: Option<&Metadata>,
755    cfg: &ExternalLocationConfig,
756) -> Result<Option<(RecordBatch, Metadata)>> {
757    match prepare_externalize_batch(batch, declared_schema, inline_metadata, cfg)? {
758        None => Ok(None),
759        Some(prepared) => upload_prepared(prepared, cfg).map(Some),
760    }
761}
762
763// ---------------------------------------------------------------------------
764// Client-side: resolve pointer batches
765// ---------------------------------------------------------------------------
766
767/// Resolve a pointer batch (zero-row batch with `vgi_rpc.location`
768/// metadata) back into the original record batch. Non-pointer batches
769/// are returned untouched.
770///
771/// Returns `(resolved_batch, user_metadata)` where the location keys
772/// have been stripped from the metadata visible to the caller. A
773/// `vgi_rpc.location.fetch_ms` claim is appended so callers / access
774/// logs can observe the fetch latency.
775pub fn resolve_external_location(
776    batch: &RecordBatch,
777    metadata: &Metadata,
778    cfg: &ExternalLocationConfig,
779) -> Result<(RecordBatch, Metadata)> {
780    let Some(url) = md_get(metadata, LOCATION_KEY) else {
781        return Ok((batch.clone(), metadata.clone()));
782    };
783    let start = std::time::Instant::now();
784    let fetched = cfg.fetcher.fetch_with_policy(
785        url,
786        cfg.compression,
787        cfg.max_encoded_bytes,
788        &cfg.url_validator,
789        cfg.max_redirects,
790    )?;
791    let ipc_bytes = decompress(
792        &fetched.bytes,
793        fetched.compression,
794        cfg.max_decompressed_bytes,
795    )?;
796
797    // Integrity check.
798    if let Some(expected) = md_get(metadata, LOCATION_SHA256_KEY) {
799        let actual = sha256_hex(&ipc_bytes);
800        if expected != actual.as_str() {
801            return Err(RpcError::runtime_error(format!(
802                "external location checksum: SHA-256 mismatch (expected {expected}, got {actual})"
803            )));
804        }
805    }
806    let (resolved, inner_md) = deserialize_single_batch_with_metadata(&ipc_bytes)?;
807    let fetch_ms = start.elapsed().as_secs_f64() * 1000.0;
808
809    // Start from the outer pointer's non-location keys, then overlay the inner
810    // (externalized) batch's metadata. Implementations differ on where they
811    // carry per-batch keys like `vgi_rpc.stream_state#b64`: the Rust server
812    // stamps them on the outer pointer, the Python server on the inner payload
813    // batch. Merging both (inner wins) recovers the token either way and
814    // matches Python's resolver, which uses the inner batch's metadata.
815    let mut user_md: Metadata = metadata
816        .iter()
817        .filter(|(k, _)| {
818            *k != LOCATION_KEY && *k != LOCATION_SHA256_KEY && *k != LOCATION_FETCH_MS_KEY
819        })
820        .map(|(k, v)| (k.clone(), v.clone()))
821        .collect();
822    for (k, v) in inner_md {
823        if k != LOCATION_KEY && k != LOCATION_SHA256_KEY && k != LOCATION_FETCH_MS_KEY {
824            user_md.insert(k, v);
825        }
826    }
827    user_md.insert(
828        LOCATION_FETCH_MS_KEY.to_string(),
829        format!("{:.2}", fetch_ms),
830    );
831    Ok((resolved, user_md))
832}
833
834// ---------------------------------------------------------------------------
835// In-memory test backend
836// ---------------------------------------------------------------------------
837//
838// Gated behind the `test-utils` feature so it doesn't show up on
839// crates.io / docs.rs for normal users. Internal tests + the
840// `external_integration` integration test enable the feature
841// transitively via the workspace.
842
843/// In-memory storage backend + fetcher pair; used by tests and CI.
844/// Thread-safe, no I/O. **Not for production use.**
845#[cfg(any(test, feature = "test-utils"))]
846pub struct InMemoryStorage {
847    map: std::sync::Mutex<std::collections::HashMap<String, Vec<u8>>>,
848    next_id: std::sync::atomic::AtomicU64,
849    base_url: String,
850}
851
852#[cfg(any(test, feature = "test-utils"))]
853impl InMemoryStorage {
854    pub fn new() -> Arc<Self> {
855        Arc::new(Self {
856            map: std::sync::Mutex::new(std::collections::HashMap::new()),
857            next_id: std::sync::atomic::AtomicU64::new(1),
858            base_url: "https://inmem.test/".to_string(),
859        })
860    }
861
862    pub fn len(&self) -> usize {
863        self.map.lock().unwrap().len()
864    }
865
866    pub fn is_empty(&self) -> bool {
867        self.len() == 0
868    }
869}
870
871#[cfg(any(test, feature = "test-utils"))]
872impl ExternalStorage for InMemoryStorage {
873    fn upload(&self, ipc_bytes: &[u8], _compression: Compression) -> Result<UploadResult> {
874        let id = self
875            .next_id
876            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
877        let url = format!("{}{:016x}", self.base_url, id);
878        let sha = sha256_hex(
879            // Storage receives the already-compressed bytes; the sha recorded
880            // in the pointer metadata tracks the RAW ipc bytes so the
881            // caller of upload() is responsible for that value — we only
882            // store, not hash, here. Return a placeholder hash (caller
883            // replaces it from maybe_externalize_batch).
884            ipc_bytes,
885        );
886        self.map
887            .lock()
888            .unwrap()
889            .insert(url.clone(), ipc_bytes.to_vec());
890        Ok(UploadResult { url, sha256: sha })
891    }
892}
893
894#[cfg(any(test, feature = "test-utils"))]
895impl Fetcher for InMemoryStorage {
896    fn fetch(&self, url: &str, _compression: Compression, max_bytes: usize) -> Result<Vec<u8>> {
897        let bytes = self
898            .map
899            .lock()
900            .unwrap()
901            .get(url)
902            .cloned()
903            .ok_or_else(|| RpcError::runtime_error(format!("inmem fetch miss: {url}")))?;
904        if bytes.len() > max_bytes {
905            return Err(RpcError::runtime_error(format!(
906                "inmem fetch payload {} bytes exceeds max_bytes={max_bytes}",
907                bytes.len()
908            )));
909        }
910        Ok(bytes)
911    }
912}
913
914#[cfg(test)]
915mod tests {
916    use std::sync::Arc as Ar;
917
918    use arrow_array::{Int64Array, RecordBatch};
919    use arrow_schema::{DataType, Field, Schema};
920
921    use super::*;
922
923    fn big_batch(rows: usize) -> RecordBatch {
924        let schema = Arc::new(Schema::new(vec![Field::new(
925            "value",
926            DataType::Int64,
927            false,
928        )]));
929        let col: Ar<dyn arrow_array::Array> =
930            Arc::new(Int64Array::from((0..rows as i64).collect::<Vec<_>>()));
931        RecordBatch::try_new(schema, vec![col]).unwrap()
932    }
933
934    fn cfg_with(storage: Arc<InMemoryStorage>, threshold: usize) -> ExternalLocationConfig {
935        let s: Arc<dyn ExternalStorage> = storage.clone();
936        let f: Arc<dyn Fetcher> = storage;
937        ExternalLocationConfig::new(s, f)
938            .with_threshold_bytes(threshold)
939            .with_url_validator(any_url_validator())
940    }
941
942    #[test]
943    fn small_batch_stays_inline() {
944        let storage = InMemoryStorage::new();
945        let cfg = cfg_with(storage.clone(), 1024 * 1024);
946        let batch = big_batch(10);
947        let out = maybe_externalize_batch(&batch, batch.schema().as_ref(), None, &cfg).unwrap();
948        assert!(out.is_none());
949        assert!(storage.is_empty());
950    }
951
952    #[test]
953    fn large_batch_externalizes_and_round_trips() {
954        let storage = InMemoryStorage::new();
955        let cfg = cfg_with(storage.clone(), 1024);
956        let batch = big_batch(50_000);
957
958        let (ptr, md) = maybe_externalize_batch(&batch, batch.schema().as_ref(), None, &cfg)
959            .unwrap()
960            .unwrap();
961        assert_eq!(ptr.num_rows(), 0);
962        // Pointer batch carries the original schema (zero-row); cross-language
963        // clients expect the column count to match the result schema.
964        assert_eq!(ptr.schema().fields().len(), batch.schema().fields().len());
965        assert!(md_get(&md, LOCATION_KEY).unwrap().starts_with("https://"));
966        assert_eq!(storage.len(), 1);
967
968        let (resolved, user_md) = resolve_external_location(&ptr, &md, &cfg).unwrap();
969        assert_eq!(resolved.num_rows(), batch.num_rows());
970        assert!(md_get(&user_md, LOCATION_KEY).is_none());
971        assert!(md_get(&user_md, LOCATION_FETCH_MS_KEY).is_some());
972    }
973
974    #[test]
975    fn zstd_compression_round_trip() {
976        let storage = InMemoryStorage::new();
977        let cfg = cfg_with(storage.clone(), 1024).with_compression(Compression::Zstd(3));
978        let batch = big_batch(20_000);
979        let (ptr, md) = maybe_externalize_batch(&batch, batch.schema().as_ref(), None, &cfg)
980            .unwrap()
981            .unwrap();
982        let (resolved, _) = resolve_external_location(&ptr, &md, &cfg).unwrap();
983        assert_eq!(resolved.num_rows(), batch.num_rows());
984    }
985
986    #[test]
987    fn zstd_rejects_large_window_even_when_output_is_tiny() {
988        use std::io::Write;
989
990        let mut encoder = zstd::stream::Encoder::new(Vec::new(), 1).unwrap();
991        encoder.window_log(20).unwrap();
992        encoder.include_contentsize(false).unwrap();
993        encoder.write_all(b"tiny").unwrap();
994        let frame = encoder.finish().unwrap();
995
996        let err = decompress(&frame, Compression::Zstd(1), 1024)
997            .expect_err("a 1 MiB window must exceed a 1 KiB decode budget");
998        assert!(err.message.contains("window") || err.message.contains("memory"));
999    }
1000
1001    // An externalised payload is a standalone IPC stream and declares its own
1002    // schema, whereas an inline batch rides a stream whose schema was declared
1003    // once, up front. A batch differing from that declared schema only
1004    // cosmetically — here, field nullability — is invisible inline (the writer
1005    // never reconciles the two) and becomes a hard `Schema mismatch` at a peer
1006    // that validates the fetched payload the moment externalisation is turned
1007    // on. The declared schema must therefore travel with the payload.
1008    #[test]
1009    fn externalized_payload_declares_the_enclosing_stream_schema() {
1010        let storage = InMemoryStorage::new();
1011        let cfg = cfg_with(storage.clone(), 1024);
1012        // What the stream promised: `value` is nullable.
1013        let declared = Schema::new(vec![Field::new("value", DataType::Int64, true)]);
1014        // What the worker emitted: same data, non-nullable field.
1015        let batch = big_batch(50_000);
1016        assert!(!batch.schema().field(0).is_nullable());
1017
1018        let (ptr, md) = maybe_externalize_batch(&batch, &declared, None, &cfg)
1019            .unwrap()
1020            .unwrap();
1021        // The pointer the peer decodes carries the declared schema...
1022        assert!(ptr.schema().field(0).is_nullable());
1023        // ...and so does the payload behind it, so a validating peer sees
1024        // the two agree.
1025        let raw = cfg
1026            .fetcher
1027            .fetch(
1028                md_get(&md, LOCATION_KEY).unwrap(),
1029                cfg.compression,
1030                cfg.max_decompressed_bytes,
1031            )
1032            .unwrap();
1033        let mut reader = StreamReader::new(raw.as_slice()).unwrap();
1034        let (fetched, _) = reader.read_next().unwrap().unwrap();
1035        assert_eq!(fetched.schema().as_ref(), &declared);
1036        assert_eq!(fetched.num_rows(), batch.num_rows());
1037    }
1038
1039    // Java's uploader cannot carry dictionaries, so its port has to keep
1040    // dictionary-encoded batches inline. Rust's writer emits the dictionary
1041    // messages and the reader consumes them transparently, so no such
1042    // carve-out is needed here — pinned so a future change to either side
1043    // cannot quietly reintroduce the constraint.
1044    #[test]
1045    fn dictionary_encoded_batch_round_trips_externally() {
1046        use arrow_array::types::Int32Type;
1047        use arrow_array::{Array, DictionaryArray};
1048
1049        let storage = InMemoryStorage::new();
1050        let cfg = cfg_with(storage.clone(), 1024);
1051        let values: Vec<&str> = (0..20_000)
1052            .map(|i| ["alpha", "beta", "gamma"][i % 3])
1053            .collect();
1054        let dict: DictionaryArray<Int32Type> = values.into_iter().collect();
1055        let schema = Arc::new(Schema::new(vec![Field::new(
1056            "label",
1057            dict.data_type().clone(),
1058            false,
1059        )]));
1060        let col: Ar<dyn arrow_array::Array> = Arc::new(dict);
1061        let batch = RecordBatch::try_new(schema.clone(), vec![col]).unwrap();
1062
1063        let (ptr, md) = maybe_externalize_batch(&batch, schema.as_ref(), None, &cfg)
1064            .unwrap()
1065            .unwrap();
1066        assert_eq!(storage.len(), 1);
1067        let (resolved, _) = resolve_external_location(&ptr, &md, &cfg).unwrap();
1068        assert_eq!(resolved.num_rows(), batch.num_rows());
1069        assert_eq!(resolved.column(0).as_ref(), batch.column(0).as_ref());
1070    }
1071
1072    #[test]
1073    fn https_only_validator_rejects_plaintext() {
1074        let storage = InMemoryStorage::new();
1075        let s: Arc<dyn ExternalStorage> = storage.clone();
1076        let f: Arc<dyn Fetcher> = storage;
1077        let cfg = ExternalLocationConfig::new(s, f).with_threshold_bytes(0);
1078        // In-memory URL is https, so build a forged metadata entry.
1079        let batch = big_batch(1);
1080        let mut bogus_md = Metadata::new();
1081        bogus_md.insert("vgi_rpc.location".into(), "http://not-secure/x".into());
1082        let err = resolve_external_location(&batch, &bogus_md, &cfg).unwrap_err();
1083        assert!(err.message.contains("https://"));
1084    }
1085
1086    #[test]
1087    fn safe_https_validator_blocks_ssrf_targets() {
1088        let v = safe_https_validator();
1089        // Non-https.
1090        assert!(v("http://example.com/x").is_err());
1091        // IP-literal internal / non-routable targets.
1092        assert!(v("https://169.254.169.254/latest/meta-data/").is_err());
1093        assert!(v("https://127.0.0.1/").is_err());
1094        assert!(v("https://10.0.0.1/").is_err());
1095        assert!(v("https://192.168.1.1/").is_err());
1096        assert!(v("https://[::1]/").is_err());
1097        assert!(v("https://0.0.0.0/").is_err());
1098        // Hostname forms of loopback.
1099        assert!(v("https://localhost/x").is_err());
1100        assert!(v("https://api.localhost/x").is_err());
1101        // A public IP literal is allowed.
1102        assert!(v("https://1.1.1.1/x").is_ok());
1103    }
1104
1105    #[test]
1106    fn sha_mismatch_is_rejected() {
1107        let storage = InMemoryStorage::new();
1108        let cfg = cfg_with(storage.clone(), 1024);
1109        let batch = big_batch(10_000);
1110        let (ptr, mut md) = maybe_externalize_batch(&batch, batch.schema().as_ref(), None, &cfg)
1111            .unwrap()
1112            .unwrap();
1113        // Corrupt the recorded hash.
1114        if let Some(v) = md.get_mut(LOCATION_SHA256_KEY) {
1115            *v = "deadbeef".into();
1116        }
1117        let err = resolve_external_location(&ptr, &md, &cfg).unwrap_err();
1118        assert!(err.message.contains("SHA-256 mismatch"));
1119    }
1120}