blob: b3d8065f0841e10ca4f85eb09d7a33e3c77b91b3 [file]
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
use std::sync::Arc;
use arrow::array::{
Array, AsArray, BinaryArray, BinaryBuilder, GenericStringArray, OffsetSizeTrait,
};
use arrow::datatypes::DataType;
use datafusion::common::{exec_err, DataFusionError, ScalarValue};
use datafusion::physical_plan::ColumnarValue;
/// Spark `unbase64(str)`: decodes a base64 string to binary using JDK's MIME decoder rules.
///
/// Matches `java.util.Base64.getMimeDecoder().decode(str.getBytes(ISO_8859_1))`: every byte
/// outside the base64 alphabet is skipped (so CRLF-wrapped output from Spark's own `base64`
/// round-trips cleanly), and the four terminal-shape error conditions are reproduced with
/// matching messages.
pub fn spark_unbase64(args: &[ColumnarValue]) -> Result<ColumnarValue, DataFusionError> {
if args.len() != 1 {
return exec_err!("unbase64 expects exactly one argument, got {}", args.len());
}
match &args[0] {
ColumnarValue::Array(array) => match array.data_type() {
DataType::Utf8 => Ok(ColumnarValue::Array(Arc::new(decode_array(
array.as_string::<i32>(),
)?))),
DataType::LargeUtf8 => Ok(ColumnarValue::Array(Arc::new(decode_array(
array.as_string::<i64>(),
)?))),
// Comet's planner coerces string inputs back to Utf8 for well-supported UDF
// signatures, so Utf8View is not expected to reach us today. Fail loudly if it
// ever does, rather than silently materialise.
other => exec_err!("unbase64 expects a string argument, got {other}"),
},
ColumnarValue::Scalar(ScalarValue::Utf8(value))
| ColumnarValue::Scalar(ScalarValue::LargeUtf8(value)) => {
let decoded = value
.as_ref()
.map(|s| {
let mut out = Vec::new();
decode(s.as_bytes(), &mut out).map(|_| out)
})
.transpose()?;
Ok(ColumnarValue::Scalar(ScalarValue::Binary(decoded)))
}
ColumnarValue::Scalar(other) => {
exec_err!("unbase64 expects a string argument, got {other}")
}
}
}
/// Sentinel in `BASE64_LUT` for bytes that are not in the base64 alphabet.
const INVALID: u8 = 0xFF;
/// Sentinel in `BASE64_LUT` for the padding character `=`.
const PADDING: u8 = 0xFE;
/// Lookup table mapping each possible input byte to its 6-bit base64 value, `PADDING` for `=`,
/// or `INVALID` for every other byte. Built at compile time so the hot loop is a single indexed
/// load per input byte.
const BASE64_LUT: [u8; 256] = build_base64_lut();
const fn build_base64_lut() -> [u8; 256] {
let mut lut = [INVALID; 256];
let mut i = 0;
while i < 26 {
lut[b'A' as usize + i] = i as u8;
lut[b'a' as usize + i] = (i + 26) as u8;
i += 1;
}
let mut i = 0;
while i < 10 {
lut[b'0' as usize + i] = (i + 52) as u8;
i += 1;
}
lut[b'+' as usize] = 62;
lut[b'/' as usize] = 63;
lut[b'=' as usize] = PADDING;
lut
}
const ERR_WRONG_ENDING: &str = "Input byte array has wrong 4-byte ending unit";
const ERR_NOT_ENOUGH_BITS: &str = "Last unit does not have enough valid bits";
/// Returns `exec_err` if `input_bytes` cannot fit inside a `BinaryArray`'s i32 offset range.
///
/// `GenericByteBuilder::next_offset` in arrow-rs is `.expect("byte array offset overflow")`, so
/// once the running offset would cross `i32::MAX` the append panics. That panic bubbles across
/// the JNI boundary without Comet context; the upfront guard turns it into an `exec_err` with
/// the actual input size, which is what surfaces in the Spark task log.
///
/// The bound is conservative: the decoder produces at most `input_bytes * 3 / 4` output bytes
/// (skipped non-alphabet bytes shrink it further), so an input at this bound decodes to well
/// under `i32::MAX`. Rejecting at input length keeps the check O(1) and independent of the
/// decoder implementation.
fn check_binary_capacity(input_bytes: usize) -> Result<(), DataFusionError> {
if input_bytes > i32::MAX as usize {
return exec_err!(
"unbase64 input of {input_bytes} bytes exceeds BinaryArray capacity ({} bytes)",
i32::MAX
);
}
Ok(())
}
// Both Utf8 and LargeUtf8 inputs decode to i32-offset `BinaryArray`. Comet's shuffle path
// does not otherwise plumb `LargeBinaryArray`, so widening the return type would ripple
// through every downstream consumer of this column; the `check_binary_capacity` guard on the
// LargeUtf8 path turns the only reachable overflow into a clean `exec_err` instead.
fn decode_array<O: OffsetSizeTrait>(
array: &GenericStringArray<O>,
) -> Result<BinaryArray, DataFusionError> {
// Byte span of the slice (last offset − first offset), not the underlying buffer length.
// `value_data().len()` reports the whole buffer and ignores slicing, which would
// false-positive the capacity guard on a small slice into a large parent.
let offsets = array.value_offsets();
let input_bytes = (offsets[array.len()] - offsets[0]).as_usize();
if O::IS_LARGE {
check_binary_capacity(input_bytes)?;
}
// Upper bound: N alphabet chars decode to `ceil(N/4)*3` bytes, and alphabet chars <= total
// input bytes. Over-reserves for inputs padded heavily with skipped bytes (long CRLF-wrapped
// values), which is acceptable.
let capacity = input_bytes.div_ceil(4) * 3;
let mut builder = BinaryBuilder::with_capacity(array.len(), capacity);
let mut scratch = Vec::new();
for i in 0..array.len() {
if array.is_null(i) {
builder.append_null();
continue;
}
scratch.clear();
decode(array.value(i).as_bytes(), &mut scratch)?;
builder.append_value(&scratch);
}
Ok(builder.finish())
}
/// Decodes `src` in MIME mode, appending decoded bytes to `out`.
///
/// Mirrors `java.util.Base64.Decoder.decode0` with `isMIME = true`: every byte outside the base64
/// alphabet is skipped, and the four terminal-shape error conditions are reproduced verbatim so
/// the error messages match Spark's codegen-dispatched path.
fn decode(src: &[u8], out: &mut Vec<u8>) -> Result<(), DataFusionError> {
out.reserve(src.len().div_ceil(4) * 3);
let mut bits: u32 = 0;
// Position, in bits, where the next 6-bit group's high bit lands inside a 24-bit atom.
// Steps 18 -> 12 -> 6 -> 0 for the four chars of a group; wraps back to 18 after emit.
let mut shift: i32 = 18;
let mut sp = 0;
while sp < src.len() {
let raw = src[sp];
sp += 1;
let v = BASE64_LUT[raw as usize];
if v == INVALID {
continue;
}
if v == PADDING {
if shift == 18 {
// '=' with no data before it in this group.
return Err(DataFusionError::Execution(ERR_WRONG_ENDING.into()));
}
if shift == 6 {
// `xx=` shape: the immediate next byte must also be `=`. JDK reads it adjacent,
// without skipping non-alphabet bytes, so we do the same for message parity.
if sp >= src.len() || src[sp] != b'=' {
return Err(DataFusionError::Execution(ERR_WRONG_ENDING.into()));
}
sp += 1;
}
break;
}
bits |= (v as u32) << shift;
shift -= 6;
if shift < 0 {
out.push((bits >> 16) as u8);
out.push((bits >> 8) as u8);
out.push(bits as u8);
shift = 18;
bits = 0;
}
}
// Finalize the partial group. shift reflects how many chars we consumed in the tail.
match shift {
18 => {}
12 => return Err(DataFusionError::Execution(ERR_NOT_ENOUGH_BITS.into())),
6 => out.push((bits >> 16) as u8),
0 => {
out.push((bits >> 16) as u8);
out.push((bits >> 8) as u8);
}
_ => unreachable!("shift is always one of 18, 12, 6, 0"),
}
// After padding, only real alphabet characters are errors. JDK's trailing loop treats any
// byte with `base64[b] < 0` as skippable, which covers both non-alphabet bytes (INVALID)
// and additional padding bytes (PADDING) — so e.g. `YQ===` decodes cleanly to `a`.
while sp < src.len() {
let b = src[sp];
sp += 1;
if BASE64_LUT[b as usize] < PADDING {
return Err(DataFusionError::Execution(format!(
"Input byte array has incorrect ending byte at {sp}"
)));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use arrow::array::{LargeStringArray, StringArray};
fn dec(s: &str) -> Vec<u8> {
let mut out = Vec::new();
decode(s.as_bytes(), &mut out).unwrap();
out
}
fn dec_err(s: &str) -> String {
decode(s.as_bytes(), &mut Vec::new())
.unwrap_err()
.to_string()
}
#[test]
fn empty_input() {
assert_eq!(dec(""), Vec::<u8>::new());
}
#[test]
fn standard_padded() {
assert_eq!(dec("YQ=="), b"a");
assert_eq!(dec("YWI="), b"ab");
assert_eq!(dec("YWJj"), b"abc");
}
#[test]
fn unpadded_accepted() {
// JDK MIME accepts truncated tails at clean boundaries.
assert_eq!(dec("YQ"), b"a");
assert_eq!(dec("YWI"), b"ab");
}
#[test]
fn skips_non_alphabet_bytes() {
assert_eq!(dec("YW Jj"), b"abc");
assert_eq!(dec("YWJj?"), b"abc");
assert_eq!(dec("Y\r\nWJj"), b"abc");
}
#[test]
fn crlf_wrapped_round_trips() {
// 60-byte input encodes to 80 chars → one CRLF wrap at char 76 (matches spark_base64).
let raw: Vec<u8> = (0..60).map(|i| i as u8).collect();
let encoded_flat =
"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7";
let encoded_wrapped = format!("{}\r\n{}", &encoded_flat[..76], &encoded_flat[76..]);
assert_eq!(dec(encoded_flat), raw);
assert_eq!(dec(&encoded_wrapped), raw);
}
#[test]
fn extra_trailing_padding_after_valid_pair() {
// Regression: JDK's MIME trailing loop uses `base64[b] < 0`, and '=' maps to -2 (< 0),
// so additional `=` bytes after the required pair are silently consumed. An earlier
// implementation compared against the INVALID sentinel only and rejected these.
assert_eq!(dec("YQ==="), b"a");
assert_eq!(dec("YQ==\r\n="), b"a");
}
#[test]
fn err_dangling_single_char() {
assert!(dec_err("YWJjY").contains(ERR_NOT_ENOUGH_BITS));
}
#[test]
fn err_padding_without_data() {
assert!(dec_err("====").contains(ERR_WRONG_ENDING));
}
#[test]
fn err_missing_second_pad() {
// "YW=" is shape xx=, the second `=` is missing.
assert!(dec_err("YW=").contains(ERR_WRONG_ENDING));
}
#[test]
fn err_alphabet_after_padding() {
let err = dec_err("YQ==Z");
assert!(err.contains("incorrect ending byte"), "{err}");
}
#[test]
fn decode_array_utf8_with_nulls() {
let input = StringArray::from(vec![Some("YWJj"), None, Some("YQ=="), Some("")]);
let out = decode_array(&input).unwrap();
assert_eq!(out.len(), 4);
assert_eq!(out.value(0), b"abc");
assert!(out.is_null(1));
assert_eq!(out.value(2), b"a");
assert_eq!(out.value(3), b"");
}
#[test]
fn check_binary_capacity_at_boundary() {
assert!(check_binary_capacity(0).is_ok());
assert!(check_binary_capacity(i32::MAX as usize).is_ok());
assert!(check_binary_capacity(i32::MAX as usize + 1).is_err());
}
#[test]
fn spark_unbase64_scalar_utf8() {
let args = vec![ColumnarValue::Scalar(ScalarValue::Utf8(Some(
"YWJj".into(),
)))];
match spark_unbase64(&args).unwrap() {
ColumnarValue::Scalar(ScalarValue::Binary(Some(v))) => assert_eq!(v, b"abc"),
other => panic!("unexpected result: {other:?}"),
}
}
#[test]
fn spark_unbase64_scalar_null() {
let args = vec![ColumnarValue::Scalar(ScalarValue::Utf8(None))];
match spark_unbase64(&args).unwrap() {
ColumnarValue::Scalar(ScalarValue::Binary(None)) => {}
other => panic!("unexpected result: {other:?}"),
}
}
#[test]
fn spark_unbase64_wrong_arity() {
assert!(spark_unbase64(&[]).is_err());
let two = vec![
ColumnarValue::Scalar(ScalarValue::Utf8(Some("YWJj".into()))),
ColumnarValue::Scalar(ScalarValue::Utf8(Some("YWJj".into()))),
];
assert!(spark_unbase64(&two).is_err());
}
#[test]
fn spark_unbase64_wrong_scalar_type() {
let args = vec![ColumnarValue::Scalar(ScalarValue::Int32(Some(42)))];
assert!(spark_unbase64(&args).is_err());
}
#[test]
fn spark_unbase64_large_utf8_array() {
let input = LargeStringArray::from(vec![Some("YWJj"), None, Some("YQ==")]);
match spark_unbase64(&[ColumnarValue::Array(Arc::new(input))]).unwrap() {
ColumnarValue::Array(out) => {
let bin = out.as_binary::<i32>();
assert_eq!(bin.len(), 3);
assert_eq!(bin.value(0), b"abc");
assert!(bin.is_null(1));
assert_eq!(bin.value(2), b"a");
}
other => panic!("unexpected result: {other:?}"),
}
}
}