Skip to main content

vgi_rpc/
wire.rs

1//! Low-level IPC stream helpers that preserve per-batch custom metadata.
2//!
3//! The standard `arrow-ipc` `StreamWriter` / `StreamReader` types do not
4//! expose per-message `custom_metadata`, but the vgi_rpc wire protocol
5//! relies on that field to carry `vgi_rpc.method`,
6//! `vgi_rpc.request_version`, log keys, externalisation pointers,
7//! state tokens, etc. This module hand-rolls the framing layer so the
8//! crate can depend on **stock** arrow-rs from crates.io rather than a
9//! patched fork — the published vgi-rpc crate is therefore directly
10//! installable without any `[patch.crates-io]` directives downstream.
11//!
12//! Internally we delegate column encoding / decoding to
13//! [`arrow_ipc::writer::IpcDataGenerator`] and the
14//! [`arrow_ipc::reader::read_record_batch`] / `read_dictionary`
15//! functions, and only intercept the flatbuffer `Message` wrapper to
16//! attach / extract `custom_metadata`. That keeps the code small and
17//! the on-wire bytes byte-for-byte compatible with arrow-rs's
18//! `StreamWriter`.
19//!
20//! ## DoS guard
21//!
22//! [`StreamReader::new`] pre-validates the schema-message length prefix
23//! against [`MAX_IPC_SCHEMA_BYTES`] *before* allocating; a remote
24//! client cannot trigger a multi-gigabyte alloc by sending a crafted
25//! 4-byte payload. A per-batch message body is bounded twice: an absurd
26//! `bodyLength` is refused outright against [`MAX_IPC_MESSAGE_BYTES`],
27//! and what survives that is buffered as the peer actually delivers it
28//! rather than on the strength of the claim — so the flatbuffer
29//! overshoot the fuzz harness surfaced costs a few MiB and an EOF.
30
31use std::collections::HashMap;
32use std::io::{Read, Write};
33use std::sync::Arc;
34
35use arrow_array::RecordBatch;
36use arrow_buffer::Buffer as ArrowBuffer;
37use arrow_ipc::reader as ipc_reader;
38use arrow_ipc::writer::{write_message, DictionaryTracker, IpcDataGenerator, IpcWriteOptions};
39use arrow_ipc::{convert as ipc_convert, root_as_message, MessageHeader};
40use arrow_schema::{Schema, SchemaRef};
41use flatbuffers::FlatBufferBuilder;
42
43use crate::errors::{Result, RpcError};
44
45/// Per-batch metadata pairs. Order is not preserved across
46/// serialisation; that matches Python's `RecordBatch.custom_metadata`
47/// semantics.
48pub type Metadata = HashMap<String, String>;
49
50/// Look up a key in a [`Metadata`] map, returning the value as `&str`.
51#[inline]
52pub fn md_get<'a>(md: &'a Metadata, key: &str) -> Option<&'a str> {
53    md.get(key).map(String::as_str)
54}
55
56/// Maximum permitted size, in bytes, of the schema-message flatbuffer
57/// at the head of an IPC stream. Schemas are typically tens to
58/// hundreds of bytes; 16 MiB is gracious headroom that still rejects
59/// the crafted 4-byte input `[0x1A, 0x2C, 0xF5, 0x2C]` that
60/// `fuzz/wire_stream_reader` discovered would OOM the process by
61/// claiming a ~720 MB schema. Applies to the *schema* message length
62/// prefix on the wire.
63pub const MAX_IPC_SCHEMA_BYTES: usize = 16 * 1024 * 1024;
64
65/// Maximum permitted total size of any per-batch IPC message (header
66/// flatbuffer + body bytes) — the sanity ceiling that refuses the
67/// `bodyLength = 0x4000000100000` overshoot the fuzz harness surfaced.
68///
69/// This used to be 256 MiB, which also made it a hard limit on
70/// *legitimate* payloads: a >2 GiB `large_binary` round-trip is well
71/// within what the Python reference accepts, and the
72/// `large_payload.echo_binary_over_int32_max` conformance test sends
73/// exactly that. Refusing it was a conformance defect, not a defence.
74///
75/// The ceiling no longer carries the anti-OOM job on its own —
76/// `read_message_bytes` grows the body buffer from the bytes that
77/// actually arrive (see `BODY_PREALLOC_LIMIT`), so a crafted length
78/// costs a few MiB and an EOF rather than the amount it claimed.
79/// `u32::MAX` keeps the constant expressible on 32-bit targets, where
80/// it saturates to `usize::MAX` and the allocation guard is the only
81/// one that can meaningfully apply anyway.
82pub const MAX_IPC_MESSAGE_BYTES: usize = u32::MAX as usize;
83
84/// Bytes reserved up front for a message body before any of it has
85/// arrived. Beyond this the buffer grows amortised as the peer actually
86/// delivers, so a header claiming a petabyte cannot turn a 4-byte frame
87/// into a multi-gigabyte allocation.
88///
89/// Growing rather than pre-sizing also keeps the *read* side out of the
90/// trouble [`ChunkedWriter`] fixes on the write side: `impl Read for
91/// &UnixStream` calls `recv(2)` with the length unclamped, exactly as
92/// its `Write` counterpart calls `send(2)`, so handing it a single spare
93/// region past 2 GiB would earn the same `EINVAL`. Doubling from here
94/// means the largest slice ever offered is about half the body — a
95/// 1 GiB read for a 2 GiB message — and never reaches `INT_MAX`.
96const BODY_PREALLOC_LIMIT: usize = 8 * 1024 * 1024;
97
98// ---------------------------------------------------------------------------
99// Writer
100// ---------------------------------------------------------------------------
101
102const CONTINUATION_MARKER: [u8; 4] = [0xFF, 0xFF, 0xFF, 0xFF];
103
104/// Largest slice offered to a single underlying `write` call.
105///
106/// Sits well under `INT_MAX`, which is where the two macOS failure modes
107/// live (see [`ChunkedWriter`]). Slicing is free, so the whole cost of
108/// the clamp is one extra syscall per gigabyte.
109const MAX_WRITE_CHUNK: usize = 1 << 30; // 1 GiB
110
111/// Clamp every `write` to [`MAX_WRITE_CHUNK`] so a large payload
112/// survives the syscall underneath.
113///
114/// Every raw transport writer here is unbuffered on purpose, so one
115/// `write` maps onto one `write(2)` / `send(2)`. That syscall is not
116/// obliged to accept the whole buffer, and above 2 GiB on macOS it
117/// refuses to — in one of two different ways depending on what is
118/// underneath:
119///
120/// * **pipes** return a short count of exactly `INT_MAX` with *no
121///   error*, so a writer that trusts the return value silently drops
122///   the tail and the peer blocks forever waiting for bytes the Arrow
123///   IPC header promised. The symptom is a deadlock, not an exception.
124/// * **sockets** (Unix domain and TCP) fail outright with `EINVAL`.
125///
126/// Both halves are needed. `io::Write::write_all` already loops on the
127/// returned count, and `std`'s file-descriptor writer clamps to
128/// `INT_MAX` for us — but `impl Write for &UnixStream` and `&TcpStream`
129/// do *not* go through it. They call `send(2)` with
130/// `cmp::min(buf.len(), wrlen_t::MAX)`, and `wrlen_t` is `usize` on
131/// unix, so the length reaches the kernel unclamped and a >2 GiB Arrow
132/// IPC body dies with `EINVAL`. Clamping here is what makes the socket
133/// transports behave like the pipe ones.
134///
135/// Deliberately does *not* forward `write_vectored`: the default
136/// implementation routes back through `write`, which is where the clamp
137/// lives.
138struct ChunkedWriter<W: Write> {
139    inner: W,
140    limit: usize,
141}
142
143impl<W: Write> ChunkedWriter<W> {
144    fn new(inner: W) -> Self {
145        Self {
146            inner,
147            limit: MAX_WRITE_CHUNK,
148        }
149    }
150
151    /// Same behaviour with a smaller clamp, so the chunking can be
152    /// exercised without allocating a gigabyte.
153    #[cfg(test)]
154    fn with_limit(inner: W, limit: usize) -> Self {
155        Self { inner, limit }
156    }
157
158    fn get_mut(&mut self) -> &mut W {
159        &mut self.inner
160    }
161}
162
163impl<W: Write> Write for ChunkedWriter<W> {
164    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
165        let end = buf.len().min(self.limit);
166        self.inner.write(&buf[..end])
167    }
168
169    fn flush(&mut self) -> std::io::Result<()> {
170        self.inner.flush()
171    }
172}
173
174/// A streaming IPC writer that supports per-batch custom metadata.
175///
176/// The byte sequence written for a complete stream is:
177///   `SchemaMessage → [DictionaryMessage]* → [RecordBatchMessage]* → EOS(4xFF 0x00)`.
178///
179/// Each call to [`write`](Self::write) emits one record-batch message
180/// (preceded by any newly-needed dictionary messages) with its
181/// `custom_metadata` attached at the IPC Message level.
182///
183/// Every byte the crate puts on a transport passes through here, which
184/// is why the `ChunkedWriter` clamp lives at this level rather than in
185/// each transport: `serve` takes an arbitrary `W`, so a worker that
186/// hands it a raw socket gets the same protection as `serve_unix` does.
187pub struct StreamWriter<W: Write> {
188    writer: ChunkedWriter<W>,
189    schema: SchemaRef,
190    opts: IpcWriteOptions,
191    data_gen: IpcDataGenerator,
192    dict_tracker: DictionaryTracker,
193    finished: bool,
194    /// Reused across `write` calls so the per-batch metadata repack doesn't
195    /// allocate a fresh flatbuffer builder (and its internal vectors) each
196    /// time. `reset()` before each use; the buffer's capacity is retained.
197    fbb: FlatBufferBuilder<'static>,
198}
199
200impl<W: Write> StreamWriter<W> {
201    /// Create a new writer and emit the schema message.
202    pub fn new(writer: W, schema: &Schema) -> Result<Self> {
203        let mut writer = ChunkedWriter::new(writer);
204        let opts = IpcWriteOptions::default();
205        let data_gen = IpcDataGenerator::default();
206        let mut dict_tracker = DictionaryTracker::new(false);
207        let encoded =
208            data_gen.schema_to_bytes_with_dictionary_tracker(schema, &mut dict_tracker, &opts);
209        write_message(&mut writer, encoded, &opts)?;
210        Ok(Self {
211            writer,
212            schema: Arc::new(schema.clone()),
213            opts,
214            data_gen,
215            dict_tracker,
216            finished: false,
217            fbb: FlatBufferBuilder::new(),
218        })
219    }
220
221    /// Write one RecordBatch carrying optional `metadata` as the IPC
222    /// Message-level `custom_metadata` field. Pass `None` to omit the
223    /// field (saves a few bytes per message).
224    pub fn write(&mut self, batch: &RecordBatch, metadata: Option<&Metadata>) -> Result<()> {
225        if self.finished {
226            return Err(RpcError::new("IOError", "writer already finished"));
227        }
228        let mut ctx = Default::default();
229        let (dicts, data) = self
230            .data_gen
231            .encode(batch, &mut self.dict_tracker, &self.opts, &mut ctx)
232            .map_err(RpcError::from)?;
233        for d in dicts {
234            write_message(&mut self.writer, d, &self.opts).map_err(RpcError::from)?;
235        }
236        if let Some(md) = metadata.filter(|m| !m.is_empty()) {
237            self.fbb.reset();
238            repack_record_batch_message_with_metadata(&mut self.fbb, &data.ipc_message, md)?;
239            let encoded = arrow_ipc::writer::EncodedData {
240                ipc_message: self.fbb.finished_data().to_vec(),
241                arrow_data: data.arrow_data,
242            };
243            write_message(&mut self.writer, encoded, &self.opts).map_err(RpcError::from)?;
244        } else {
245            write_message(&mut self.writer, data, &self.opts).map_err(RpcError::from)?;
246        }
247        Ok(())
248    }
249
250    /// Return the schema this writer was opened with.
251    pub fn schema(&self) -> SchemaRef {
252        self.schema.clone()
253    }
254
255    /// Write the EOS continuation marker. Idempotent.
256    pub fn finish(&mut self) -> Result<()> {
257        if self.finished {
258            return Ok(());
259        }
260        self.writer.write_all(&CONTINUATION_MARKER)?;
261        self.writer.write_all(&[0u8; 4])?;
262        self.writer.flush()?;
263        self.finished = true;
264        Ok(())
265    }
266
267    /// Flush the underlying writer.
268    pub fn flush(&mut self) -> Result<()> {
269        self.writer.flush()?;
270        Ok(())
271    }
272
273    pub fn get_mut(&mut self) -> &mut W {
274        self.writer.get_mut()
275    }
276}
277
278impl<W: Write> Drop for StreamWriter<W> {
279    fn drop(&mut self) {
280        let _ = self.finish();
281    }
282}
283
284/// Rebuild a Message flatbuffer with `custom_metadata` added,
285/// preserving the embedded RecordBatch header unchanged.
286fn repack_record_batch_message_with_metadata(
287    fbb: &mut FlatBufferBuilder<'static>,
288    msg_bytes: &[u8],
289    metadata: &Metadata,
290) -> Result<()> {
291    use arrow_ipc::{KeyValue, KeyValueArgs, MessageBuilder, RecordBatchBuilder};
292
293    let msg = root_as_message(msg_bytes)
294        .map_err(|e| RpcError::new("IPC", format!("parsing message: {e}")))?;
295    let version = msg.version();
296    let header_type = msg.header_type();
297    let body_length = msg.bodyLength();
298    if header_type != MessageHeader::RecordBatch {
299        return Err(RpcError::new(
300            "IPC",
301            format!("repack expected RecordBatch header, got {header_type:?}"),
302        ));
303    }
304    let rb = msg
305        .header_as_record_batch()
306        .ok_or_else(|| RpcError::new("IPC", "missing RecordBatch header"))?;
307
308    // The caller has already `reset()` the builder. Feed the field-node and
309    // buffer descriptors straight from the source flatbuffer vectors via
310    // `create_vector_from_iter` — no throwaway `Vec` per batch.
311    let src_nodes = rb
312        .nodes()
313        .ok_or_else(|| RpcError::new("IPC", "RecordBatch missing nodes"))?;
314    let nodes_vec = fbb.create_vector_from_iter(src_nodes.iter());
315
316    let src_buffers = rb
317        .buffers()
318        .ok_or_else(|| RpcError::new("IPC", "RecordBatch missing buffers"))?;
319    let buffers_vec = fbb.create_vector_from_iter(src_buffers.iter());
320
321    let variadic_vec = rb
322        .variadicBufferCounts()
323        .map(|v| fbb.create_vector_from_iter(v.iter()));
324
325    let new_rb = {
326        let mut b = RecordBatchBuilder::new(fbb);
327        b.add_length(rb.length());
328        b.add_nodes(nodes_vec);
329        b.add_buffers(buffers_vec);
330        if let Some(v) = variadic_vec {
331            b.add_variadicBufferCounts(v);
332        }
333        // Note: we don't carry compression here; the conformance worker
334        // does not enable IPC batch compression, so this is safe.
335        b.finish()
336    };
337
338    // Build custom_metadata vector. Order matches HashMap iteration —
339    // not stable, but that matches both upstream arrow-ipc and Python
340    // `RecordBatch.custom_metadata` semantics.
341    let kvs: Vec<_> = metadata
342        .iter()
343        .map(|(k, v)| {
344            let k_off = fbb.create_string(k);
345            let v_off = fbb.create_string(v);
346            KeyValue::create(
347                fbb,
348                &KeyValueArgs {
349                    key: Some(k_off),
350                    value: Some(v_off),
351                },
352            )
353        })
354        .collect();
355    let md_vec = fbb.create_vector(&kvs);
356
357    let mut mb = MessageBuilder::new(fbb);
358    mb.add_version(version);
359    mb.add_header_type(header_type);
360    mb.add_header(new_rb.as_union_value());
361    mb.add_bodyLength(body_length);
362    mb.add_custom_metadata(md_vec);
363    let m = mb.finish();
364    fbb.finish(m, None);
365    Ok(())
366}
367
368// ---------------------------------------------------------------------------
369// Reader
370// ---------------------------------------------------------------------------
371
372/// A streaming IPC reader that surfaces per-message custom metadata.
373///
374/// [`read_next`](Self::read_next) returns `Some((batch, metadata))`
375/// for each RecordBatch message and `None` on end-of-stream.
376/// Dictionary and schema messages are consumed transparently.
377pub struct StreamReader<R: Read> {
378    reader: R,
379    schema: SchemaRef,
380    dictionaries: HashMap<i64, arrow_array::ArrayRef>,
381    finished: bool,
382    /// When `Some`, every read batch is rewrapped with this relaxed
383    /// schema before being returned to the caller (used by the
384    /// conformance worker to accept Python's nullable-flag-lying
385    /// `ArrowSerializableDataclass` outputs).
386    relaxed_schema: Option<SchemaRef>,
387}
388
389impl<R: Read> StreamReader<R> {
390    /// Create a new reader and consume the schema message.
391    ///
392    /// The schema-message length prefix is validated against
393    /// [`MAX_IPC_SCHEMA_BYTES`] *before* allocating, so a remote
394    /// client cannot trigger a multi-gigabyte alloc by sending a
395    /// crafted short payload.
396    pub fn new(mut reader: R) -> Result<Self> {
397        let msg = read_message_bytes(&mut reader, MAX_IPC_SCHEMA_BYTES)?
398            .ok_or_else(|| RpcError::new("IPC", "empty IPC stream (no schema)"))?;
399        let msg_fb = root_as_message(&msg.message_bytes)
400            .map_err(|e| RpcError::new("IPC", format!("parse schema message: {e}")))?;
401        if msg_fb.header_type() != MessageHeader::Schema {
402            return Err(RpcError::new(
403                "IPC",
404                format!("expected Schema, got {:?}", msg_fb.header_type()),
405            ));
406        }
407        let ipc_schema = msg_fb
408            .header_as_schema()
409            .ok_or_else(|| RpcError::new("IPC", "bad schema header"))?;
410        // A legitimate Arrow Schema message always carries a `fields` vector
411        // (possibly empty). When it's absent, `fb_to_schema` does
412        // `fb.fields().unwrap()` and panics — under cargo-fuzz's `panic=abort`
413        // that aborts the process before any `catch_unwind` can intercept.
414        // Reject the malformed frame explicitly here. (crates.io arrow tolerates
415        // this; the pinned arrow-rs fork the fuzz harness uses does not.)
416        if ipc_schema.fields().is_none() {
417            return Err(RpcError::new("IPC", "schema message has no fields vector"));
418        }
419        // `fb_to_schema` still `unwrap()`s other optional members while walking
420        // field types; keep the `catch_unwind` net (matching record-batch
421        // decode) so any residual panic becomes a clean `RpcError` in normal
422        // (panic=unwind) builds rather than escaping the reader.
423        let schema = decode_guard("schema message", || ipc_convert::fb_to_schema(ipc_schema))?;
424        Ok(Self {
425            reader,
426            schema: Arc::new(schema),
427            dictionaries: HashMap::new(),
428            finished: false,
429            relaxed_schema: None,
430        })
431    }
432
433    /// Get the schema of the stream (relaxed schema, if relaxation was
434    /// requested).
435    pub fn schema(&self) -> SchemaRef {
436        self.relaxed_schema
437            .clone()
438            .unwrap_or_else(|| self.schema.clone())
439    }
440
441    /// Promote every field in the stream's schema to `nullable = true`,
442    /// recursively (lists, structs, fixed-size lists). Use when a
443    /// producer declares a field non-nullable but legitimately sends
444    /// nulls — e.g. Python's `ArrowSerializableDataclass` for
445    /// `Annotated[T | None, ArrowType(...)]`.
446    pub fn relax_nullability(mut self) -> Self {
447        self.relaxed_schema = Some(Arc::new(relax_schema_nullability(self.schema.as_ref())));
448        self
449    }
450
451    /// Read the next record batch, or `None` on end-of-stream.
452    /// Returns `(batch, metadata)` where `metadata` carries the IPC
453    /// Message-level `custom_metadata` (empty when the producer
454    /// omitted the field).
455    pub fn read_next(&mut self) -> Result<Option<(RecordBatch, Metadata)>> {
456        if self.finished {
457            return Ok(None);
458        }
459        loop {
460            let msg = match read_message_bytes(&mut self.reader, MAX_IPC_MESSAGE_BYTES)? {
461                Some(m) => m,
462                None => {
463                    self.finished = true;
464                    return Ok(None);
465                }
466            };
467            let msg_fb = root_as_message(&msg.message_bytes)
468                .map_err(|e| RpcError::new("IPC", format!("parse message: {e}")))?;
469            let version = msg_fb.version();
470            match msg_fb.header_type() {
471                MessageHeader::DictionaryBatch => {
472                    let dict = msg_fb
473                        .header_as_dictionary_batch()
474                        .ok_or_else(|| RpcError::new("IPC", "bad dictionary header"))?;
475                    let body_buf = ArrowBuffer::from_vec(msg.body);
476                    // Reject buffer descriptors that point outside the
477                    // body *before* handing them to arrow-ipc, which
478                    // would otherwise panic on an out-of-bounds slice.
479                    if let Some(data) = dict.data() {
480                        validate_record_batch_buffers(&data, body_buf.len())?;
481                    }
482                    // arrow-ipc's decoder still has internal invariants
483                    // we don't re-check; `catch_unwind` is the backstop
484                    // that turns any residual panic into a clean error.
485                    decode_guard("dictionary batch", || {
486                        ipc_reader::read_dictionary(
487                            &body_buf,
488                            dict,
489                            self.schema.as_ref(),
490                            &mut self.dictionaries,
491                            &version,
492                        )
493                    })?
494                    .map_err(RpcError::from)?;
495                }
496                MessageHeader::RecordBatch => {
497                    let rb_fb = msg_fb
498                        .header_as_record_batch()
499                        .ok_or_else(|| RpcError::new("IPC", "bad record batch header"))?;
500                    let body_buf = ArrowBuffer::from_vec(msg.body);
501                    validate_record_batch_buffers(&rb_fb, body_buf.len())?;
502                    // When relaxation is in effect, feed the relaxed
503                    // schema directly to `read_record_batch` so its
504                    // validation accepts the legitimate null buffers
505                    // a producer (e.g. Python
506                    // `ArrowSerializableDataclass`) emits for fields
507                    // it declared `nullable=false`.
508                    let decode_schema = self
509                        .relaxed_schema
510                        .clone()
511                        .unwrap_or_else(|| self.schema.clone());
512                    let batch = decode_guard("record batch", || {
513                        ipc_reader::read_record_batch(
514                            &body_buf,
515                            rb_fb,
516                            decode_schema,
517                            &self.dictionaries,
518                            None,
519                            &version,
520                        )
521                    })?
522                    .map_err(RpcError::from)?;
523                    let metadata = parse_custom_metadata(&msg_fb);
524                    return Ok(Some((batch, metadata)));
525                }
526                MessageHeader::Schema => {
527                    return Err(RpcError::new("IPC", "unexpected schema message mid-stream"));
528                }
529                MessageHeader::NONE => continue,
530                other => {
531                    return Err(RpcError::new(
532                        "IPC",
533                        format!("unsupported message type {other:?}"),
534                    ));
535                }
536            }
537        }
538    }
539
540    /// Drain and discard any remaining messages.
541    pub fn drain(&mut self) -> Result<()> {
542        while self.read_next()?.is_some() {}
543        Ok(())
544    }
545
546    pub fn get_mut(&mut self) -> &mut R {
547        &mut self.reader
548    }
549}
550
551fn parse_custom_metadata(msg: &arrow_ipc::Message) -> Metadata {
552    let Some(md) = msg.custom_metadata() else {
553        return Metadata::new();
554    };
555    // Size the map to the known key count so it doesn't rehash while filling.
556    let mut out = Metadata::with_capacity(md.len());
557    for kv in md.iter() {
558        let k = kv.key().unwrap_or("").to_string();
559        let v = kv.value().unwrap_or("").to_string();
560        out.insert(k, v);
561    }
562    out
563}
564
565/// Validate that every `(offset, length)` buffer descriptor in an IPC
566/// record-batch header references a region wholly inside the message
567/// body. arrow-ipc's column decoders index into the body using these
568/// descriptors verbatim and will panic (slice out-of-bounds / arithmetic
569/// overflow) on a crafted frame whose descriptors are inconsistent with
570/// the body it shipped. Catching that here turns a hostile frame into a
571/// clean `RpcError` instead of a thread panic.
572fn validate_record_batch_buffers(rb: &arrow_ipc::RecordBatch, body_len: usize) -> Result<()> {
573    if let Some(buffers) = rb.buffers() {
574        for buf in buffers.iter() {
575            let offset = buf.offset();
576            let length = buf.length();
577            if offset < 0 || length < 0 {
578                return Err(RpcError::new("IPC", "negative IPC buffer descriptor"));
579            }
580            let end = (offset as u64)
581                .checked_add(length as u64)
582                .ok_or_else(|| RpcError::new("IPC", "IPC buffer descriptor overflows"))?;
583            if end > body_len as u64 {
584                return Err(RpcError::new(
585                    "IPC",
586                    "IPC buffer descriptor exceeds message body",
587                ));
588            }
589        }
590    }
591    Ok(())
592}
593
594/// Run an arrow-ipc decode call, converting any panic into a clean
595/// `RpcError`. The descriptor pre-validation above catches the common
596/// crafted-frame cases; this is the defence-in-depth net for any other
597/// internal arrow-ipc invariant a hostile frame might trip.
598fn decode_guard<T>(what: &str, f: impl FnOnce() -> T) -> Result<T> {
599    std::panic::catch_unwind(std::panic::AssertUnwindSafe(f))
600        .map_err(|_| RpcError::new("IPC", format!("panic decoding {what} (malformed frame)")))
601}
602
603struct RawMessage {
604    message_bytes: Vec<u8>,
605    body: Vec<u8>,
606}
607
608fn read_exact(r: &mut impl Read, buf: &mut [u8]) -> Result<bool> {
609    let mut read = 0;
610    while read < buf.len() {
611        match r.read(&mut buf[read..]) {
612            Ok(0) => {
613                if read == 0 {
614                    return Ok(false);
615                }
616                return Err(RpcError::new("IOError", "unexpected EOF in IPC message"));
617            }
618            Ok(n) => read += n,
619            Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
620            Err(e) => return Err(e.into()),
621        }
622    }
623    Ok(true)
624}
625
626/// Read one IPC message off `r`, capping the header and body at
627/// `max_bytes` so a crafted length prefix or flatbuffer
628/// `bodyLength` cannot trigger an unbounded allocation.
629///
630/// The header flatbuffer has to be buffered whole before it can be
631/// parsed, so its length prefix is checked against `max_bytes` and
632/// allocated outright. The body is not: it is grown from the bytes the
633/// peer actually delivers, so the ceiling can be generous enough for a
634/// legitimate multi-gigabyte batch without a lying `bodyLength` costing
635/// more than [`BODY_PREALLOC_LIMIT`] and an EOF.
636fn read_message_bytes(r: &mut impl Read, max_bytes: usize) -> Result<Option<RawMessage>> {
637    let mut prefix = [0u8; 4];
638    if !read_exact(r, &mut prefix)? {
639        return Ok(None);
640    }
641    let size_bytes = if prefix == CONTINUATION_MARKER {
642        let mut sb = [0u8; 4];
643        if !read_exact(r, &mut sb)? {
644            return Ok(None);
645        }
646        sb
647    } else {
648        prefix
649    };
650    let size = u32::from_le_bytes(size_bytes) as usize;
651    if size == 0 {
652        // EOS
653        return Ok(None);
654    }
655    if size > max_bytes {
656        return Err(RpcError::new(
657            "IPC",
658            format!(
659                "IPC message header length {size} bytes exceeds cap {max_bytes} — \
660                 refusing to allocate before parsing"
661            ),
662        ));
663    }
664    let mut message_bytes = vec![0u8; size];
665    if !read_exact(r, &mut message_bytes)? {
666        return Err(RpcError::new("IOError", "unexpected EOF in message body"));
667    }
668    // Parse just enough to learn the body length, then refuse an absurd
669    // claim outright. This blocks the `bodyLength = 1 TB` attack vector
670    // even when the header itself is small.
671    let msg = root_as_message(&message_bytes)
672        .map_err(|e| RpcError::new("IPC", format!("parse message header: {e}")))?;
673    let body_length_signed = msg.bodyLength();
674    if body_length_signed < 0 {
675        return Err(RpcError::new(
676            "IPC",
677            format!("IPC message has negative bodyLength ({body_length_signed})"),
678        ));
679    }
680    // Compare in u64: on a 32-bit target the claim can exceed anything
681    // `usize` can hold, and truncating first would let it wrap under the
682    // cap.
683    if body_length_signed as u64 > max_bytes as u64 {
684        return Err(RpcError::new(
685            "IPC",
686            format!(
687                "IPC message bodyLength {body_length_signed} bytes exceeds cap {max_bytes} — \
688                 refusing to allocate before parsing"
689            ),
690        ));
691    }
692    let body_length = body_length_signed as usize;
693    // Reserve only what a normal batch needs; past that the buffer grows
694    // as the bytes arrive, so the peer pays for the size it claimed
695    // before we do.
696    let mut body = Vec::with_capacity(body_length.min(BODY_PREALLOC_LIMIT));
697    if body_length > 0 {
698        let read = (&mut *r)
699            .take(body_length as u64)
700            .read_to_end(&mut body)
701            .map_err(RpcError::from)?;
702        if read != body_length {
703            return Err(RpcError::new("IOError", "unexpected EOF in message body"));
704        }
705    }
706    Ok(Some(RawMessage {
707        message_bytes,
708        body,
709    }))
710}
711
712// ---------------------------------------------------------------------------
713// Utilities
714// ---------------------------------------------------------------------------
715
716/// Serialize one record batch as a complete IPC stream
717/// (schema + batch + EOS), with optional custom metadata on the batch.
718pub fn write_one_batch(batch: &RecordBatch, metadata: Option<&Metadata>) -> Result<Vec<u8>> {
719    write_one_batch_as(batch, batch.schema().as_ref(), metadata)
720}
721
722/// Like [`write_one_batch`] but declares `schema` on the stream instead of
723/// the batch's own schema, writing the batch's buffers unchanged.
724///
725/// [`StreamWriter::write`] never reconciles a batch against the schema its
726/// stream was opened with — it encodes the buffers and the reader decodes
727/// them under the declared schema. So a batch that differs from its
728/// enclosing stream's schema only cosmetically (field nullability,
729/// dictionary encoding, schema-level metadata) round-trips invisibly while
730/// it stays inline.
731///
732/// That stops being true the moment the batch is lifted onto a *standalone*
733/// stream, as external-location payloads are: the payload declares its own
734/// schema, and a peer that validates it against the schema it was promised
735/// (the enclosing stream's) sees a hard mismatch over a difference that
736/// never mattered before. Passing the enclosing schema here keeps the two
737/// delivery routes indistinguishable.
738///
739/// Deliberately not a cast: the buffers are emitted as-is, so this cannot
740/// silently do nothing the way an "equivalent schemas" fast path in a cast
741/// helper would, and it cannot change the bytes either.
742pub fn write_one_batch_as(
743    batch: &RecordBatch,
744    schema: &Schema,
745    metadata: Option<&Metadata>,
746) -> Result<Vec<u8>> {
747    let mut buf = Vec::new();
748    {
749        let mut w = StreamWriter::new(&mut buf, schema)?;
750        w.write(batch, metadata)?;
751        w.finish()?;
752    }
753    Ok(buf)
754}
755
756/// Lowercase hex encoding of a byte slice. Internal helper — use the
757/// `hex` crate from your application code.
758// Only the http/external/mtls modules call this; unused in a minimal
759// (macros-only) wasm build, so suppress the conditional dead-code warning.
760#[allow(dead_code)]
761pub(crate) fn bytes_to_hex(bytes: &[u8]) -> String {
762    const HEX: &[u8; 16] = b"0123456789abcdef";
763    let mut out = String::with_capacity(bytes.len() * 2);
764    for b in bytes {
765        out.push(HEX[(b >> 4) as usize] as char);
766        out.push(HEX[(b & 0x0f) as usize] as char);
767    }
768    out
769}
770
771fn relax_field_nullability(f: &arrow_schema::Field) -> arrow_schema::Field {
772    use arrow_schema::DataType;
773    let dt = match f.data_type() {
774        DataType::List(inner) => DataType::List(Arc::new(relax_field_nullability(inner))),
775        DataType::LargeList(inner) => DataType::LargeList(Arc::new(relax_field_nullability(inner))),
776        DataType::FixedSizeList(inner, n) => {
777            DataType::FixedSizeList(Arc::new(relax_field_nullability(inner)), *n)
778        }
779        DataType::Struct(fields) => DataType::Struct(
780            fields
781                .iter()
782                .map(|child| Arc::new(relax_field_nullability(child)))
783                .collect(),
784        ),
785        // Map: leave the entries struct alone (Arrow requires
786        // entries/keys to be non-nullable); leaf nullability inside
787        // the values child is preserved by the original schema.
788        other => other.clone(),
789    };
790    #[allow(deprecated)]
791    let new_field = if let DataType::Dictionary(_, _) = f.data_type() {
792        arrow_schema::Field::new_dict(
793            f.name(),
794            dt,
795            true,
796            f.dict_id().unwrap_or(0),
797            f.dict_is_ordered().unwrap_or(false),
798        )
799    } else {
800        arrow_schema::Field::new(f.name(), dt, true)
801    };
802    new_field.with_metadata(f.metadata().clone())
803}
804
805fn relax_schema_nullability(s: &Schema) -> Schema {
806    let new_fields: Vec<arrow_schema::Field> = s
807        .fields()
808        .iter()
809        .map(|f| relax_field_nullability(f))
810        .collect();
811    Schema::new_with_metadata(new_fields, s.metadata().clone())
812}
813
814/// Build a zero-row `RecordBatch` matching the given schema.
815pub fn empty_batch(schema: &Schema) -> Result<RecordBatch> {
816    use arrow_array::array::new_empty_array;
817    use arrow_array::RecordBatchOptions;
818    let cols: Vec<arrow_array::ArrayRef> = schema
819        .fields()
820        .iter()
821        .map(|f| new_empty_array(f.data_type()))
822        .collect();
823    RecordBatch::try_new_with_options(
824        Arc::new(schema.clone()),
825        cols,
826        &RecordBatchOptions::new().with_row_count(Some(0)),
827    )
828    .map_err(RpcError::from)
829}
830
831#[cfg(test)]
832mod tests {
833    use super::*;
834    use arrow_array::{Int64Array, StringArray};
835    use arrow_schema::{DataType, Field};
836
837    /// Records what each `write` call was *offered* and honours a
838    /// caller-chosen short-count, so both halves of the large-payload
839    /// contract can be observed: the clamp and the retry.
840    struct SabotageWriter {
841        offered: Vec<usize>,
842        accept: usize,
843        sink: Vec<u8>,
844    }
845
846    impl Write for SabotageWriter {
847        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
848            self.offered.push(buf.len());
849            let n = buf.len().min(self.accept);
850            self.sink.extend_from_slice(&buf[..n]);
851            Ok(n)
852        }
853        fn flush(&mut self) -> std::io::Result<()> {
854            Ok(())
855        }
856    }
857
858    #[test]
859    fn chunked_writer_clamps_and_retries() {
860        // A macOS pipe answers a >2 GiB write with a short count and no
861        // error; a macOS socket answers it with EINVAL. Surviving both
862        // needs the clamp *and* the loop, so assert both here rather
863        // than trusting `write_all` alone.
864        let mut w = ChunkedWriter::with_limit(
865            SabotageWriter {
866                offered: Vec::new(),
867                accept: 3,
868                sink: Vec::new(),
869            },
870            8,
871        );
872        let payload: Vec<u8> = (0..50u8).collect();
873        w.write_all(&payload).unwrap();
874        let inner = w.get_mut();
875        assert!(
876            inner.offered.iter().all(|n| *n <= 8),
877            "a write was offered more than the clamp: {:?}",
878            inner.offered
879        );
880        assert_eq!(inner.sink, payload, "short writes lost bytes");
881    }
882
883    #[test]
884    fn write_chunk_stays_under_int_max() {
885        // The clamp is only worth anything if it lands below the size at
886        // which macOS starts rejecting or truncating.
887        assert!(MAX_WRITE_CHUNK < i32::MAX as usize);
888    }
889
890    #[test]
891    fn oversized_body_claim_costs_nothing_to_refuse() {
892        // The ceiling is generous enough for a legitimate multi-gigabyte
893        // batch, so the flatbuffer overshoot the fuzzer found has to be
894        // refused by the cap and not by a lucky allocation failure.
895        assert!(MAX_IPC_MESSAGE_BYTES as u64 > (1u64 << 31) + 1);
896        assert!((0x4000000100000u64) > MAX_IPC_MESSAGE_BYTES as u64);
897    }
898
899    #[test]
900    fn roundtrip_with_metadata() {
901        let schema = Schema::new(vec![
902            Field::new("idx", DataType::Int64, false),
903            Field::new("name", DataType::Utf8, false),
904        ]);
905        let batch = RecordBatch::try_new(
906            Arc::new(schema.clone()),
907            vec![
908                Arc::new(Int64Array::from(vec![1, 2, 3])) as _,
909                Arc::new(StringArray::from(vec!["a", "b", "c"])) as _,
910            ],
911        )
912        .unwrap();
913
914        let mut buf: Vec<u8> = Vec::new();
915        {
916            let mut w = StreamWriter::new(&mut buf, &schema).unwrap();
917            let mut md = Metadata::new();
918            md.insert("vgi_rpc.method".into(), "echo_string".into());
919            w.write(&batch, Some(&md)).unwrap();
920            w.finish().unwrap();
921        }
922
923        let mut r = StreamReader::new(buf.as_slice()).unwrap();
924        let (rb, md) = r.read_next().unwrap().expect("batch");
925        assert_eq!(rb.num_rows(), 3);
926        assert_eq!(md_get(&md, "vgi_rpc.method"), Some("echo_string"));
927        assert!(r.read_next().unwrap().is_none());
928    }
929
930    #[test]
931    fn zero_row_metadata_only() {
932        let schema = Schema::empty();
933        let batch = empty_batch(&schema).unwrap();
934
935        let mut buf: Vec<u8> = Vec::new();
936        {
937            let mut w = StreamWriter::new(&mut buf, &schema).unwrap();
938            let mut md = Metadata::new();
939            md.insert("vgi_rpc.log_level".into(), "INFO".into());
940            w.write(&batch, Some(&md)).unwrap();
941            w.finish().unwrap();
942        }
943        let mut r = StreamReader::new(buf.as_slice()).unwrap();
944        let (rb, md) = r.read_next().unwrap().expect("batch");
945        assert_eq!(rb.num_rows(), 0);
946        assert_eq!(md_get(&md, "vgi_rpc.log_level"), Some("INFO"));
947    }
948
949    #[test]
950    fn rejects_oversize_schema_length_prefix() {
951        // The 4-byte payload `[0x1A, 0x2C, 0xF5, 0x2C]` parsed LE
952        // claims ~720 MB of schema-message body — must be refused
953        // before any allocation.
954        let bomb: &[u8] = &[0x1A, 0x2C, 0xF5, 0x2C];
955        let err = StreamReader::new(bomb).err().expect("must reject");
956        assert!(
957            err.message.contains("exceeds cap"),
958            "unexpected error: {err:?}"
959        );
960    }
961
962    #[test]
963    fn rejects_oversize_message_bodylength() {
964        // Encode a tiny but well-formed schema then send a record-
965        // batch message whose flatbuffer claims a multi-GB
966        // `bodyLength` — must be refused before allocating the body.
967        use arrow_ipc::{Buffer as FbBuffer, FieldNode, MessageBuilder, RecordBatchBuilder};
968        // Build a real schema first so the schema gate passes.
969        let schema = Schema::new(vec![Field::new("v", DataType::Int64, false)]);
970        let mut buf: Vec<u8> = Vec::new();
971        {
972            let w = StreamWriter::new(&mut buf, &schema).unwrap();
973            // Don't write any batches; we'll append a hand-crafted
974            // malicious message below.
975            // Drop without finish so EOS is not written.
976            std::mem::forget(w);
977        }
978        // Hand-craft a RecordBatch Message flatbuffer with absurd
979        // bodyLength.
980        let mut fbb = FlatBufferBuilder::new();
981        let nodes_vec = fbb.create_vector(&[FieldNode::new(0, 0)]);
982        let buffers_vec = fbb.create_vector(&[FbBuffer::new(0, 0)]);
983        let rb_off = {
984            let mut b = RecordBatchBuilder::new(&mut fbb);
985            b.add_length(0);
986            b.add_nodes(nodes_vec);
987            b.add_buffers(buffers_vec);
988            b.finish()
989        };
990        let msg_off = {
991            let mut mb = MessageBuilder::new(&mut fbb);
992            mb.add_version(arrow_ipc::MetadataVersion::V5);
993            mb.add_header_type(MessageHeader::RecordBatch);
994            mb.add_header(rb_off.as_union_value());
995            mb.add_bodyLength(MAX_IPC_MESSAGE_BYTES as i64 + 1);
996            mb.finish()
997        };
998        fbb.finish(msg_off, None);
999        let msg_bytes = fbb.finished_data();
1000        // Frame: continuation + 4-byte LE length + flatbuffer body.
1001        buf.extend_from_slice(&CONTINUATION_MARKER);
1002        buf.extend_from_slice(&(msg_bytes.len() as u32).to_le_bytes());
1003        buf.extend_from_slice(msg_bytes);
1004        // No body — but we never get that far; the cap rejects first.
1005
1006        let mut r = StreamReader::new(buf.as_slice()).unwrap();
1007        let err = r.read_next().expect_err("must reject");
1008        assert!(
1009            err.message.contains("bodyLength") && err.message.contains("exceeds cap"),
1010            "unexpected error: {err:?}"
1011        );
1012    }
1013
1014    #[test]
1015    fn malformed_schema_message_is_error_not_panic() {
1016        // Regression for a fuzz-found crash: a structurally-parseable but
1017        // malformed Schema message made arrow-ipc's `fb_to_schema` panic
1018        // (`Option::unwrap()` on `None` in convert.rs) out of `StreamReader::new`
1019        // — aborting the process instead of returning a clean error. The
1020        // schema parse must now be caught like the per-batch decode.
1021        // `crash-cea0477693563377f77c693ca8d3df51ee421811` from
1022        // `fuzz/wire_stream_reader`.
1023        let crash: &[u8] = &[
1024            22, 0, 0, 0, 12, 0, 0, 0, 0, 0, 8, 0, 4, 0, 0, 0, 12, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 134,
1025        ];
1026        // Must not panic; either an Err here or a clean reader is acceptable —
1027        // the contract is "no unwind escapes".
1028        let _ = StreamReader::new(crash);
1029    }
1030
1031    #[test]
1032    fn rejects_buffer_descriptor_past_body() {
1033        // A record-batch message whose body is 8 bytes but whose buffer
1034        // descriptor claims offset 0 / length 1000. arrow-ipc would
1035        // index out of bounds and panic; the descriptor pre-check must
1036        // reject it as a clean `RpcError` first.
1037        use arrow_ipc::{Buffer as FbBuffer, FieldNode, MessageBuilder, RecordBatchBuilder};
1038        let schema = Schema::new(vec![Field::new("v", DataType::Int64, false)]);
1039        let mut buf: Vec<u8> = Vec::new();
1040        {
1041            let w = StreamWriter::new(&mut buf, &schema).unwrap();
1042            std::mem::forget(w);
1043        }
1044        let mut fbb = FlatBufferBuilder::new();
1045        let nodes_vec = fbb.create_vector(&[FieldNode::new(1, 0)]);
1046        // offset 0, length 1000 — far past the 8-byte body below.
1047        let buffers_vec = fbb.create_vector(&[FbBuffer::new(0, 1000)]);
1048        let rb_off = {
1049            let mut b = RecordBatchBuilder::new(&mut fbb);
1050            b.add_length(1);
1051            b.add_nodes(nodes_vec);
1052            b.add_buffers(buffers_vec);
1053            b.finish()
1054        };
1055        let msg_off = {
1056            let mut mb = MessageBuilder::new(&mut fbb);
1057            mb.add_version(arrow_ipc::MetadataVersion::V5);
1058            mb.add_header_type(MessageHeader::RecordBatch);
1059            mb.add_header(rb_off.as_union_value());
1060            mb.add_bodyLength(8);
1061            mb.finish()
1062        };
1063        fbb.finish(msg_off, None);
1064        let msg_bytes = fbb.finished_data().to_vec();
1065        buf.extend_from_slice(&CONTINUATION_MARKER);
1066        buf.extend_from_slice(&(msg_bytes.len() as u32).to_le_bytes());
1067        buf.extend_from_slice(&msg_bytes);
1068        buf.extend_from_slice(&[0u8; 8]); // the 8-byte body
1069
1070        let mut r = StreamReader::new(buf.as_slice()).unwrap();
1071        let err = r.read_next().expect_err("must reject");
1072        assert!(
1073            err.message.contains("buffer descriptor"),
1074            "unexpected error: {err:?}"
1075        );
1076    }
1077}