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}