Skip to main content

uni_plugin_wasm_rt/
ipc.rs

1//! Arrow IPC bridge — `RecordBatch` ↔ wire-stream bytes.
2//!
3//! Both the Extism loader (bytes-in/bytes-out via `Plugin::call`) and
4//! the Component Model loader (alloc/copy/free through linear memory)
5//! cross the host↔plugin boundary by shipping Arrow IPC stream bytes.
6//! Standardizing on the wire format means the executor's columnar
7//! contract is identical regardless of which ABI delivered a batch.
8//!
9//! Host call pattern:
10//!
11//! 1. Serialize arguments / state via [`encode_batch`].
12//! 2. Pass the byte slice through the loader-specific call boundary.
13//! 3. Read the returned bytes; deserialize via [`decode_batch`] (or
14//!    [`decode_batches`] for procedure `YIELD` streaming).
15
16use arrow::array::RecordBatch;
17use arrow::ipc::reader::StreamReader;
18use arrow::ipc::writer::StreamWriter;
19use arrow_schema::SchemaRef;
20
21use crate::error::IpcError;
22
23/// FU-2: Arrow extension name tagging a `secret-handle` column.
24///
25/// Columns whose `Field::metadata` contains
26/// `"ARROW:extension:name" = SECRET_HANDLE_EXTENSION` are blocked at
27/// the IPC boundary — see `reject_secret_handles`. The host's
28/// `SecretStore` returns `secret-handle` resources via the
29/// `host.secrets.acquire` WIT import; the IPC membrane ensures those
30/// opaque handles cannot be exfiltrated as raw bytes inside a plugin's
31/// output `RecordBatch`.
32pub const SECRET_HANDLE_EXTENSION: &str = "uni-db.secret-handle";
33
34/// Arrow metadata key for extension-type names.
35const ARROW_EXTENSION_KEY: &str = "ARROW:extension:name";
36
37/// Walk every field of `batch.schema()` and return
38/// [`IpcError::SecretLeakAttempt`] if any field carries the
39/// `uni-db.secret-handle` extension marker.
40///
41/// Called on every encode and decode path ([`encode_batch`],
42/// [`encode_batches`], [`decode_batch`], [`decode_batches`]) via
43/// [`reject_all`] so neither direction can carry a secret-handle column across
44/// the wasm boundary. Nested children (struct fields, list items) are walked
45/// recursively.
46fn reject_secret_handles(batch: &RecordBatch) -> Result<(), IpcError> {
47    fn walk(field: &arrow_schema::Field) -> Result<(), IpcError> {
48        use arrow_schema::DataType;
49        if field
50            .metadata()
51            .get(ARROW_EXTENSION_KEY)
52            .is_some_and(|name| name == SECRET_HANDLE_EXTENSION)
53        {
54            return Err(IpcError::SecretLeakAttempt {
55                column: field.name().clone(),
56            });
57        }
58        match field.data_type() {
59            DataType::Struct(fields) => fields.iter().try_for_each(|f| walk(f.as_ref())),
60            DataType::List(item) | DataType::LargeList(item) | DataType::FixedSizeList(item, _) => {
61                walk(item.as_ref())
62            }
63            DataType::Map(field, _) => walk(field.as_ref()),
64            _ => Ok(()),
65        }
66    }
67    batch
68        .schema()
69        .fields()
70        .iter()
71        .try_for_each(|f| walk(f.as_ref()))
72}
73
74/// Run [`reject_secret_handles`] over every batch — the FU-2 membrane shared by
75/// all encode/decode paths so a secret-handle column is rejected regardless of
76/// single- vs multi-batch shape.
77fn reject_all(batches: &[RecordBatch]) -> Result<(), IpcError> {
78    batches.iter().try_for_each(reject_secret_handles)
79}
80
81/// Encode a `RecordBatch` as Arrow IPC stream bytes.
82///
83/// Output: schema header + one record batch + end-of-stream marker —
84/// suitable for one-shot transmission across a wasm boundary.
85///
86/// # Errors
87///
88/// Returns [`IpcError::Arrow`] if the writer cannot serialize the
89/// batch (e.g., schema-incompatible types).
90pub fn encode_batch(batch: &RecordBatch) -> Result<Vec<u8>, IpcError> {
91    reject_secret_handles(batch)?;
92    let mut buf: Vec<u8> = Vec::with_capacity(estimate_size(batch));
93    write_stream(&mut buf, batch.schema(), std::slice::from_ref(batch))?;
94    Ok(buf)
95}
96
97/// Encode multiple `RecordBatch`es sharing a schema as one IPC stream.
98///
99/// Useful for procedure plugins that ship a series of yielded rows in
100/// one call. All batches must use the same schema (Arrow IPC stream
101/// invariant).
102///
103/// # Errors
104///
105/// - [`IpcError::EmptyBatchInput`] if `batches` is empty.
106/// - [`IpcError::Arrow`] if the writer rejects the batches.
107pub fn encode_batches(batches: &[RecordBatch]) -> Result<Vec<u8>, IpcError> {
108    let first = batches.first().ok_or(IpcError::EmptyBatchInput)?;
109    reject_all(batches)?;
110    let mut buf: Vec<u8> = Vec::with_capacity(estimate_size(first).saturating_mul(batches.len()));
111    write_stream(&mut buf, first.schema(), batches)?;
112    Ok(buf)
113}
114
115/// Write `batches` (assumed to share `schema`) to `buf` as one IPC stream.
116fn write_stream(
117    buf: &mut Vec<u8>,
118    schema: SchemaRef,
119    batches: &[RecordBatch],
120) -> Result<(), IpcError> {
121    let mut w = StreamWriter::try_new(buf, schema.as_ref())
122        .map_err(|e| IpcError::Arrow(format!("writer setup: {e}")))?;
123    for b in batches {
124        w.write(b)
125            .map_err(|e| IpcError::Arrow(format!("write batch: {e}")))?;
126    }
127    w.finish()
128        .map_err(|e| IpcError::Arrow(format!("finish: {e}")))?;
129    Ok(())
130}
131
132/// Decode the single `RecordBatch` from Arrow IPC stream bytes.
133///
134/// `encode_batch` writes exactly one batch, so any well-formed stream
135/// from this codec carries one batch (or zero, when the plugin produced
136/// no rows). Multiple batches indicate a malformed or malicious sender
137/// and are rejected.
138///
139/// Returns `None` if the stream contained only an end-of-stream marker.
140///
141/// # Errors
142///
143/// Returns [`IpcError::Arrow`] if the bytes are malformed or if the
144/// stream contains more than one batch. The previous form used
145/// `Vec::pop()` and silently returned the *last* batch when more than
146/// one was present, contradicting the "first batch" contract its
147/// documentation promised.
148pub fn decode_batch(bytes: &[u8]) -> Result<Option<RecordBatch>, IpcError> {
149    let batches = read_stream(bytes, "read batch")?;
150    // FU-2: a single-batch stream is still an inbound boundary — reject any
151    // secret-handle column, symmetric with `decode_batches` / `encode_batch`.
152    // (decode_batch is the hot path used by every scalar/aggregate adapter.)
153    reject_all(&batches)?;
154    match batches.len() {
155        0 => Ok(None),
156        1 => Ok(batches.into_iter().next()),
157        n => Err(IpcError::Arrow(format!(
158            "decode_batch expects a single-batch stream, got {n} batches"
159        ))),
160    }
161}
162
163/// Decode every `RecordBatch` from Arrow IPC stream bytes.
164///
165/// # Errors
166///
167/// Returns [`IpcError::Arrow`] if the bytes are malformed.
168pub fn decode_batches(bytes: &[u8]) -> Result<Vec<RecordBatch>, IpcError> {
169    let batches = read_stream(bytes, "read batches")?;
170    // FU-2: reject any incoming batch that carries a secret-handle column.
171    // Symmetric with the encode path so a malicious plugin can't smuggle a
172    // handle back across the boundary either.
173    reject_all(&batches)?;
174    Ok(batches)
175}
176
177/// Build a `StreamReader` over `bytes` and collect all batches.
178/// `read_label` is used only for error messages so each caller's
179/// failure context (`"read batch"` vs `"read batches"`) is preserved.
180fn read_stream(bytes: &[u8], read_label: &str) -> Result<Vec<RecordBatch>, IpcError> {
181    let reader = StreamReader::try_new(bytes, None)
182        .map_err(|e| IpcError::Arrow(format!("reader setup: {e}")))?;
183    reader
184        .collect::<Result<Vec<_>, _>>()
185        .map_err(|e| IpcError::Arrow(format!("{read_label}: {e}")))
186}
187
188fn estimate_size(batch: &RecordBatch) -> usize {
189    // ~16 bytes/cell + 4 KiB schema overhead. Writer grows on demand.
190    let rows = batch.num_rows();
191    let cols = batch.num_columns();
192    rows.saturating_mul(cols).saturating_mul(16) + 4096
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198    use std::sync::Arc;
199
200    use arrow::array::{
201        Array, BooleanArray, Float32Array, Float64Array, Int32Array, Int64Array, LargeBinaryArray,
202        ListArray, StringArray, StructArray, TimestampMillisecondArray,
203    };
204    use arrow::buffer::OffsetBuffer;
205    use arrow_schema::{DataType, Field, Fields, Schema, TimeUnit};
206
207    fn schema_for(name: &str, dt: DataType) -> SchemaRef {
208        Arc::new(Schema::new(vec![Field::new(name, dt, true)]))
209    }
210
211    fn one_col_batch(name: &str, col: Arc<dyn arrow::array::Array>) -> RecordBatch {
212        let dt = col.data_type().clone();
213        let schema = schema_for(name, dt);
214        RecordBatch::try_new(schema, vec![col]).unwrap()
215    }
216
217    #[test]
218    fn round_trip_int64() {
219        let arr: Arc<dyn arrow::array::Array> = Arc::new(Int64Array::from(vec![1, 2, 3]));
220        let batch = one_col_batch("x", arr);
221        let encoded = encode_batch(&batch).unwrap();
222        let decoded = decode_batch(&encoded).unwrap().unwrap();
223        assert_eq!(decoded.num_rows(), 3);
224    }
225
226    #[test]
227    fn round_trip_int32_float32_float64() {
228        let schema = Arc::new(Schema::new(vec![
229            Field::new("i32", DataType::Int32, true),
230            Field::new("f32", DataType::Float32, true),
231            Field::new("f64", DataType::Float64, true),
232        ]));
233        let i: Arc<dyn arrow::array::Array> = Arc::new(Int32Array::from(vec![1, 2]));
234        let f32a: Arc<dyn arrow::array::Array> = Arc::new(Float32Array::from(vec![1.5_f32, 2.5]));
235        let f64a: Arc<dyn arrow::array::Array> = Arc::new(Float64Array::from(vec![10.5_f64, 20.5]));
236        let batch = RecordBatch::try_new(schema, vec![i, f32a, f64a]).unwrap();
237        let encoded = encode_batch(&batch).unwrap();
238        let decoded = decode_batch(&encoded).unwrap().unwrap();
239        assert_eq!(decoded.num_rows(), 2);
240        let f64_out = decoded
241            .column(2)
242            .as_any()
243            .downcast_ref::<Float64Array>()
244            .unwrap();
245        assert!((f64_out.value(1) - 20.5).abs() < f64::EPSILON);
246    }
247
248    #[test]
249    fn round_trip_utf8_strings_including_unicode() {
250        let arr: Arc<dyn arrow::array::Array> =
251            Arc::new(StringArray::from(vec!["hello", "naïve", "🌳", ""]));
252        let batch = one_col_batch("s", arr);
253        let encoded = encode_batch(&batch).unwrap();
254        let decoded = decode_batch(&encoded).unwrap().unwrap();
255        let col = decoded
256            .column(0)
257            .as_any()
258            .downcast_ref::<StringArray>()
259            .unwrap();
260        assert_eq!(col.value(2), "🌳");
261        assert_eq!(col.value(3), "");
262    }
263
264    #[test]
265    fn round_trip_booleans_with_nulls() {
266        let arr: Arc<dyn arrow::array::Array> =
267            Arc::new(BooleanArray::from(vec![Some(true), None, Some(false)]));
268        let batch = one_col_batch("b", arr);
269        let encoded = encode_batch(&batch).unwrap();
270        let decoded = decode_batch(&encoded).unwrap().unwrap();
271        let col = decoded
272            .column(0)
273            .as_any()
274            .downcast_ref::<BooleanArray>()
275            .unwrap();
276        assert!(col.is_null(1));
277        assert!(col.value(0));
278        assert!(!col.value(2));
279    }
280
281    #[test]
282    fn round_trip_timestamp_ms() {
283        let arr: Arc<dyn arrow::array::Array> = Arc::new(
284            TimestampMillisecondArray::from(vec![1_700_000_000_000_i64, 1_800_000_000_000])
285                .with_timezone_opt::<&str>(None),
286        );
287        let batch = one_col_batch("ts", arr);
288        let encoded = encode_batch(&batch).unwrap();
289        let decoded = decode_batch(&encoded).unwrap().unwrap();
290        assert!(matches!(
291            decoded.schema().field(0).data_type(),
292            DataType::Timestamp(TimeUnit::Millisecond, _)
293        ));
294    }
295
296    #[test]
297    fn round_trip_large_binary_for_cypher_values() {
298        let arr: Arc<dyn arrow::array::Array> = Arc::new(LargeBinaryArray::from(vec![
299            &[1_u8, 2, 3][..],
300            &[4, 5, 6, 7],
301        ]));
302        let batch = one_col_batch("v", arr);
303        let encoded = encode_batch(&batch).unwrap();
304        let decoded = decode_batch(&encoded).unwrap().unwrap();
305        let col = decoded
306            .column(0)
307            .as_any()
308            .downcast_ref::<LargeBinaryArray>()
309            .unwrap();
310        assert_eq!(col.value(0), &[1, 2, 3]);
311        assert_eq!(col.value(1), &[4, 5, 6, 7]);
312    }
313
314    #[test]
315    fn round_trip_list_of_int64() {
316        let values: Arc<dyn arrow::array::Array> =
317            Arc::new(Int64Array::from(vec![1_i64, 2, 3, 4, 5, 6]));
318        let offsets = OffsetBuffer::new(vec![0_i32, 2, 5, 6].into());
319        let field = Arc::new(Field::new("item", DataType::Int64, true));
320        let list = ListArray::new(field, offsets, values, None);
321        let arr: Arc<dyn arrow::array::Array> = Arc::new(list);
322        let batch = one_col_batch("xs", arr);
323        let encoded = encode_batch(&batch).unwrap();
324        let decoded = decode_batch(&encoded).unwrap().unwrap();
325        let col = decoded
326            .column(0)
327            .as_any()
328            .downcast_ref::<ListArray>()
329            .unwrap();
330        assert_eq!(col.len(), 3);
331        assert_eq!(col.value_length(1), 3);
332    }
333
334    #[test]
335    fn round_trip_struct_array() {
336        let id: Arc<dyn arrow::array::Array> = Arc::new(Int64Array::from(vec![10, 20]));
337        let label: Arc<dyn arrow::array::Array> = Arc::new(StringArray::from(vec!["a", "b"]));
338        let fields = Fields::from(vec![
339            Field::new("id", DataType::Int64, false),
340            Field::new("label", DataType::Utf8, false),
341        ]);
342        let s = StructArray::new(fields, vec![id, label], None);
343        let arr: Arc<dyn arrow::array::Array> = Arc::new(s);
344        let batch = one_col_batch("rec", arr);
345        let encoded = encode_batch(&batch).unwrap();
346        let decoded = decode_batch(&encoded).unwrap().unwrap();
347        assert_eq!(decoded.num_rows(), 2);
348        assert!(matches!(
349            decoded.schema().field(0).data_type(),
350            DataType::Struct(_)
351        ));
352    }
353
354    #[test]
355    fn decode_empty_stream_returns_none() {
356        let schema = Arc::new(Schema::new(vec![Field::new("x", DataType::Int64, false)]));
357        let mut buf: Vec<u8> = Vec::new();
358        {
359            let mut w = StreamWriter::try_new(&mut buf, schema.as_ref()).unwrap();
360            w.finish().unwrap();
361        }
362        assert!(decode_batch(&buf).unwrap().is_none());
363    }
364
365    #[test]
366    fn decode_garbage_bytes_is_arrow_ipc_error() {
367        let err = decode_batch(b"not arrow ipc").unwrap_err();
368        assert!(matches!(err, IpcError::Arrow(_)));
369    }
370
371    #[test]
372    fn encode_batches_emits_multiple_in_one_stream() {
373        let schema = Arc::new(Schema::new(vec![Field::new("x", DataType::Int64, true)]));
374        let a: Arc<dyn arrow::array::Array> = Arc::new(Int64Array::from(vec![1_i64, 2]));
375        let b: Arc<dyn arrow::array::Array> = Arc::new(Int64Array::from(vec![3_i64, 4, 5]));
376        let ba = RecordBatch::try_new(schema.clone(), vec![a]).unwrap();
377        let bb = RecordBatch::try_new(schema, vec![b]).unwrap();
378        let encoded = encode_batches(&[ba, bb]).unwrap();
379        let all = decode_batches(&encoded).unwrap();
380        assert_eq!(all.len(), 2);
381        assert_eq!(all[0].num_rows(), 2);
382        assert_eq!(all[1].num_rows(), 3);
383    }
384
385    #[test]
386    fn encode_batches_rejects_empty_input() {
387        let err = encode_batches(&[]).unwrap_err();
388        assert!(matches!(err, IpcError::EmptyBatchInput));
389    }
390
391    // ── FU-2: secret-handle leak rejection ─────────────────────────
392
393    fn secret_tagged_field(name: &str) -> Field {
394        Field::new(name, DataType::FixedSizeBinary(8), false).with_metadata(
395            std::collections::HashMap::from([(
396                "ARROW:extension:name".to_owned(),
397                SECRET_HANDLE_EXTENSION.to_owned(),
398            )]),
399        )
400    }
401
402    /// FU-2 acceptance: `encode_batch` refuses any column tagged with
403    /// the `uni-db.secret-handle` Arrow extension and returns
404    /// `IpcError::SecretLeakAttempt` naming the offending column.
405    #[test]
406    fn encode_batch_rejects_secret_handle_column() {
407        use arrow::array::FixedSizeBinaryArray;
408        let schema = Arc::new(Schema::new(vec![secret_tagged_field("api_key_handle")]));
409        let arr =
410            FixedSizeBinaryArray::try_from_iter([[0u8; 8], [1; 8]].iter().map(|b| b.as_slice()))
411                .unwrap();
412        let batch = RecordBatch::try_new(schema, vec![Arc::new(arr)]).unwrap();
413        match encode_batch(&batch) {
414            Ok(_) => panic!("encode_batch must reject secret-handle columns"),
415            Err(IpcError::SecretLeakAttempt { column }) => {
416                assert_eq!(column, "api_key_handle");
417            }
418            Err(other) => panic!("expected SecretLeakAttempt, got {other:?}"),
419        }
420    }
421
422    /// FU-2 acceptance: `decode_batches` symmetrically rejects an
423    /// incoming stream that smuggles a secret-handle column back
424    /// across the boundary.
425    #[test]
426    fn decode_batches_rejects_secret_handle_column() {
427        use arrow::array::FixedSizeBinaryArray;
428        let plain_field = Field::new("api_key_handle", DataType::FixedSizeBinary(8), false);
429        let schema = Arc::new(Schema::new(vec![plain_field]));
430        let arr =
431            FixedSizeBinaryArray::try_from_iter([[0u8; 8]].iter().map(|b| b.as_slice())).unwrap();
432        let batch = RecordBatch::try_new(schema, vec![Arc::new(arr)]).unwrap();
433        let encoded = encode_batch(&batch).unwrap();
434        // Now corrupt the encoded bytes by re-encoding with the
435        // extension marker present. This simulates a hostile plugin
436        // tagging its output column to try to exfiltrate a handle.
437        let tagged_schema = Arc::new(Schema::new(vec![secret_tagged_field("api_key_handle")]));
438        let arr2 =
439            FixedSizeBinaryArray::try_from_iter([[0u8; 8]].iter().map(|b| b.as_slice())).unwrap();
440        let tagged = RecordBatch::try_new(tagged_schema, vec![Arc::new(arr2)]).unwrap();
441        // Build the tagged stream directly (bypassing `encode_batch`
442        // which would have rejected it).
443        let mut buf: Vec<u8> = Vec::new();
444        {
445            let mut w = StreamWriter::try_new(&mut buf, tagged.schema().as_ref()).unwrap();
446            w.write(&tagged).unwrap();
447            w.finish().unwrap();
448        }
449        // The decode side must reject it.
450        match decode_batches(&buf) {
451            Ok(_) => panic!("decode_batches must reject secret-handle columns"),
452            Err(IpcError::SecretLeakAttempt { column }) => {
453                assert_eq!(column, "api_key_handle");
454            }
455            Err(other) => panic!("expected SecretLeakAttempt, got {other:?}"),
456        }
457        // Sanity-check: encoding the *un-tagged* version works.
458        assert!(!encoded.is_empty());
459    }
460
461    /// FU-2 regression: the single-batch `decode_batch` path (the hot path for
462    /// every scalar/aggregate adapter) must reject a smuggled secret-handle
463    /// column too — not just the multi-batch `decode_batches`.
464    #[test]
465    fn decode_batch_rejects_secret_handle_column() {
466        use arrow::array::FixedSizeBinaryArray;
467        let tagged_schema = Arc::new(Schema::new(vec![secret_tagged_field("api_key_handle")]));
468        let arr =
469            FixedSizeBinaryArray::try_from_iter([[0u8; 8]].iter().map(|b| b.as_slice())).unwrap();
470        let tagged = RecordBatch::try_new(tagged_schema, vec![Arc::new(arr)]).unwrap();
471        // Build a single-batch tagged stream directly (bypassing `encode_batch`,
472        // which would have rejected it on the way out).
473        let mut buf: Vec<u8> = Vec::new();
474        {
475            let mut w = StreamWriter::try_new(&mut buf, tagged.schema().as_ref()).unwrap();
476            w.write(&tagged).unwrap();
477            w.finish().unwrap();
478        }
479        match decode_batch(&buf) {
480            Ok(_) => panic!("decode_batch must reject secret-handle columns"),
481            Err(IpcError::SecretLeakAttempt { column }) => {
482                assert_eq!(column, "api_key_handle");
483            }
484            Err(other) => panic!("expected SecretLeakAttempt, got {other:?}"),
485        }
486    }
487
488    /// FU-2 acceptance: nested struct/list fields are walked, so a
489    /// plugin can't bury a secret-handle inside a struct column.
490    #[test]
491    fn encode_batch_rejects_secret_handle_inside_struct() {
492        use arrow::array::Int64Array;
493        let plain = Field::new("id", DataType::Int64, false);
494        let secret = secret_tagged_field("handle");
495        let struct_field = Field::new(
496            "rec",
497            DataType::Struct(Fields::from(vec![plain, secret])),
498            false,
499        );
500        let schema = Arc::new(Schema::new(vec![struct_field]));
501        let id_arr: Arc<dyn arrow::array::Array> = Arc::new(Int64Array::from(vec![1, 2]));
502        let secret_arr: Arc<dyn arrow::array::Array> = Arc::new(
503            arrow::array::FixedSizeBinaryArray::try_from_iter(
504                [[0u8; 8], [1; 8]].iter().map(|b| b.as_slice()),
505            )
506            .unwrap(),
507        );
508        let s = StructArray::new(
509            Fields::from(vec![
510                Field::new("id", DataType::Int64, false),
511                Field::new("handle", DataType::FixedSizeBinary(8), false).with_metadata(
512                    std::collections::HashMap::from([(
513                        "ARROW:extension:name".to_owned(),
514                        SECRET_HANDLE_EXTENSION.to_owned(),
515                    )]),
516                ),
517            ]),
518            vec![id_arr, secret_arr],
519            None,
520        );
521        let batch = RecordBatch::try_new(schema, vec![Arc::new(s)]).unwrap();
522        match encode_batch(&batch) {
523            Ok(_) => panic!("encode_batch must reject nested secret-handle"),
524            Err(IpcError::SecretLeakAttempt { column }) => {
525                assert_eq!(column, "handle");
526            }
527            Err(other) => panic!("expected SecretLeakAttempt, got {other:?}"),
528        }
529    }
530}