diff --git a/vortex-array/src/arrow/session.rs b/vortex-array/src/arrow/session.rs index d3f43443d5d..89e9c854eeb 100644 --- a/vortex-array/src/arrow/session.rs +++ b/vortex-array/src/arrow/session.rs @@ -60,6 +60,7 @@ use crate::dtype::Nullability; use crate::dtype::StructFields; use crate::dtype::arrow::TryFromArrowType; use crate::dtype::arrow::to_data_type_naive; +use crate::dtype::arrow::validate_arrow_field_name; use crate::dtype::extension::ExtId; use crate::extension::datetime::AnyTemporal; use crate::extension::uuid::Uuid; @@ -221,6 +222,7 @@ impl ArrowSession { /// For [`DType::Extension`]s, plugins registered against the extension's `Id` /// are tried in registration order; the first plugin to return `Some(field)` wins. pub fn to_arrow_field(&self, name: &str, dtype: &DType) -> VortexResult { + validate_arrow_field_name(name)?; // Handle the structural encodings, which may have recursive types match dtype { DType::List(elem_dtype, nullability) => { @@ -791,4 +793,12 @@ mod tests { assert_eq!(fsb.value(1), b"fedcba9876543210"); Ok(()) } + + #[test] + fn to_arrow_field_rejects_nul_field_name() { + // Arrow's FFI schema export aborts on a NUL byte in a field name (#8652); reject it here. + let session = ArrowSession::default(); + let dtype = DType::Primitive(PType::I32, Nullability::Nullable); + assert!(session.to_arrow_field("col\0hidden", &dtype).is_err()); + } } diff --git a/vortex-array/src/dtype/arrow.rs b/vortex-array/src/dtype/arrow.rs index 9d381b6eab2..c67dcb3370a 100644 --- a/vortex-array/src/dtype/arrow.rs +++ b/vortex-array/src/dtype/arrow.rs @@ -273,6 +273,7 @@ impl DType { let mut builder = SchemaBuilder::with_capacity(struct_dtype.names().len()); for (field_name, field_dtype) in struct_dtype.names().iter().zip(struct_dtype.fields()) { + validate_arrow_field_name(field_name.as_ref())?; let field = if field_dtype.is_variant() { let storage = DataType::Struct(variant_storage_fields_minimal()); Field::new(field_name.as_ref(), storage, field_dtype.is_nullable()).with_metadata( @@ -308,6 +309,15 @@ impl DType { } } +/// Reject a field name Arrow cannot export: an interior NUL byte aborts the process during FFI +/// schema export, where the name is copied into a NUL-terminated `CString`. +pub(crate) fn validate_arrow_field_name(name: &str) -> VortexResult<()> { + if name.contains('\0') { + vortex_bail!("field name {name:?} cannot be exported to Arrow: contains a NUL byte"); + } + Ok(()) +} + /// Naive conversion from a Vortex `DType` to the nearest Arrow physical data type. pub(crate) fn to_data_type_naive(dtype: &DType) -> VortexResult { Ok(match dtype { @@ -361,6 +371,7 @@ pub(crate) fn to_data_type_naive(dtype: &DType) -> VortexResult { DType::Struct(struct_dtype, _) => { let mut fields = Vec::with_capacity(struct_dtype.names().len()); for (field_name, field_dt) in struct_dtype.names().iter().zip(struct_dtype.fields()) { + validate_arrow_field_name(field_name.as_ref())?; fields.push(FieldRef::from(Field::new( field_name.as_ref(), to_data_type_naive(&field_dt)?, @@ -653,4 +664,33 @@ mod test { assert_eq!(dtype, DType::Primitive(PType::I32, Nullability::Nullable)); Ok(()) } + + #[test] + fn test_to_arrow_schema_rejects_nul_field_name() { + // A NUL byte in a field name aborts Arrow's FFI schema export (#8652); reject it up front. + let dtype = DType::Struct( + StructFields::new( + FieldNames::from(["col\0hidden"]), + vec![DType::Primitive(PType::I32, Nullability::Nullable)], + ), + Nullability::NonNullable, + ); + assert!(dtype.to_arrow_schema().is_err()); + } + + #[test] + fn test_to_arrow_schema_rejects_nul_in_nested_field_name() { + let inner = DType::Struct( + StructFields::new( + FieldNames::from(["a\0b"]), + vec![DType::Primitive(PType::I32, Nullability::Nullable)], + ), + Nullability::Nullable, + ); + let dtype = DType::Struct( + StructFields::new(FieldNames::from(["outer"]), vec![inner]), + Nullability::NonNullable, + ); + assert!(dtype.to_arrow_schema().is_err()); + } }