1use arrow::array::RecordBatch;
17use arrow::ipc::reader::StreamReader;
18use arrow::ipc::writer::StreamWriter;
19use arrow_schema::SchemaRef;
20
21use crate::error::IpcError;
22
23pub const SECRET_HANDLE_EXTENSION: &str = "uni-db.secret-handle";
33
34const ARROW_EXTENSION_KEY: &str = "ARROW:extension:name";
36
37fn 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
74fn reject_all(batches: &[RecordBatch]) -> Result<(), IpcError> {
78 batches.iter().try_for_each(reject_secret_handles)
79}
80
81pub 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
97pub 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
115fn 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
132pub fn decode_batch(bytes: &[u8]) -> Result<Option<RecordBatch>, IpcError> {
149 let batches = read_stream(bytes, "read batch")?;
150 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
163pub fn decode_batches(bytes: &[u8]) -> Result<Vec<RecordBatch>, IpcError> {
169 let batches = read_stream(bytes, "read batches")?;
170 reject_all(&batches)?;
174 Ok(batches)
175}
176
177fn 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 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 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 #[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 #[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 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 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 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 assert!(!encoded.is_empty());
459 }
460
461 #[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 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 #[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}