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/// Pluggable fetcher used to resolve pointer batches back into data.
168///
169/// Takes a URL (and the declared compression) and returns the still-encoded
170/// payload bytes. `vgi-rpc` ships an HTTPS fetcher; tests plug in an
171/// in-memory implementation.
172///
173/// `max_bytes` is a hard ceiling on the number of bytes the fetcher may
174/// read from the remote — implementations **must** abort once it is
175/// exceeded rather than buffering an unbounded response into memory. A
176/// hostile or compromised storage URL would otherwise OOM the process
177/// before decompression's own cap is ever reached.
178pub trait Fetcher: Send + Sync {
179    fn fetch(&self, url: &str, compression: Compression, max_bytes: usize) -> Result<Vec<u8>>;
180}
181
182/// Externalization configuration.
183#[derive(Clone)]
184pub struct ExternalLocationConfig {
185    /// Payload size in bytes at which a batch is eligible for
186    /// externalization. Smaller batches stay inline. Default 1 MiB.
187    pub threshold_bytes: usize,
188    /// Compression applied to the IPC bytes before upload. Default
189    /// `Compression::None`.
190    pub compression: Compression,
191    /// Upload backend. Required when externalization is enabled.
192    pub storage: Arc<dyn ExternalStorage>,
193    /// Resolver used on the read side. Required when resolving inbound
194    /// pointer batches.
195    pub fetcher: Arc<dyn Fetcher>,
196    /// URL validator run on both the upload (post-signing) and fetch
197    /// (pre-download) paths. Defaults to [`safe_https_validator`], which
198    /// rejects non-`https` URLs and internal/non-routable hosts. Reject
199    /// a URL by returning `Err`.
200    pub url_validator: UrlValidator,
201    /// Hard ceiling on the post-decompression size of a fetched
202    /// payload. Zstd frames carry their decompressed size in the
203    /// header and `zstd::decode_all` would otherwise trust it
204    /// eagerly — a small malicious payload claiming gigabytes of
205    /// output would OOM the client. Default 1 GiB. Set to `usize::MAX`
206    /// to disable.
207    pub max_decompressed_bytes: usize,
208}
209
210impl std::fmt::Debug for ExternalLocationConfig {
211    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
212        f.debug_struct("ExternalLocationConfig")
213            .field("threshold_bytes", &self.threshold_bytes)
214            .field("compression", &self.compression)
215            .finish_non_exhaustive()
216    }
217}
218
219/// Helper: scheme-only HTTPS validator.
220///
221/// **Insufficient for untrusted input** — it accepts `https://localhost`,
222/// `https://169.254.169.254` (cloud metadata), and any internal-network
223/// host. Use it only when the set of external-location URLs is fully
224/// trusted (e.g. URLs your own backend minted). For anything that can
225/// carry a client-supplied location, use [`safe_https_validator`].
226pub fn https_only_validator() -> UrlValidator {
227    Arc::new(|url: &str| {
228        if url.starts_with("https://") {
229            Ok(())
230        } else {
231            Err(RpcError::value_error(format!(
232                "external location URL must be https:// ({url})"
233            )))
234        }
235    })
236}
237
238/// Helper: default-deny HTTPS validator with SSRF protection.
239///
240/// Requires the `https` scheme, then rejects the URL when its host is —
241/// or resolves to — a loopback, private, link-local, unique-local,
242/// carrier-grade-NAT, broadcast, documentation, or unspecified address.
243/// This is the default for [`ExternalLocationConfig::new`] because the
244/// unary HTTP path resolves a *client-supplied* `vgi_rpc.location`
245/// server-side; without this a client could pivot the server into
246/// fetching `https://169.254.169.254/...` or an internal service.
247///
248/// Note: a hostname is resolved here and again at fetch time, so a
249/// DNS-rebinding attacker could still slip through the gap. Pair this
250/// with a redirect-free, size-capped fetcher (the bundled `HttpFetcher`
251/// is both) and, for high-assurance deployments, an egress firewall.
252pub fn safe_https_validator() -> UrlValidator {
253    Arc::new(|raw: &str| {
254        let url = url::Url::parse(raw)
255            .map_err(|e| RpcError::value_error(format!("invalid external location URL: {e}")))?;
256        if url.scheme() != "https" {
257            return Err(RpcError::value_error(
258                "external location URL must be https://",
259            ));
260        }
261        let host = url
262            .host()
263            .ok_or_else(|| RpcError::value_error("external location URL has no host"))?;
264        match host {
265            url::Host::Ipv4(ip) => reject_unsafe_ip(IpAddr::V4(ip)),
266            url::Host::Ipv6(ip) => reject_unsafe_ip(IpAddr::V6(ip)),
267            url::Host::Domain(name) => {
268                let lname = name.to_ascii_lowercase();
269                if lname == "localhost" || lname.ends_with(".localhost") {
270                    return Err(RpcError::value_error(
271                        "external location host is not publicly routable",
272                    ));
273                }
274                let port = url.port_or_known_default().unwrap_or(443);
275                let addrs = (name, port).to_socket_addrs().map_err(|e| {
276                    RpcError::value_error(format!("external location host does not resolve: {e}"))
277                })?;
278                let mut saw_any = false;
279                for sa in addrs {
280                    saw_any = true;
281                    reject_unsafe_ip(sa.ip())?;
282                }
283                if !saw_any {
284                    return Err(RpcError::value_error(
285                        "external location host does not resolve",
286                    ));
287                }
288                Ok(())
289            }
290        }
291    })
292}
293
294/// Reject an IP address that is not safe for the server to dial: any
295/// loopback / private / link-local / unique-local / CGNAT / broadcast /
296/// documentation / unspecified / multicast address.
297fn reject_unsafe_ip(ip: IpAddr) -> Result<()> {
298    let unsafe_addr = match ip {
299        IpAddr::V4(v4) => {
300            let o = v4.octets();
301            v4.is_loopback()
302                || v4.is_private()
303                || v4.is_link_local()
304                || v4.is_unspecified()
305                || v4.is_broadcast()
306                || v4.is_multicast()
307                || v4.is_documentation()
308                // 100.64.0.0/10 — carrier-grade NAT.
309                || (o[0] == 100 && (o[1] & 0xc0) == 0x40)
310        }
311        IpAddr::V6(v6) => {
312            let seg0 = v6.segments()[0];
313            v6.is_loopback()
314                || v6.is_unspecified()
315                || v6.is_multicast()
316                // fc00::/7 — unique local.
317                || (seg0 & 0xfe00) == 0xfc00
318                // fe80::/10 — link local.
319                || (seg0 & 0xffc0) == 0xfe80
320                // IPv4-mapped (::ffff:0:0/96) — classify the embedded v4.
321                || v6
322                    .to_ipv4_mapped()
323                    .map(|m| reject_unsafe_ip(IpAddr::V4(m)).is_err())
324                    .unwrap_or(false)
325        }
326    };
327    if unsafe_addr {
328        return Err(RpcError::value_error(
329            "external location host resolves to a non-routable / internal address",
330        ));
331    }
332    Ok(())
333}
334
335/// Helper: accept any URL (useful for local tests + MinIO).
336pub fn any_url_validator() -> UrlValidator {
337    Arc::new(|_: &str| Ok(()))
338}
339
340impl ExternalLocationConfig {
341    pub fn new(storage: Arc<dyn ExternalStorage>, fetcher: Arc<dyn Fetcher>) -> Self {
342        Self {
343            threshold_bytes: 1024 * 1024,
344            compression: Compression::None,
345            storage,
346            fetcher,
347            url_validator: safe_https_validator(),
348            max_decompressed_bytes: 1024 * 1024 * 1024,
349        }
350    }
351
352    pub fn with_threshold_bytes(mut self, n: usize) -> Self {
353        self.threshold_bytes = n;
354        self
355    }
356
357    pub fn with_compression(mut self, c: Compression) -> Self {
358        self.compression = c;
359        self
360    }
361
362    pub fn with_url_validator(mut self, v: UrlValidator) -> Self {
363        self.url_validator = v;
364        self
365    }
366
367    /// Override the hard ceiling on post-decompression payload size.
368    /// Pass `usize::MAX` to disable. Default is 1 GiB.
369    pub fn with_max_decompressed_bytes(mut self, n: usize) -> Self {
370        self.max_decompressed_bytes = n;
371        self
372    }
373}
374
375// ---------------------------------------------------------------------------
376// Serialize a batch as an IPC stream with no custom metadata.
377// ---------------------------------------------------------------------------
378
379/// Serialize one record batch as a complete IPC stream (schema + batch + EOS).
380pub fn serialize_batch_to_ipc(batch: &RecordBatch) -> Result<Vec<u8>> {
381    // External payloads carry the raw data only; the pointer batch on
382    // the outside owns the metadata. Pass `None` to omit any
383    // `custom_metadata` field on the wire.
384    write_one_batch_as(batch, batch.schema().as_ref(), None)
385}
386
387/// Read back an IPC stream containing a single batch.
388/// Fetch, decompress, and integrity-check an external-location pointer's
389/// payload, returning the raw inner IPC stream bytes. The inner stream may
390/// contain **multiple** batches (e.g. a peer that externalizes a whole
391/// per-iteration output — logs followed by the data batch), so callers that
392/// need log/exception handling should process the returned bytes as a full
393/// response stream rather than assuming a single batch.
394///
395/// Returns `Ok(None)` when `metadata` carries no `vgi_rpc.location` pointer.
396pub fn fetch_external_ipc_bytes(
397    metadata: &Metadata,
398    cfg: &ExternalLocationConfig,
399) -> Result<Option<Vec<u8>>> {
400    let Some(url) = md_get(metadata, LOCATION_KEY) else {
401        return Ok(None);
402    };
403    (cfg.url_validator)(url)?;
404    let compressed = cfg
405        .fetcher
406        .fetch(url, cfg.compression, cfg.max_decompressed_bytes)?;
407    let ipc_bytes = decompress(&compressed, cfg.compression, cfg.max_decompressed_bytes)?;
408    if let Some(expected) = md_get(metadata, LOCATION_SHA256_KEY) {
409        let actual = sha256_hex(&ipc_bytes);
410        if expected != actual.as_str() {
411            return Err(RpcError::runtime_error(format!(
412                "external location SHA-256 mismatch (expected {expected}, got {actual})"
413            )));
414        }
415    }
416    Ok(Some(ipc_bytes))
417}
418
419pub fn deserialize_single_batch(ipc_bytes: &[u8]) -> Result<RecordBatch> {
420    Ok(deserialize_single_batch_with_metadata(ipc_bytes)?.0)
421}
422
423/// Like [`deserialize_single_batch`] but also returns the batch's per-message
424/// custom metadata — some peers carry keys (e.g. the stream-state token) on the
425/// externalized inner batch rather than the outer pointer.
426pub fn deserialize_single_batch_with_metadata(ipc_bytes: &[u8]) -> Result<(RecordBatch, Metadata)> {
427    let mut r = StreamReader::new(ipc_bytes)?;
428    r.read_next()?
429        .ok_or_else(|| RpcError::runtime_error("external batch stream is empty"))
430}
431
432fn sha256_hex(bytes: &[u8]) -> String {
433    bytes_to_hex(&Sha256::digest(bytes))
434}
435
436fn compress(ipc_bytes: &[u8], compression: Compression) -> Result<Vec<u8>> {
437    match compression {
438        Compression::None => Ok(ipc_bytes.to_vec()),
439        Compression::Zstd(level) => {
440            // Use the bulk API and explicitly include the decompressed
441            // size in the frame header. Python's `zstandard.ZstdDecompressor`
442            // requires `Content-Size` to be present when decompressing
443            // a single frame in one shot.
444            let mut enc = zstd::bulk::Compressor::new(level)
445                .map_err(|e| RpcError::runtime_error(format!("zstd encoder: {e}")))?;
446            enc.set_parameter(zstd::stream::raw::CParameter::ContentSizeFlag(true))
447                .map_err(|e| RpcError::runtime_error(format!("zstd contentsize: {e}")))?;
448            enc.compress(ipc_bytes)
449                .map_err(|e| RpcError::runtime_error(format!("zstd encode: {e}")))
450        }
451    }
452}
453
454fn decompress(bytes: &[u8], compression: Compression, max_size: usize) -> Result<Vec<u8>> {
455    match compression {
456        Compression::None => {
457            if bytes.len() > max_size {
458                return Err(RpcError::runtime_error(format!(
459                    "external payload {} bytes exceeds max_decompressed_bytes={max_size}",
460                    bytes.len()
461                )));
462            }
463            Ok(bytes.to_vec())
464        }
465        // Stream-decode and stop if we exceed the cap. Avoids trusting
466        // the zstd frame header's declared decompressed size (which
467        // `decode_all` would otherwise allocate eagerly), blocking a
468        // remote OOM via a tiny payload claiming gigabytes of output.
469        Compression::Zstd(_) => {
470            use std::io::Read;
471            let mut decoder = zstd::Decoder::new(bytes)
472                .map_err(|e| RpcError::runtime_error(format!("zstd decode: {e}")))?;
473            let mut out = Vec::new();
474            let mut buf = [0u8; 64 * 1024];
475            loop {
476                let n = decoder
477                    .read(&mut buf)
478                    .map_err(|e| RpcError::runtime_error(format!("zstd decode: {e}")))?;
479                if n == 0 {
480                    break;
481                }
482                if out.len() + n > max_size {
483                    return Err(RpcError::runtime_error(format!(
484                        "zstd decode: output exceeds max_decompressed_bytes={max_size}"
485                    )));
486                }
487                out.extend_from_slice(&buf[..n]);
488            }
489            Ok(out)
490        }
491    }
492}
493
494// ---------------------------------------------------------------------------
495// Server-side: externalize large batches
496// ---------------------------------------------------------------------------
497
498/// Pointer-batch schema — a zero-field, zero-row batch. The Python
499/// canonical externalizes into an empty-schema batch with location
500/// metadata; we match that so the on-wire bytes are identical.
501pub fn pointer_schema() -> SchemaRef {
502    Arc::new(Schema::empty())
503}
504
505/// A batch that has been serialized (and compressed) for external storage
506/// but **not yet uploaded**.
507///
508/// Splitting the prepare step out of [`maybe_externalize_batch`] is what
509/// makes an operator cap (`max_externalized_response_bytes`) enforceable
510/// *before* the bytes leave the process: the exact payload is already in
511/// hand, so a response that would violate the cap can be refused without
512/// paying for the storage round trip. Nothing observable happens until
513/// [`upload_prepared`] is called, so dropping a `PreparedExternal` is a
514/// clean abort.
515pub struct PreparedExternal {
516    /// Raw (pre-compression) IPC bytes — what the cap is measured in.
517    raw_len: usize,
518    /// The bytes that will actually be uploaded.
519    payload: Vec<u8>,
520    /// SHA-256 of the **raw** IPC bytes.
521    sha: String,
522    /// Zero-row pointer batch matching the source batch's schema.
523    ptr: RecordBatch,
524    /// Caller metadata with any stale location keys already stripped.
525    md: Metadata,
526}
527
528impl PreparedExternal {
529    /// Size of this payload as `max_externalized_response_bytes` measures
530    /// it: the **raw** IPC bytes, captured *before* external compression.
531    ///
532    /// Pre-compression on purpose, and identical whether or not
533    /// [`ExternalLocationConfig::with_compression`] is in effect. Python's
534    /// `maybe_externalize_batch` returns exactly this quantity for cap
535    /// accounting (`raw_size = original_bytes ...`, taken before
536    /// `_codec_compress`), and TypeScript compares an uncompressed batch
537    /// size too. Charging the compressed upload instead would make one
538    /// configuration mean different things on different ports, and would
539    /// silently loosen the cap by the compression ratio — which for the
540    /// repetitive payloads that provoke externalisation is enormous.
541    ///
542    /// The access-log counter (`DispatchInfo::externalized_bytes`) stays on
543    /// the compressed number: that one answers "what left the machine",
544    /// this one answers "what did the operator allow".
545    ///
546    /// One deliberate deviation from the reference: Python's *pre-flight*
547    /// predicts with `batch.get_total_buffer_size()` and then charges the
548    /// raw IPC count, so its two numbers are only approximately equal.
549    /// Rust has already serialized by this point (the threshold gate needs
550    /// the IPC length anyway), so the pre-flight and the charge are the
551    /// same number and cannot disagree. Same units, same
552    /// "uncompressed size of the data" semantics, no estimate error.
553    pub fn cap_bytes(&self) -> usize {
554        self.raw_len
555    }
556}
557
558/// Serialize `batch` for external storage without uploading it.
559///
560/// Returns `None` under exactly the conditions [`maybe_externalize_batch`]
561/// declines to externalize (empty batch, or IPC bytes below the configured
562/// threshold), so a caller that pre-flights with this and then uploads sees
563/// the same decision it would have gotten from the one-shot helper.
564///
565/// `declared_schema` is the schema of the **enclosing** IPC stream — the one
566/// the peer already read and will validate the fetched payload against. It
567/// is not always `batch.schema()`: a worker may emit a batch that differs
568/// from its stream's declared schema in nullability, dictionary encoding or
569/// schema metadata, and inline delivery hides that completely (see
570/// [`crate::wire::write_one_batch_as`]). Declaring the batch's own schema on
571/// the uploaded payload is what turns such a difference into a client-side
572/// `Schema mismatch` the moment externalisation is switched on.
573pub fn prepare_externalize_batch(
574    batch: &RecordBatch,
575    declared_schema: &Schema,
576    inline_metadata: Option<&Metadata>,
577    cfg: &ExternalLocationConfig,
578) -> Result<Option<PreparedExternal>> {
579    if batch.num_rows() == 0 {
580        return Ok(None);
581    }
582    // Build pointer metadata, merging the caller-supplied metadata first.
583    // The location keys are added by `upload_prepared`, which is the only
584    // place that knows the URL.
585    let mut md: Metadata = inline_metadata.cloned().unwrap_or_default();
586    md.remove(LOCATION_KEY);
587    md.remove(LOCATION_SHA256_KEY);
588    md.remove(LOCATION_FETCH_MS_KEY);
589
590    // The caller's metadata is written on BOTH sides of the indirection:
591    // inside the uploaded stream and on the pointer batch. Ports disagree
592    // about which one carries per-batch keys — Python's resolver returns the
593    // *inner* batch's metadata and discards the pointer's, while this crate's
594    // resolver merges both. Writing it twice is what makes a key that must
595    // survive externalisation (the exchange cursor
596    // `vgi_rpc.stream_state#b64`, `vgi_batch_index`, partition values)
597    // survive for either client. The duplicate costs a few bytes and the
598    // values are identical, so a merge in any order agrees.
599    let ipc_bytes = write_one_batch_as(
600        batch,
601        declared_schema,
602        if md.is_empty() { None } else { Some(&md) },
603    )?;
604    if ipc_bytes.len() < cfg.threshold_bytes {
605        return Ok(None);
606    }
607    let raw_len = ipc_bytes.len();
608    let sha = sha256_hex(&ipc_bytes);
609    let payload = compress(&ipc_bytes, cfg.compression)?;
610
611    // Pointer batch: zero-row but matching the enclosing stream's schema,
612    // matching Python's `make_external_location_batch` shape so the
613    // client's IPC reader sees a consistent column count — and so the
614    // schema it validates the fetched payload against is the one the
615    // payload declares.
616    let ptr = empty_batch(declared_schema)?;
617    Ok(Some(PreparedExternal {
618        raw_len,
619        payload,
620        sha,
621        ptr,
622        md,
623    }))
624}
625
626/// Upload a [`PreparedExternal`] and return the pointer batch + metadata.
627///
628/// This is the single upload choke point: the externalised-bytes counter
629/// read by [`ExternalizedScope`] is incremented here, so a new call site
630/// cannot make the total drift from reality.
631pub fn upload_prepared(
632    prepared: PreparedExternal,
633    cfg: &ExternalLocationConfig,
634) -> Result<(RecordBatch, Metadata)> {
635    let PreparedExternal {
636        payload,
637        sha,
638        ptr,
639        mut md,
640        ..
641    } = prepared;
642    EXTERNALIZED_BYTES.with(|c| c.set(c.get() + payload.len() as u64));
643    let upload = cfg.storage.upload(&payload, cfg.compression)?;
644    // Validator runs over the final URL.
645    (cfg.url_validator)(&upload.url)?;
646    md.insert(LOCATION_KEY.to_string(), upload.url);
647    md.insert(LOCATION_SHA256_KEY.to_string(), sha);
648    Ok((ptr, md))
649}
650
651/// Decide whether to externalize `batch`; return the pointer (zero-row)
652/// batch + pointer metadata when yes, else `None`. The original batch is
653/// left untouched so the caller can emit it inline.
654///
655/// `inline_metadata` — optional custom metadata the caller wants to
656/// attach alongside the location keys (merged; location keys win).
657///
658/// Callers that must enforce a byte cap should use
659/// [`prepare_externalize_batch`] + [`upload_prepared`] instead, which lets
660/// them see the payload size before the upload happens.
661///
662/// `declared_schema` is the enclosing IPC stream's schema; see
663/// [`prepare_externalize_batch`].
664pub fn maybe_externalize_batch(
665    batch: &RecordBatch,
666    declared_schema: &Schema,
667    inline_metadata: Option<&Metadata>,
668    cfg: &ExternalLocationConfig,
669) -> Result<Option<(RecordBatch, Metadata)>> {
670    match prepare_externalize_batch(batch, declared_schema, inline_metadata, cfg)? {
671        None => Ok(None),
672        Some(prepared) => upload_prepared(prepared, cfg).map(Some),
673    }
674}
675
676// ---------------------------------------------------------------------------
677// Client-side: resolve pointer batches
678// ---------------------------------------------------------------------------
679
680/// Resolve a pointer batch (zero-row batch with `vgi_rpc.location`
681/// metadata) back into the original record batch. Non-pointer batches
682/// are returned untouched.
683///
684/// Returns `(resolved_batch, user_metadata)` where the location keys
685/// have been stripped from the metadata visible to the caller. A
686/// `vgi_rpc.location.fetch_ms` claim is appended so callers / access
687/// logs can observe the fetch latency.
688pub fn resolve_external_location(
689    batch: &RecordBatch,
690    metadata: &Metadata,
691    cfg: &ExternalLocationConfig,
692) -> Result<(RecordBatch, Metadata)> {
693    let Some(url) = md_get(metadata, LOCATION_KEY) else {
694        return Ok((batch.clone(), metadata.clone()));
695    };
696    (cfg.url_validator)(url)?;
697
698    let start = std::time::Instant::now();
699    // Cap the fetched (still-encoded) payload at `max_decompressed_bytes`:
700    // any well-formed compressed body is smaller than its decompressed
701    // form, so this is a safe ceiling that also bounds the uncompressed
702    // case. `decompress` enforces the post-decompression cap on top.
703    let compressed = cfg
704        .fetcher
705        .fetch(url, cfg.compression, cfg.max_decompressed_bytes)?;
706    let ipc_bytes = decompress(&compressed, cfg.compression, cfg.max_decompressed_bytes)?;
707
708    // Integrity check.
709    if let Some(expected) = md_get(metadata, LOCATION_SHA256_KEY) {
710        let actual = sha256_hex(&ipc_bytes);
711        if expected != actual.as_str() {
712            return Err(RpcError::runtime_error(format!(
713                "external location SHA-256 mismatch (expected {expected}, got {actual})"
714            )));
715        }
716    }
717    let (resolved, inner_md) = deserialize_single_batch_with_metadata(&ipc_bytes)?;
718    let fetch_ms = start.elapsed().as_secs_f64() * 1000.0;
719
720    // Start from the outer pointer's non-location keys, then overlay the inner
721    // (externalized) batch's metadata. Implementations differ on where they
722    // carry per-batch keys like `vgi_rpc.stream_state#b64`: the Rust server
723    // stamps them on the outer pointer, the Python server on the inner payload
724    // batch. Merging both (inner wins) recovers the token either way and
725    // matches Python's resolver, which uses the inner batch's metadata.
726    let mut user_md: Metadata = metadata
727        .iter()
728        .filter(|(k, _)| {
729            *k != LOCATION_KEY && *k != LOCATION_SHA256_KEY && *k != LOCATION_FETCH_MS_KEY
730        })
731        .map(|(k, v)| (k.clone(), v.clone()))
732        .collect();
733    for (k, v) in inner_md {
734        if k != LOCATION_KEY && k != LOCATION_SHA256_KEY && k != LOCATION_FETCH_MS_KEY {
735            user_md.insert(k, v);
736        }
737    }
738    user_md.insert(
739        LOCATION_FETCH_MS_KEY.to_string(),
740        format!("{:.2}", fetch_ms),
741    );
742    Ok((resolved, user_md))
743}
744
745// ---------------------------------------------------------------------------
746// In-memory test backend
747// ---------------------------------------------------------------------------
748//
749// Gated behind the `test-utils` feature so it doesn't show up on
750// crates.io / docs.rs for normal users. Internal tests + the
751// `external_integration` integration test enable the feature
752// transitively via the workspace.
753
754/// In-memory storage backend + fetcher pair; used by tests and CI.
755/// Thread-safe, no I/O. **Not for production use.**
756#[cfg(any(test, feature = "test-utils"))]
757pub struct InMemoryStorage {
758    map: std::sync::Mutex<std::collections::HashMap<String, Vec<u8>>>,
759    next_id: std::sync::atomic::AtomicU64,
760    base_url: String,
761}
762
763#[cfg(any(test, feature = "test-utils"))]
764impl InMemoryStorage {
765    pub fn new() -> Arc<Self> {
766        Arc::new(Self {
767            map: std::sync::Mutex::new(std::collections::HashMap::new()),
768            next_id: std::sync::atomic::AtomicU64::new(1),
769            base_url: "https://inmem.test/".to_string(),
770        })
771    }
772
773    pub fn len(&self) -> usize {
774        self.map.lock().unwrap().len()
775    }
776
777    pub fn is_empty(&self) -> bool {
778        self.len() == 0
779    }
780}
781
782#[cfg(any(test, feature = "test-utils"))]
783impl ExternalStorage for InMemoryStorage {
784    fn upload(&self, ipc_bytes: &[u8], _compression: Compression) -> Result<UploadResult> {
785        let id = self
786            .next_id
787            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
788        let url = format!("{}{:016x}", self.base_url, id);
789        let sha = sha256_hex(
790            // Storage receives the already-compressed bytes; the sha recorded
791            // in the pointer metadata tracks the RAW ipc bytes so the
792            // caller of upload() is responsible for that value — we only
793            // store, not hash, here. Return a placeholder hash (caller
794            // replaces it from maybe_externalize_batch).
795            ipc_bytes,
796        );
797        self.map
798            .lock()
799            .unwrap()
800            .insert(url.clone(), ipc_bytes.to_vec());
801        Ok(UploadResult { url, sha256: sha })
802    }
803}
804
805#[cfg(any(test, feature = "test-utils"))]
806impl Fetcher for InMemoryStorage {
807    fn fetch(&self, url: &str, _compression: Compression, max_bytes: usize) -> Result<Vec<u8>> {
808        let bytes = self
809            .map
810            .lock()
811            .unwrap()
812            .get(url)
813            .cloned()
814            .ok_or_else(|| RpcError::runtime_error(format!("inmem fetch miss: {url}")))?;
815        if bytes.len() > max_bytes {
816            return Err(RpcError::runtime_error(format!(
817                "inmem fetch payload {} bytes exceeds max_bytes={max_bytes}",
818                bytes.len()
819            )));
820        }
821        Ok(bytes)
822    }
823}
824
825#[cfg(test)]
826mod tests {
827    use std::sync::Arc as Ar;
828
829    use arrow_array::{Int64Array, RecordBatch};
830    use arrow_schema::{DataType, Field, Schema};
831
832    use super::*;
833
834    fn big_batch(rows: usize) -> RecordBatch {
835        let schema = Arc::new(Schema::new(vec![Field::new(
836            "value",
837            DataType::Int64,
838            false,
839        )]));
840        let col: Ar<dyn arrow_array::Array> =
841            Arc::new(Int64Array::from((0..rows as i64).collect::<Vec<_>>()));
842        RecordBatch::try_new(schema, vec![col]).unwrap()
843    }
844
845    fn cfg_with(storage: Arc<InMemoryStorage>, threshold: usize) -> ExternalLocationConfig {
846        let s: Arc<dyn ExternalStorage> = storage.clone();
847        let f: Arc<dyn Fetcher> = storage;
848        ExternalLocationConfig::new(s, f)
849            .with_threshold_bytes(threshold)
850            .with_url_validator(any_url_validator())
851    }
852
853    #[test]
854    fn small_batch_stays_inline() {
855        let storage = InMemoryStorage::new();
856        let cfg = cfg_with(storage.clone(), 1024 * 1024);
857        let batch = big_batch(10);
858        let out = maybe_externalize_batch(&batch, batch.schema().as_ref(), None, &cfg).unwrap();
859        assert!(out.is_none());
860        assert!(storage.is_empty());
861    }
862
863    #[test]
864    fn large_batch_externalizes_and_round_trips() {
865        let storage = InMemoryStorage::new();
866        let cfg = cfg_with(storage.clone(), 1024);
867        let batch = big_batch(50_000);
868
869        let (ptr, md) = maybe_externalize_batch(&batch, batch.schema().as_ref(), None, &cfg)
870            .unwrap()
871            .unwrap();
872        assert_eq!(ptr.num_rows(), 0);
873        // Pointer batch carries the original schema (zero-row); cross-language
874        // clients expect the column count to match the result schema.
875        assert_eq!(ptr.schema().fields().len(), batch.schema().fields().len());
876        assert!(md_get(&md, LOCATION_KEY).unwrap().starts_with("https://"));
877        assert_eq!(storage.len(), 1);
878
879        let (resolved, user_md) = resolve_external_location(&ptr, &md, &cfg).unwrap();
880        assert_eq!(resolved.num_rows(), batch.num_rows());
881        assert!(md_get(&user_md, LOCATION_KEY).is_none());
882        assert!(md_get(&user_md, LOCATION_FETCH_MS_KEY).is_some());
883    }
884
885    #[test]
886    fn zstd_compression_round_trip() {
887        let storage = InMemoryStorage::new();
888        let cfg = cfg_with(storage.clone(), 1024).with_compression(Compression::Zstd(3));
889        let batch = big_batch(20_000);
890        let (ptr, md) = maybe_externalize_batch(&batch, batch.schema().as_ref(), None, &cfg)
891            .unwrap()
892            .unwrap();
893        let (resolved, _) = resolve_external_location(&ptr, &md, &cfg).unwrap();
894        assert_eq!(resolved.num_rows(), batch.num_rows());
895    }
896
897    // An externalised payload is a standalone IPC stream and declares its own
898    // schema, whereas an inline batch rides a stream whose schema was declared
899    // once, up front. A batch differing from that declared schema only
900    // cosmetically — here, field nullability — is invisible inline (the writer
901    // never reconciles the two) and becomes a hard `Schema mismatch` at a peer
902    // that validates the fetched payload the moment externalisation is turned
903    // on. The declared schema must therefore travel with the payload.
904    #[test]
905    fn externalized_payload_declares_the_enclosing_stream_schema() {
906        let storage = InMemoryStorage::new();
907        let cfg = cfg_with(storage.clone(), 1024);
908        // What the stream promised: `value` is nullable.
909        let declared = Schema::new(vec![Field::new("value", DataType::Int64, true)]);
910        // What the worker emitted: same data, non-nullable field.
911        let batch = big_batch(50_000);
912        assert!(!batch.schema().field(0).is_nullable());
913
914        let (ptr, md) = maybe_externalize_batch(&batch, &declared, None, &cfg)
915            .unwrap()
916            .unwrap();
917        // The pointer the peer decodes carries the declared schema...
918        assert!(ptr.schema().field(0).is_nullable());
919        // ...and so does the payload behind it, so a validating peer sees
920        // the two agree.
921        let raw = cfg
922            .fetcher
923            .fetch(
924                md_get(&md, LOCATION_KEY).unwrap(),
925                cfg.compression,
926                cfg.max_decompressed_bytes,
927            )
928            .unwrap();
929        let mut reader = StreamReader::new(raw.as_slice()).unwrap();
930        let (fetched, _) = reader.read_next().unwrap().unwrap();
931        assert_eq!(fetched.schema().as_ref(), &declared);
932        assert_eq!(fetched.num_rows(), batch.num_rows());
933    }
934
935    // Java's uploader cannot carry dictionaries, so its port has to keep
936    // dictionary-encoded batches inline. Rust's writer emits the dictionary
937    // messages and the reader consumes them transparently, so no such
938    // carve-out is needed here — pinned so a future change to either side
939    // cannot quietly reintroduce the constraint.
940    #[test]
941    fn dictionary_encoded_batch_round_trips_externally() {
942        use arrow_array::types::Int32Type;
943        use arrow_array::{Array, DictionaryArray};
944
945        let storage = InMemoryStorage::new();
946        let cfg = cfg_with(storage.clone(), 1024);
947        let values: Vec<&str> = (0..20_000)
948            .map(|i| ["alpha", "beta", "gamma"][i % 3])
949            .collect();
950        let dict: DictionaryArray<Int32Type> = values.into_iter().collect();
951        let schema = Arc::new(Schema::new(vec![Field::new(
952            "label",
953            dict.data_type().clone(),
954            false,
955        )]));
956        let col: Ar<dyn arrow_array::Array> = Arc::new(dict);
957        let batch = RecordBatch::try_new(schema.clone(), vec![col]).unwrap();
958
959        let (ptr, md) = maybe_externalize_batch(&batch, schema.as_ref(), None, &cfg)
960            .unwrap()
961            .unwrap();
962        assert_eq!(storage.len(), 1);
963        let (resolved, _) = resolve_external_location(&ptr, &md, &cfg).unwrap();
964        assert_eq!(resolved.num_rows(), batch.num_rows());
965        assert_eq!(resolved.column(0).as_ref(), batch.column(0).as_ref());
966    }
967
968    #[test]
969    fn https_only_validator_rejects_plaintext() {
970        let storage = InMemoryStorage::new();
971        let s: Arc<dyn ExternalStorage> = storage.clone();
972        let f: Arc<dyn Fetcher> = storage;
973        let cfg = ExternalLocationConfig::new(s, f).with_threshold_bytes(0);
974        // In-memory URL is https, so build a forged metadata entry.
975        let batch = big_batch(1);
976        let mut bogus_md = Metadata::new();
977        bogus_md.insert("vgi_rpc.location".into(), "http://not-secure/x".into());
978        let err = resolve_external_location(&batch, &bogus_md, &cfg).unwrap_err();
979        assert!(err.message.contains("https://"));
980    }
981
982    #[test]
983    fn safe_https_validator_blocks_ssrf_targets() {
984        let v = safe_https_validator();
985        // Non-https.
986        assert!(v("http://example.com/x").is_err());
987        // IP-literal internal / non-routable targets.
988        assert!(v("https://169.254.169.254/latest/meta-data/").is_err());
989        assert!(v("https://127.0.0.1/").is_err());
990        assert!(v("https://10.0.0.1/").is_err());
991        assert!(v("https://192.168.1.1/").is_err());
992        assert!(v("https://[::1]/").is_err());
993        assert!(v("https://0.0.0.0/").is_err());
994        // Hostname forms of loopback.
995        assert!(v("https://localhost/x").is_err());
996        assert!(v("https://api.localhost/x").is_err());
997        // A public IP literal is allowed.
998        assert!(v("https://1.1.1.1/x").is_ok());
999    }
1000
1001    #[test]
1002    fn sha_mismatch_is_rejected() {
1003        let storage = InMemoryStorage::new();
1004        let cfg = cfg_with(storage.clone(), 1024);
1005        let batch = big_batch(10_000);
1006        let (ptr, mut md) = maybe_externalize_batch(&batch, batch.schema().as_ref(), None, &cfg)
1007            .unwrap()
1008            .unwrap();
1009        // Corrupt the recorded hash.
1010        if let Some(v) = md.get_mut(LOCATION_SHA256_KEY) {
1011            *v = "deadbeef".into();
1012        }
1013        let err = resolve_external_location(&ptr, &md, &cfg).unwrap_err();
1014        assert!(err.message.contains("SHA-256 mismatch"));
1015    }
1016}