This is an automated email from the ASF dual-hosted git repository.

JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-rust.git


The following commit(s) were added to refs/heads/main by this push:
     new 31201c07 [read] Support Java-compatible REST query authorization masks 
(#1122)
31201c07 is described below

commit 31201c0717bd7a9b9753174b8a473f73186ba04c
Author: Jingsong Lee <[email protected]>
AuthorDate: Tue Oct 13 20:10:25 2026 +0800

    [read] Support Java-compatible REST query authorization masks (#1122)
---
 crates/paimon/src/arrow/mod.rs                     |   2 +
 crates/paimon/src/arrow/string_transform.rs        | 447 +++++++++++++++++++++
 crates/paimon/src/spec/mod.rs                      |   4 +-
 crates/paimon/src/spec/predicate.rs                | 225 ++++++++++-
 crates/paimon/src/spec/schema.rs                   |  33 +-
 .../src/table/goldens/split_v1_query_auth.bin      | Bin 0 -> 993 bytes
 crates/paimon/src/table/mod.rs                     |  22 +
 crates/paimon/src/table/query_auth.rs              |  95 ++++-
 crates/paimon/src/table/query_auth/rules.rs        |  62 ++-
 crates/paimon/src/table/source.rs                  | 268 +++++++++++-
 10 files changed, 1113 insertions(+), 45 deletions(-)

diff --git a/crates/paimon/src/arrow/mod.rs b/crates/paimon/src/arrow/mod.rs
index 34140b97..7273e175 100644
--- a/crates/paimon/src/arrow/mod.rs
+++ b/crates/paimon/src/arrow/mod.rs
@@ -26,6 +26,8 @@ pub(crate) mod partition;
 mod read_budget;
 pub(crate) mod residual;
 mod row_filter;
+mod string_transform;
+pub(crate) use string_transform::evaluate_string_transform;
 pub(crate) mod schema_evolution;
 pub(crate) mod shredding;
 mod variant;
diff --git a/crates/paimon/src/arrow/string_transform.rs 
b/crates/paimon/src/arrow/string_transform.rs
new file mode 100644
index 00000000..71d5dbab
--- /dev/null
+++ b/crates/paimon/src/arrow/string_transform.rs
@@ -0,0 +1,447 @@
+// 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.
+
+//! Arrow execution of Java's character and position transforms.
+
+use std::collections::HashMap;
+use std::sync::Arc;
+
+use crate::spec::{PadDirection, Transform, TransformInput, TrimFlag};
+use crate::Result;
+use arrow_array::{cast::AsArray, Array, ArrayRef, Int32Array, Int64Array, 
StringArray};
+use arrow_schema::DataType as ArrowType;
+
+enum Input<'a> {
+    Text(Option<&'a str>),
+    Number(Option<i64>),
+    IntegerText(&'a str),
+    Strings(StringArray),
+    Integers(Int64Array),
+}
+
+impl Input<'_> {
+    fn is_null(&self, row: usize) -> bool {
+        match self {
+            Self::Text(value) => value.is_none(),
+            Self::Number(value) => value.is_none(),
+            Self::IntegerText(_) => false,
+            Self::Strings(values) => values.is_null(row),
+            Self::Integers(values) => values.is_null(row),
+        }
+    }
+    fn text(&self, row: usize) -> Result<Option<&str>> {
+        match self {
+            Self::Text(value) => Ok(*value),
+            Self::Strings(values) => Ok(values.is_valid(row).then(|| 
values.value(row))),
+            _ => Err(invalid("string transform requires a character input")),
+        }
+    }
+
+    fn integer(&self, row: usize) -> Result<Option<i32>> {
+        let value = match self {
+            Self::IntegerText(text) => return parse_integer(text).map(Some),
+            Self::Number(value) => *value,
+            Self::Integers(values) => values.is_valid(row).then(|| 
values.value(row)),
+            _ => return Err(invalid("position transform requires an integer 
input")),
+        };
+        value
+            .map(|v| {
+                i32::try_from(v).map_err(|_| invalid("transform position 
exceeds Java INT range"))
+            })
+            .transpose()
+    }
+}
+
+pub(crate) fn evaluate_string_transform(
+    transform: &Transform,
+    rows: usize,
+    column: &dyn Fn(usize) -> Result<ArrayRef>,
+) -> Result<ArrayRef> {
+    let inputs = transform
+        .inputs()
+        .iter()
+        .map(|input| match input {
+            TransformInput::Literal(value) => 
Ok(Input::Text(value.as_deref())),
+            TransformInput::Integer(value) => Ok(Input::Number(*value)),
+            TransformInput::IntegerText(text) => Ok(Input::IntegerText(text)),
+            TransformInput::Field(index) => {
+                let array = column(*index)?;
+                match array.data_type() {
+                    ArrowType::Utf8 => 
Ok(Input::Strings(array.as_string::<i32>().clone())),
+                    ArrowType::Int8 | ArrowType::Int16 | ArrowType::Int32 | 
ArrowType::Int64 => {
+                        let array = arrow_cast::cast(&array, &ArrowType::Int64)
+                            .map_err(|e| invalid(&e.to_string()))?;
+                        Ok(Input::Integers(
+                            array
+                                
.as_primitive::<arrow_array::types::Int64Type>()
+                                .clone(),
+                        ))
+                    }
+                    other => Err(invalid(&format!("invalid transform input 
type {other:?}"))),
+                }
+            }
+        })
+        .collect::<Result<Vec<_>>>()?;
+    if matches!(transform, Transform::Length(_) | Transform::BitLength(_)) {
+        let values = (0..rows)
+            .map(|row| {
+                Ok(inputs[0].text(row)?.map(|text| {
+                    if matches!(transform, Transform::Length(_)) {
+                        text.chars().count() as i32
+                    } else {
+                        (text.len() as i32).wrapping_mul(8)
+                    }
+                }))
+            })
+            .collect::<Result<Vec<_>>>()?;
+        return Ok(Arc::new(Int32Array::from(values)));
+    }
+    let values = (0..rows)
+        .map(|row| evaluate_row(transform, &inputs, row))
+        .collect::<Result<Vec<_>>>()?;
+    Ok(Arc::new(StringArray::from(values)))
+}
+
+fn evaluate_row(transform: &Transform, inputs: &[Input<'_>], row: usize) -> 
Result<Option<String>> {
+    let source = inputs[0].text(row)?;
+    let value = match transform {
+        Transform::Substring(_) => {
+            // SubstringTransform checks source and null positions before
+            // evaluating positions; a hidden NULL source cannot raise 
overflow.
+            let Some(source) = source else {
+                return Ok(None);
+            };
+            if inputs[1..].iter().any(|input| input.is_null(row)) {
+                return Ok(None);
+            }
+            let Some(position) = inputs[1].integer(row)? else {
+                return Ok(None);
+            };
+            let length = if inputs.len() == 3 {
+                let Some(length) = inputs[2].integer(row)? else {
+                    return Ok(None);
+                };
+                length
+            } else {
+                i32::MAX
+            };
+            substring_sql(source, position, length).to_owned()
+        }
+        Transform::Trim(_, flag) => {
+            let Some(source) = source else {
+                return Ok(None);
+            };
+            let chars = if inputs.len() == 2 {
+                let Some(chars) = inputs[1].text(row)? else {
+                    return Ok(None);
+                };
+                chars
+            } else {
+                " "
+            };
+            let contains = |c| chars.contains(c);
+            match flag {
+                TrimFlag::Leading => source.trim_start_matches(contains),
+                TrimFlag::Trailing => source.trim_end_matches(contains),
+                TrimFlag::Both => source.trim_matches(contains),
+            }
+            .to_owned()
+        }
+        Transform::Pad(_, direction) => {
+            let length = inputs[1].integer(row)?;
+            let padding = inputs[2].text(row)?;
+            let (Some(source), Some(length), Some(padding)) = (source, length, 
padding) else {
+                return Ok(None);
+            };
+            let count = source.chars().count() as i32;
+            let needed = length.wrapping_sub(count);
+            if needed <= 0 || padding.is_empty() {
+                substring(source, 0, length).to_owned()
+            } else {
+                let pad_chars = padding.chars().count();
+                let repeats = needed as usize / pad_chars;
+                let tail = substring(padding, 0, needed % pad_chars as i32);
+                let size = padding
+                    .len()
+                    .checked_mul(repeats)
+                    .and_then(|n| n.checked_add(tail.len()))
+                    .and_then(|n| n.checked_add(source.len()))
+                    .filter(|n| *n <= i32::MAX as usize)
+                    .ok_or_else(|| invalid("PAD result exceeds Arrow UTF-8 
storage"))?;
+                let mut result = String::new();
+                result
+                    .try_reserve(size)
+                    .map_err(|e| invalid(&format!("cannot reserve PAD result: 
{e}")))?;
+                if *direction == PadDirection::Right {
+                    result.push_str(source);
+                }
+                for _ in 0..repeats {
+                    result.push_str(padding);
+                }
+                result.push_str(tail);
+                if *direction == PadDirection::Left {
+                    result.push_str(source);
+                }
+                result
+            }
+        }
+        Transform::Translate(_) => {
+            let matching = inputs[1].text(row)?;
+            let replacement = inputs[2].text(row)?;
+            let (Some(source), Some(matching), Some(replacement)) = (source, 
matching, replacement)
+            else {
+                return Ok(None);
+            };
+            let replacements: Vec<char> = replacement.chars().collect();
+            let mut mapping = HashMap::new();
+            for (index, ch) in matching.chars().enumerate() {
+                mapping
+                    .entry(ch)
+                    .or_insert(replacements.get(index).copied().filter(|c| *c 
!= '\0'));
+            }
+            source
+                .chars()
+                .filter_map(|ch| mapping.get(&ch).copied().unwrap_or(Some(ch)))
+                .collect()
+        }
+        Transform::Overlay(_) => {
+            let replacement = inputs[1].text(row)?;
+            let position = inputs[2].integer(row)?;
+            let length = if inputs.len() == 4 {
+                inputs[3].integer(row)?
+            } else {
+                None
+            };
+            let (Some(source), Some(replacement), Some(position)) = (source, 
replacement, position)
+            else {
+                return Ok(None);
+            };
+            if inputs.len() == 4 && length.is_none() {
+                return Ok(None);
+            }
+            let replaced = length
+                .filter(|length| *length >= 0)
+                .unwrap_or_else(|| replacement.chars().count() as i32);
+            format!(
+                "{}{}{}",
+                substring_sql(source, 1, position.wrapping_sub(1)),
+                replacement,
+                substring_sql(source, position.wrapping_add(replaced), 
i32::MAX)
+            )
+        }
+        _ => return Err(invalid("unsupported character transform")),
+    };
+    Ok(Some(value))
+}
+
+fn substring_sql(source: &str, position: i32, length: i32) -> &str {
+    let count = source.chars().count() as i32;
+    let start = if position > 0 {
+        position - 1
+    } else if position < 0 {
+        count + position
+    } else {
+        0
+    };
+    let end = (i64::from(start) + 
i64::from(length)).clamp(i64::from(i32::MIN), i64::from(i32::MAX))
+        as i32;
+    substring(source, start, end)
+}
+
+fn substring(source: &str, start: i32, end: i32) -> &str {
+    if end <= start || end <= 0 {
+        return "";
+    }
+    let start = source
+        .char_indices()
+        .nth(start.max(0) as usize)
+        .map_or(source.len(), |(index, _)| index);
+    let end = source
+        .char_indices()
+        .nth(end as usize)
+        .map_or(source.len(), |(index, _)| index);
+    &source[start..end.max(start)]
+}
+
+fn invalid(message: &str) -> crate::Error {
+    crate::Error::DataInvalid {
+        message: message.to_owned(),
+        source: None,
+    }
+}
+
+/// Integer.parseInt uses Character.digit on UTF-16 chars: decimal digits in
+/// BMP scripts are accepted, supplementary digits and whitespace are rejected.
+fn parse_integer(text: &str) -> Result<i32> {
+    const ZEROES: &[u32] = &[
+        0x0030, 0x0660, 0x06f0, 0x07c0, 0x0966, 0x09e6, 0x0a66, 0x0ae6, 
0x0b66, 0x0be6, 0x0c66,
+        0x0ce6, 0x0d66, 0x0de6, 0x0e50, 0x0ed0, 0x0f20, 0x1040, 0x1090, 
0x17e0, 0x1810, 0x1946,
+        0x19d0, 0x1a80, 0x1a90, 0x1b50, 0x1bb0, 0x1c40, 0x1c50, 0xa620, 
0xa8d0, 0xa900, 0xa9d0,
+        0xa9f0, 0xaa50, 0xabf0, 0xff10,
+    ];
+    let (negative, digits) = match text.strip_prefix('-') {
+        Some(digits) => (true, digits),
+        None => (false, text.strip_prefix('+').unwrap_or(text)),
+    };
+    if digits.is_empty() {
+        return Err(invalid("empty transform position"));
+    }
+    // Accumulate negatively, so i32::MIN remains representable.
+    let mut value = 0i32;
+    for ch in digits.chars() {
+        let digit = ZEROES
+            .iter()
+            .find_map(|zero| {
+                (u32::from(ch) >= *zero && u32::from(ch) < zero + 10)
+                    .then(|| (u32::from(ch) - zero) as i32)
+            })
+            .ok_or_else(|| invalid("invalid Java integer position"))?;
+        value = value
+            .checked_mul(10)
+            .and_then(|v| v.checked_sub(digit))
+            .ok_or_else(|| invalid("transform position exceeds Java INT 
range"))?;
+    }
+    if negative {
+        Ok(value)
+    } else {
+        value
+            .checked_neg()
+            .ok_or_else(|| invalid("transform position exceeds Java INT 
range"))
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::spec::{BigIntType, DataField, DataType, VarCharType};
+
+    fn apply(rule: &str, source: Vec<Option<&str>>) -> Result<ArrayRef> {
+        let fields = vec![DataField::new(
+            0,
+            "s".into(),
+            DataType::VarChar(VarCharType::string_type()),
+        )];
+        let transform = Transform::from_rest_json(rule, &fields)?;
+        let rows = source.len();
+        let source = Arc::new(StringArray::from(source)) as ArrayRef;
+        evaluate_string_transform(&transform, rows, &|_| Ok(source.clone()))
+    }
+
+    #[test]
+    fn substring_positions_are_parsed_after_null_checks() {
+        for position in [
+            r#""bad""#,
+            r#""999999999999999999999""#,
+            "18446744073709551617",
+        ] {
+            let rule = 
format!(r#"{{"name":"SUBSTRING","inputs":[{{"name":"s"}}, {position}]}}"#);
+            assert!(apply(&rule, vec![None]).unwrap().is_null(0));
+            assert!(apply(&rule, vec![Some("abc")]).is_err());
+            let rule =
+                format!(r#"{{"name":"SUBSTRING","inputs":[{{"name":"s"}}, 
{position}, null]}}"#);
+            assert!(apply(&rule, vec![Some("abc")]).unwrap().is_null(0));
+        }
+        let result = apply(
+            r#"{"name":"SUBSTRING","inputs":[{"name":"s"},"+٢",1]}"#,
+            vec![Some("abc")],
+        )
+        .unwrap();
+        assert_eq!(result.as_string::<i32>().value(0), "b");
+        for position in ["1.5", "1e2", "true"] {
+            assert!(apply(
+                
&format!(r#"{{"name":"SUBSTRING","inputs":[null,{position}]}}"#),
+                vec![None]
+            )
+            .is_err());
+        }
+    }
+
+    #[test]
+    fn integer_syntax_and_range_match_java() {
+        for (text, value) in [
+            ("-2147483648", i32::MIN),
+            ("+2147483647", i32::MAX),
+            ("٠٠٢", 2),
+            ("2", 2),
+        ] {
+            assert_eq!(parse_integer(text).unwrap(), value);
+        }
+        for text in ["", "+", " 2", "2 ", "1_0", "2147483648", "-2147483649", 
"𝟚"] {
+            assert!(parse_integer(text).is_err(), "{text}");
+        }
+    }
+
+    #[test]
+    fn pad_and_overlay_convert_integers_before_null_strings() {
+        for rule in [
+            
r#"{"name":"PAD","direction":"LEFT","inputs":[null,2147483648,"x"]}"#,
+            r#"{"name":"OVERLAY","inputs":[null,"x",2147483648,null]}"#,
+            r#"{"name":"OVERLAY","inputs":[null,"x",null,2147483648]}"#,
+        ] {
+            assert!(apply(rule, vec![None]).is_err());
+        }
+    }
+
+    #[test]
+    fn mixed_fields_are_bound_by_name_and_checked_before_execution() {
+        let fields = vec![
+            DataField::new(0, "s".into(), 
DataType::VarChar(VarCharType::string_type())),
+            DataField::new(1, "n".into(), DataType::BigInt(BigIntType::new())),
+        ];
+        for rule in [
+            
r#"{"name":"PAD","direction":"RIGHT","inputs":[{"name":"s","type":"INT"},3,"x"]}"#,
+            
r#"{"name":"PAD","direction":"RIGHT","inputs":["a",{"name":"n","type":"STRING"},"x"]}"#,
+            
r#"{"name":"OVERLAY","inputs":["a","x",{"name":"n","type":"STRING"}]}"#,
+            
r#"{"name":"TRIM","trimFlag":"BOTH","inputs":[{"name":"s","type":"INT"}]}"#,
+            r#"{"name":"SUBSTRING","inputs":["a",{"name":"s","type":"INT"}]}"#,
+        ] {
+            assert!(Transform::from_rest_json(rule, &fields).is_err(), 
"{rule}");
+        }
+        let transform = 
Transform::from_rest_json(r#"{"name":"SUBSTRING","inputs":[{"index":99,"name":"s","type":"INT"},{"index":99,"name":"n","type":"STRING"},1]}"#,
 &fields).unwrap();
+        let columns: Vec<ArrayRef> = vec![
+            Arc::new(StringArray::from(vec![Some("甲🙂乙"), None])),
+            Arc::new(Int64Array::from(vec![2, i64::MAX])),
+        ];
+        let result =
+            evaluate_string_transform(&transform, 2, &|index| 
Ok(columns[index].clone())).unwrap();
+        assert_eq!(
+            result.as_string::<i32>().iter().collect::<Vec<_>>(),
+            vec![Some("🙂"), None]
+        );
+    }
+
+    #[test]
+    fn lengths_count_unicode_characters_and_utf8_bits_as_int() {
+        for (name, expected) in [
+            ("LENGTH", vec![Some(3), Some(2), Some(0), None]),
+            ("BIT_LENGTH", vec![Some(80), Some(24), Some(0), None]),
+        ] {
+            let result = apply(
+                
&format!(r#"{{"name":"{name}","inputs":[{{"name":"s","type":"STRING"}}]}}"#),
+                vec![Some("甲🙂乙"), Some("e\u{301}"), Some(""), None],
+            )
+            .unwrap();
+            assert_eq!(
+                result
+                    .as_primitive::<arrow_array::types::Int32Type>()
+                    .iter()
+                    .collect::<Vec<_>>(),
+                expected
+            );
+        }
+    }
+}
diff --git a/crates/paimon/src/spec/mod.rs b/crates/paimon/src/spec/mod.rs
index 7969f9b3..a108f89e 100644
--- a/crates/paimon/src/spec/mod.rs
+++ b/crates/paimon/src/spec/mod.rs
@@ -133,8 +133,8 @@ pub(crate) use predicate::like_match;
 pub(crate) use predicate::row_id_leaf;
 pub(crate) use predicate::validate_datum_matches_type;
 pub use predicate::{
-    field_idx_to_partition_idx, Datum, Predicate, PredicateBuilder, 
PredicateOperator, Transform,
-    TransformInput,
+    field_idx_to_partition_idx, Datum, PadDirection, Predicate, 
PredicateBuilder,
+    PredicateOperator, Transform, TransformInput, TrimFlag,
 };
 pub(crate) mod murmur_hash;
 mod partition_statistics;
diff --git a/crates/paimon/src/spec/predicate.rs 
b/crates/paimon/src/spec/predicate.rs
index 21c5ec81..79b4b25b 100644
--- a/crates/paimon/src/spec/predicate.rs
+++ b/crates/paimon/src/spec/predicate.rs
@@ -1206,13 +1206,51 @@ pub enum Transform {
     Concat(Vec<TransformInput>),
     /// Concatenation where the first input is the separator.
     ConcatWs(Vec<TransformInput>),
+    /// SQL substring with one-based, zero or negative positions.
+    Substring(Vec<TransformInput>),
+    /// Remove whole characters at the requested ends.
+    Trim(Vec<TransformInput>, TrimFlag),
+    /// Number of Unicode characters, as INT.
+    Length(TransformInput),
+    /// Number of UTF-8 bits, as INT.
+    BitLength(TransformInput),
+    /// SQL LPAD/RPAD.
+    Pad(Vec<TransformInput>, PadDirection),
+    /// Map or delete Unicode characters.
+    Translate(Vec<TransformInput>),
+    /// SQL overlay with optional replacement length.
+    Overlay(Vec<TransformInput>),
 }
 
-/// One input of a string [`Transform`].
+/// The ends selected by Java's TRIM transform.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum TrimFlag {
+    /// Remove leading characters.
+    Leading,
+    /// Remove trailing characters.
+    Trailing,
+    /// Remove characters at both ends.
+    Both,
+}
+
+/// The side selected by Java's PAD transform.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum PadDirection {
+    /// Prefix the padding.
+    Left,
+    /// Append the padding.
+    Right,
+}
+
+/// One input of a [`Transform`].
 #[derive(Debug, Clone, PartialEq)]
 pub enum TransformInput {
     /// A string literal (`None` = a JSON `null` input, i.e. SQL NULL).
     Literal(Option<String>),
+    /// An integer position or length, checked as a Java int when evaluated.
+    Integer(Option<i64>),
+    /// SUBSTRING parses string and large integral positions only after NULL 
checks.
+    IntegerText(String),
     /// A field reference.
     Field(usize),
 }
@@ -1233,11 +1271,8 @@ impl Transform {
             Transform::FieldRef(index) | Transform::Cast(index, _) => {
                 out.insert(*index);
             }
-            Transform::Upper(inputs)
-            | Transform::Lower(inputs)
-            | Transform::Concat(inputs)
-            | Transform::ConcatWs(inputs) => {
-                for input in inputs {
+            _ => {
+                for input in self.inputs() {
                     if let TransformInput::Field(index) = input {
                         out.insert(*index);
                     }
@@ -1245,6 +1280,22 @@ impl Transform {
             }
         }
     }
+
+    pub(crate) fn inputs(&self) -> &[TransformInput] {
+        match self {
+            Self::Upper(inputs)
+            | Self::Lower(inputs)
+            | Self::Concat(inputs)
+            | Self::ConcatWs(inputs)
+            | Self::Substring(inputs)
+            | Self::Trim(inputs, _)
+            | Self::Pad(inputs, _)
+            | Self::Translate(inputs)
+            | Self::Overlay(inputs) => inputs,
+            Self::Length(input) | Self::BitLength(input) => 
std::slice::from_ref(input),
+            _ => &[],
+        }
+    }
 }
 
 fn parse_rest_transform(value: &serde_json::Value, fields: &[DataField]) -> 
Result<Transform> {
@@ -1273,6 +1324,7 @@ fn parse_rest_transform(value: &serde_json::Value, 
fields: &[DataField]) -> Resu
                 serde_json::Value::Null => Ok(TransformInput::Literal(None)),
                 serde_json::Value::Object(_) => {
                     let index = field_ref_index(input)?;
+                    check_input_type(input, false)?;
                     match fields[index].data_type() {
                         DataType::Char(_) | DataType::VarChar(_) => {
                             Ok(TransformInput::Field(index))
@@ -1319,10 +1371,171 @@ fn parse_rest_transform(value: &serde_json::Value, 
fields: &[DataField]) -> Resu
         "LOWER" => Ok(Transform::Lower(string_inputs(1, 1)?)),
         "CONCAT" => Ok(Transform::Concat(string_inputs(0, usize::MAX)?)),
         "CONCAT_WS" => Ok(Transform::ConcatWs(string_inputs(2, usize::MAX)?)),
+        "SUBSTRING" => Ok(Transform::Substring(transform_inputs(
+            value,
+            fields,
+            2,
+            3,
+            &[1, 2],
+            true,
+        )?)),
+        "TRIM" => {
+            let flag = match str_prop(value, "trimFlag")? {
+                "LEADING" => TrimFlag::Leading,
+                "TRAILING" => TrimFlag::Trailing,
+                "BOTH" => TrimFlag::Both,
+                flag => return Err(rest_json_err(format!("invalid trimFlag 
`{flag}`"))),
+            };
+            Ok(Transform::Trim(string_inputs(1, 2)?, flag))
+        }
+        "LENGTH" => Ok(Transform::Length(string_inputs(1, 1)?.remove(0))),
+        "BIT_LENGTH" => Ok(Transform::BitLength(string_inputs(1, 
1)?.remove(0))),
+        "PAD" => {
+            let direction = match str_prop(value, "direction")? {
+                "LEFT" => PadDirection::Left,
+                "RIGHT" => PadDirection::Right,
+                direction => {
+                    return Err(rest_json_err(format!(
+                        "invalid PAD direction `{direction}`"
+                    )))
+                }
+            };
+            Ok(Transform::Pad(
+                transform_inputs(value, fields, 3, 3, &[1], false)?,
+                direction,
+            ))
+        }
+        "TRANSLATE" => Ok(Transform::Translate(string_inputs(3, 3)?)),
+        "OVERLAY" => Ok(Transform::Overlay(transform_inputs(
+            value,
+            fields,
+            3,
+            4,
+            &[2, 3],
+            false,
+        )?)),
         other => Err(rest_json_err(format!("transform `{other}`"))),
     }
 }
 
+/// Bind mixed string/integer inputs by name to the actual table schema.
+fn transform_inputs(
+    value: &serde_json::Value,
+    fields: &[DataField],
+    min: usize,
+    max: usize,
+    integers: &[usize],
+    string_positions: bool,
+) -> Result<Vec<TransformInput>> {
+    let inputs = value
+        .get("inputs")
+        .and_then(serde_json::Value::as_array)
+        .ok_or_else(|| rest_json_err("transform without inputs"))?;
+    if !(min..=max).contains(&inputs.len()) {
+        return Err(rest_json_err(format!(
+            "transform expects {min}..={max} inputs, got {}",
+            inputs.len()
+        )));
+    }
+    inputs
+        .iter()
+        .enumerate()
+        .map(|(slot, input)| {
+            let integer = integers.contains(&slot);
+            match input {
+                serde_json::Value::Null => Ok(if integer {
+                    TransformInput::Integer(None)
+                } else {
+                    TransformInput::Literal(None)
+                }),
+                serde_json::Value::String(text) if !integer => {
+                    Ok(TransformInput::Literal(Some(text.clone())))
+                }
+                serde_json::Value::String(text) if string_positions => {
+                    Ok(TransformInput::IntegerText(text.clone()))
+                }
+                serde_json::Value::Number(number) if integer => {
+                    // arbitrary_precision retains the lexical integer even 
beyond
+                    // i64. SUBSTRING must not convert it before row NULL 
checks.
+                    let text = number.to_string();
+                    if text
+                        .strip_prefix('-')
+                        .unwrap_or(&text)
+                        .bytes()
+                        .all(|c| c.is_ascii_digit())
+                    {
+                        match number.as_i64() {
+                            Some(number) => 
Ok(TransformInput::Integer(Some(number))),
+                            None if string_positions => 
Ok(TransformInput::IntegerText(text)),
+                            None => Err(rest_json_err("position exceeds BIGINT 
range")),
+                        }
+                    } else {
+                        Err(rest_json_err("position must be an integral 
number"))
+                    }
+                }
+                serde_json::Value::Object(_) => {
+                    let name = str_prop(input, "name")?;
+                    if !string_positions {
+                        check_input_type(input, integer)?;
+                    } else if let Some(stored) = 
input.get("type").filter(|value| !value.is_null())
+                    {
+                        serde_json::from_value::<DataType>(stored.clone())
+                            .map_err(rest_json_err)?;
+                    }
+                    let index = fields
+                        .iter()
+                        .position(|field| field.name() == name)
+                        .ok_or_else(|| rest_json_err(format!("unknown field 
`{name}`")))?;
+                    let valid = if integer {
+                        matches!(
+                            fields[index].data_type(),
+                            DataType::TinyInt(_)
+                                | DataType::SmallInt(_)
+                                | DataType::Int(_)
+                                | DataType::BigInt(_)
+                        )
+                    } else {
+                        matches!(
+                            fields[index].data_type(),
+                            DataType::Char(_) | DataType::VarChar(_)
+                        )
+                    };
+                    if !valid {
+                        return Err(rest_json_err(format!(
+                            "invalid type for transform input `{name}`"
+                        )));
+                    }
+                    Ok(TransformInput::Field(index))
+                }
+                _ => Err(rest_json_err(format!("invalid transform input 
{input}"))),
+            }
+        })
+        .collect()
+}
+
+/// Java checks the wire FieldRef family before replacing it from the schema.
+/// SUBSTRING deliberately allows arbitrary stored families and validates the
+/// rebound field at execution instead.
+fn check_input_type(input: &serde_json::Value, integer: bool) -> Result<()> {
+    let stored: DataType = input
+        .get("type")
+        .and_then(|value| serde_json::from_value(value.clone()).ok())
+        .ok_or_else(|| rest_json_err("transform field without a parseable 
type"))?;
+    let valid = if integer {
+        matches!(
+            stored,
+            DataType::TinyInt(_) | DataType::SmallInt(_) | DataType::Int(_) | 
DataType::BigInt(_)
+        )
+    } else {
+        matches!(stored, DataType::Char(_) | DataType::VarChar(_))
+    };
+    if valid {
+        Ok(())
+    } else {
+        Err(rest_json_err("invalid stored type for transform field"))
+    }
+}
+
 // ---------------------------------------------------------------------------
 // PredicateBuilder
 // ---------------------------------------------------------------------------
diff --git a/crates/paimon/src/spec/schema.rs b/crates/paimon/src/spec/schema.rs
index 2377b4be..69b13bbe 100644
--- a/crates/paimon/src/spec/schema.rs
+++ b/crates/paimon/src/spec/schema.rs
@@ -178,6 +178,36 @@ impl TableSchema {
     /// fail, so the stored value wins instead, and an absent one leaves the
     /// default standing.
     pub fn copy_with_options(&self, mut extra: HashMap<String, String>) -> 
Self {
+        self.retain_immutable_options(&mut extra);
+        let mut new_schema = self.clone();
+        new_schema.options.extend(extra);
+        new_schema
+    }
+
+    /// REST returns a Schema without its creation timestamp. Independently
+    /// loaded copies can have different local timestamps while describing the
+    /// same schema. Options are compared separately when copying a handle.
+    pub(crate) fn has_same_definition(&self, other: &Self) -> bool {
+        self.version == other.version
+            && self.id == other.id
+            && self.fields == other.fields
+            && self.highest_field_id == other.highest_field_id
+            && self.partition_keys == other.partition_keys
+            && self.primary_keys == other.primary_keys
+            && self.comment == other.comment
+    }
+
+    /// Replace dynamic options without allowing an override of stored 
immutable
+    /// options. Unlike merging, absence in this map removes a mutable option.
+    pub(crate) fn copy_with_replaced_dynamic_options(
+        &self,
+        mut options: HashMap<String, String>,
+    ) -> Self {
+        self.retain_immutable_options(&mut options);
+        self.copy_with_replaced_options(options)
+    }
+
+    fn retain_immutable_options(&self, extra: &mut HashMap<String, String>) {
         if self.core_options().query_auth_enabled() {
             extra.insert(QUERY_AUTH_ENABLED_OPTION.to_string(), 
"true".to_string());
         }
@@ -194,9 +224,6 @@ impl TableSchema {
                 None => extra.remove(key),
             };
         }
-        let mut new_schema = self.clone();
-        new_schema.options.extend(extra);
-        new_schema
     }
 
     /// Create a copy of this schema with the options replaced entirely,
diff --git a/crates/paimon/src/table/goldens/split_v1_query_auth.bin 
b/crates/paimon/src/table/goldens/split_v1_query_auth.bin
new file mode 100644
index 00000000..3978a439
Binary files /dev/null and 
b/crates/paimon/src/table/goldens/split_v1_query_auth.bin differ
diff --git a/crates/paimon/src/table/mod.rs b/crates/paimon/src/table/mod.rs
index ff1db75b..bd8f2cd5 100644
--- a/crates/paimon/src/table/mod.rs
+++ b/crates/paimon/src/table/mod.rs
@@ -645,11 +645,33 @@ impl Table {
     /// and does not perform time travel. Unlike that constructor, it retains 
the
     /// FileIO provider, REST environment and identity of this table. The 
branch
     /// selects its metadata namespace without loading a different schema.
+    /// An options-only replacement of the catalog-loaded schema keeps its REST
+    /// authorization session and the stored immutable options.
     /// Any cached time-travel resolution is discarded: subsequent scans 
resolve
     /// the supplied options without replacing the supplied fields.
     pub fn copy_with_resolved_schema(&self, schema: TableSchema, branch: &str) 
-> Result<Self> {
         schema.validate_resolved_structure()?;
         validate_branch_name(branch)?;
+        // An options-only copy still reads the exact schema loaded by the
+        // catalog. Preserve its session through the existing dynamic-option
+        // path; supplied fields, IDs, keys, comments and branch must all 
match.
+        // REST synthesizes creation times independently in different clients.
+        if self.query_auth_session.is_some()
+            && !self.time_traveled
+            && self.travel_snapshot.is_none()
+            && branch == self.branch
+            && schema.has_same_definition(&self.schema)
+        {
+            return Ok(Self {
+                schema: self
+                    .schema
+                    
.copy_with_replaced_dynamic_options(schema.options().clone()),
+                time_traveled: false,
+                travel_snapshot: None,
+                applied_dynamic_option_keys: HashSet::new(),
+                ..self.clone()
+            });
+        }
         let schema_manager = SchemaManager::new(self.file_io.clone(), 
self.location.clone());
         let schema_manager = if branch == DEFAULT_MAIN_BRANCH {
             schema_manager
diff --git a/crates/paimon/src/table/query_auth.rs 
b/crates/paimon/src/table/query_auth.rs
index 3ae7d349..3de5625a 100644
--- a/crates/paimon/src/table/query_auth.rs
+++ b/crates/paimon/src/table/query_auth.rs
@@ -24,12 +24,20 @@ pub(crate) use rules::{filter_batch, mask_batch, 
mask_inputs, ColumnMask, Rules}
 
 /// The server's answer for one user on one table; `session` ties it to the
 /// handle that asked, as the response names no table or user.
-#[derive(Debug, PartialEq)]
+#[derive(Debug)]
 pub(crate) struct QueryAuthGrant {
     session: u64,
     /// The columns the request asked about; `None` asked about the whole 
table.
     select: Option<Vec<String>>,
     rules: Rules,
+    /// Original wire rules for Java QueryAuthSplit metadata transport.
+    response: Option<AuthTableQueryResponse>,
+}
+
+impl PartialEq for QueryAuthGrant {
+    fn eq(&self, other: &Self) -> bool {
+        self.session == other.session && self.select == other.select && 
self.rules == other.rules
+    }
 }
 
 impl QueryAuthGrant {
@@ -40,7 +48,9 @@ impl QueryAuthGrant {
         select: Option<Vec<String>>,
         fields: &[crate::spec::DataField],
     ) -> crate::Result<Self> {
-        Ok(Self::new(session, select, Rules::parse(response, fields)?))
+        let mut grant = Self::new(session, select, Rules::parse(response, 
fields)?);
+        grant.response = Some(response.clone());
+        Ok(grant)
     }
 
     pub(crate) fn new(session: u64, select: Option<Vec<String>>, rules: Rules) 
-> Self {
@@ -48,9 +58,14 @@ impl QueryAuthGrant {
             session,
             select,
             rules,
+            response: None,
         }
     }
 
+    pub(crate) fn response(&self) -> Option<&AuthTableQueryResponse> {
+        self.response.as_ref()
+    }
+
     /// No row filter and no masking.
     pub(crate) fn is_unrestricted(&self) -> bool {
         self.rules.is_empty()
@@ -405,13 +420,83 @@ mod tests {
     }
 
     #[tokio::test]
-    async fn test_a_schema_replaced_copy_loses_its_session() {
+    async fn test_options_only_schema_replacement_keeps_its_session() {
         let table = crate::table::rest_query_auth_table().await;
         assert!(table.query_auth_session().is_some());
+        let mut json = serde_json::to_value(table.schema()).unwrap();
+        json["timeMillis"] = serde_json::json!(1);
         let copy = table
-            .copy_with_resolved_schema(table.schema().clone(), "main")
+            .copy_with_resolved_schema(serde_json::from_value(json).unwrap(), 
"main")
             .unwrap();
-        assert!(copy.query_auth_session().is_none());
+        assert_eq!(copy.query_auth_session(), table.query_auth_session());
+        let table = table.copy_with_options(std::collections::HashMap::from([
+            ("scan.snapshot-id".into(), "7".into()),
+            ("read.batch-size".into(), "99".into()),
+        ]));
+        let schema = table
+            .schema()
+            .copy_with_replaced_options(std::collections::HashMap::from([
+                ("query-auth.enabled".into(), "false".into()),
+                ("type".into(), "format".into()),
+                ("data-file.path-directory".into(), "/tmp/other".into()),
+            ]));
+        let copy = table.copy_with_resolved_schema(schema, "main").unwrap();
+        assert_eq!(copy.query_auth_session(), table.query_auth_session());
+        assert!(copy.schema().core_options().query_auth_enabled());
+        for key in [
+            "scan.snapshot-id",
+            "read.batch-size",
+            "type",
+            "data-file.path-directory",
+        ] {
+            assert!(!copy.schema().options().contains_key(key), "{key}");
+        }
+        assert!(!copy.reads_another_schema().unwrap());
+    }
+
+    #[tokio::test]
+    async fn test_changed_schema_or_branch_loses_its_session() {
+        let table = crate::table::rest_query_auth_table().await;
+        let mut json = serde_json::to_value(table.schema()).unwrap();
+        json["id"] = serde_json::json!(table.schema().id() + 1);
+        let changed = serde_json::from_value(json).unwrap();
+        assert!(table
+            .copy_with_resolved_schema(changed, "main")
+            .unwrap()
+            .query_auth_session()
+            .is_none());
+        assert!(table
+            .copy_with_resolved_schema(table.schema().clone(), "test")
+            .unwrap()
+            .query_auth_session()
+            .is_none());
+        let assembled = crate::table::query_auth_table();
+        assert!(assembled
+            .copy_with_resolved_schema(assembled.schema().clone(), "main")
+            .unwrap()
+            .query_auth_session()
+            .is_none());
+    }
+
+    #[tokio::test]
+    async fn 
test_resolved_schema_copy_cannot_reauthorize_a_historical_handle() {
+        let table = crate::table::rest_query_auth_table().await;
+        let grant = super::QueryAuthGrant::new(
+            table.query_auth_session().unwrap(),
+            None,
+            Default::default(),
+        );
+        let mut travelled = table.clone();
+        travelled.time_traveled = true;
+        let pinned = table.copy_with_pinned_snapshot(None);
+        for view in [travelled, pinned] {
+            let schema = view
+                .schema()
+                .copy_with_replaced_options(table.schema().options().clone());
+            let copy = view.copy_with_resolved_schema(schema, "main").unwrap();
+            assert!(copy.query_auth_session().is_none());
+            assert!(!grant.matches_table(&copy));
+        }
     }
 
     #[tokio::test]
diff --git a/crates/paimon/src/table/query_auth/rules.rs 
b/crates/paimon/src/table/query_auth/rules.rs
index 08650011..d1e71ac0 100644
--- a/crates/paimon/src/table/query_auth/rules.rs
+++ b/crates/paimon/src/table/query_auth/rules.rs
@@ -391,6 +391,9 @@ pub(crate) fn mask_batch(
             | Transform::Lower(_)
             | Transform::Concat(_)
             | Transform::ConcatWs(_) => string_mask(batch, &mask.transform, 
&input)?,
+            _ => {
+                crate::arrow::evaluate_string_transform(&mask.transform, 
batch.num_rows(), &input)?
+            }
         };
         let masked = cast_to(&masked, &target_type)?;
         for position in targets {
@@ -408,6 +411,8 @@ pub(crate) fn mask_batch(
 fn mask_can_be_null(transform: &Transform, fields: &[DataField]) -> bool {
     let nullable = |input: &TransformInput| match input {
         TransformInput::Literal(literal) => literal.is_none(),
+        TransformInput::Integer(value) => value.is_none(),
+        TransformInput::IntegerText(_) => false,
         TransformInput::Field(index) => 
fields[*index].data_type().is_nullable(),
     };
     match transform {
@@ -415,11 +420,9 @@ fn mask_can_be_null(transform: &Transform, fields: 
&[DataField]) -> bool {
         Transform::FieldRef(index) | Transform::Cast(index, _) => {
             fields[*index].data_type().is_nullable()
         }
-        Transform::Upper(inputs) | Transform::Lower(inputs) | 
Transform::Concat(inputs) => {
-            inputs.iter().any(nullable)
-        }
         // Only a null separator makes CONCAT_WS null.
         Transform::ConcatWs(inputs) => inputs.first().is_some_and(nullable),
+        _ => transform.inputs().iter().any(nullable),
     }
 }
 
@@ -431,10 +434,8 @@ fn mask_output_type(transform: &Transform, fields: 
&[DataField]) -> Option<arrow
             crate::arrow::paimon_type_to_arrow(fields[*index].data_type()).ok()
         }
         Transform::Cast(_, to) => crate::arrow::paimon_type_to_arrow(to).ok(),
-        Transform::Upper(_)
-        | Transform::Lower(_)
-        | Transform::Concat(_)
-        | Transform::ConcatWs(_) => Some(arrow_schema::DataType::Utf8),
+        Transform::Length(_) | Transform::BitLength(_) => 
Some(arrow_schema::DataType::Int32),
+        _ => Some(arrow_schema::DataType::Utf8),
     }
 }
 
@@ -536,6 +537,9 @@ fn string_mask(
         .iter()
         .map(|i| match i {
             TransformInput::Literal(literal) => 
Ok(StringInput::Literal(literal.as_deref())),
+            TransformInput::Integer(_) | TransformInput::IntegerText(_) => {
+                Err(unsupported("string mask has an integer input"))
+            }
             TransformInput::Field(index) => Ok(StringInput::Column(
                 cast_to(&input(*index)?, &arrow_schema::DataType::Utf8)?
                     .as_string::<i32>()
@@ -651,6 +655,50 @@ mod tests {
         DataType::VarChar(VarCharType::string_type())
     }
 
+    #[test]
+    fn extended_string_masks_match_java_character_semantics() {
+        let fields = vec![DataField::new(0, "value".into(), string())];
+        let source = batch(
+            &fields,
+            vec![Arc::new(StringArray::from(vec![
+                Some("甲🙂乙"),
+                Some("abcabc"),
+                Some(""),
+                None,
+            ]))],
+        );
+        for (rule, expected) in [
+            (
+                
r#"{"name":"SUBSTRING","inputs":[{"name":"value","type":"STRING"},2,2]}"#,
+                vec![Some("🙂乙"), Some("bc"), Some(""), None],
+            ),
+            (
+                
r#"{"name":"TRIM","trimFlag":"BOTH","inputs":[{"name":"value","type":"STRING"},"甲乙ac"]}"#,
+                vec![Some("🙂"), Some("bcab"), Some(""), None],
+            ),
+            (
+                
r#"{"name":"PAD","direction":"LEFT","inputs":[{"name":"value","type":"STRING"},5,"🙂x"]}"#,
+                vec![Some("🙂x甲🙂乙"), Some("abcab"), Some("🙂x🙂x🙂"), None],
+            ),
+            (
+                
r#"{"name":"TRANSLATE","inputs":[{"name":"value","type":"STRING"},"甲🙂乙aba","XY"]}"#,
+                vec![Some("XY"), Some("cc"), Some(""), None],
+            ),
+            (
+                
r#"{"name":"OVERLAY","inputs":[{"name":"value","type":"STRING"},"x",2,1]}"#,
+                vec![Some("甲x乙"), Some("axcabc"), Some("x"), None],
+            ),
+        ] {
+            let rules = parse(&[], &[("value", rule)], &fields).unwrap();
+            let result = mask_batch(&source, &rules.masks, &fields, 
&fields).unwrap();
+            assert_eq!(
+                result.column(0).as_string::<i32>(),
+                &StringArray::from(expected),
+                "{rule}"
+            );
+        }
+    }
+
     fn batch(fields: &[DataField], columns: Vec<ArrayRef>) -> RecordBatch {
         let schema: Vec<Field> = fields
             .iter()
diff --git a/crates/paimon/src/table/source.rs 
b/crates/paimon/src/table/source.rs
index 11d6bc3f..593f432c 100644
--- a/crates/paimon/src/table/source.rs
+++ b/crates/paimon/src/table/source.rs
@@ -771,8 +771,8 @@ impl DataSplit {
         DataSplitBuilder::new()
     }
 
-    /// The Java-compatible frames have no field for the marker, so a reader 
would
-    /// rebuild the split without it. Serde keeps it; these two must refuse.
+    /// A bare DataSplit frame has no authorization marker and must refuse.
+    /// SplitSerializer can instead transport the Java QueryAuthSplit wrapper.
     fn ensure_serializable_without_grant(&self) -> crate::Result<()> {
         if self.query_auth_required {
             return Err(crate::table::query_auth::unsupported(
@@ -940,7 +940,8 @@ impl DataSplit {
 
     /// Java `SplitSerializer#serialize` frame: magic + version + type id + 
body. The cross-language
     /// entry point. A plain split serializes as `DataSplit` (type 1); a split 
carrying row ranges as
-    /// `IndexedSplit` (type 3) wrapping the DataSplit body plus the ranges. 
Byte-compatible with
+    /// `IndexedSplit` (type 3) wrapping the DataSplit body plus the ranges. 
Authorized metadata
+    /// uses `QueryAuthSplit` (type 5), retaining the complete captured REST 
response. Byte-compatible with
     /// `compatibility/split-v1-data` / `split-v1-indexed`.
     pub fn serialize_split_v1(&self) -> crate::Result<Vec<u8>> {
         self.serialize_split_v1_with_scores(None)
@@ -949,6 +950,29 @@ impl DataSplit {
     /// Serialize aligned vector scores using Java's IndexedSplit wire format.
     /// Scores belong to the selection, in ascending expanded-range order.
     pub fn serialize_split_v1_with_scores(&self, scores: Option<&[f32]>) -> 
crate::Result<Vec<u8>> {
+        if self.query_auth_required {
+            let response = self
+                .query_auth_grant
+                .as_ref()
+                .and_then(|grant| grant.response())
+                .ok_or_else(|| {
+                    crate::table::query_auth::unsupported(
+                        "the split has no live wire authorization",
+                    )
+                })?;
+            // The nested DataSplit contains metadata only. The outer frame
+            // carries the rules and cannot decode as an ordinary DataSplit.
+            let mut metadata = self.clone();
+            metadata.query_auth_required = false;
+            metadata.query_auth_grant = None;
+            let mut out = Vec::new();
+            out.extend_from_slice(&SPLIT_SER_MAGIC.to_be_bytes());
+            out.extend_from_slice(&SPLIT_SER_VERSION.to_be_bytes());
+            
out.extend_from_slice(&SPLIT_SER_TYPE_QUERY_AUTH_SPLIT.to_be_bytes());
+            
out.extend_from_slice(&metadata.serialize_split_v1_with_scores(scores)?);
+            write_auth_response(&mut out, response)?;
+            return Ok(out);
+        }
         self.ensure_serializable_without_grant()?;
         if let Some(scores) = scores {
             let count = self.row_ranges.as_ref().and_then(|ranges| {
@@ -999,42 +1023,68 @@ impl DataSplit {
     }
 
     /// Reverse of [`DataSplit::serialize_split_v1`]: parse a Java 
`SplitSerializer` frame into a
-    /// `DataSplit` (type 1) or an `IndexedSplit` (type 3, which carries 
`row_ranges`). Other split
-    /// type ids, and IndexedSplit vector scores, are unsupported. Consumes 
the entire buffer.
+    /// `DataSplit` (type 1), an `IndexedSplit` (type 3, which carries 
`row_ranges`), or a
+    /// `QueryAuthSplit` (type 5, retaining the authorization-required marker 
without minting
+    /// a grant). Other type ids, and IndexedSplit vector scores, are 
unsupported.
+    /// Consumes the entire buffer.
     pub fn deserialize_split_v1(data: &[u8]) -> crate::Result<DataSplit> {
         let mut cur = data;
-        let magic = read_i64(&mut cur)?;
+        let split = Self::read_split_v1(&mut cur, 0)?;
+        if !cur.is_empty() {
+            return Err(crate::Error::DataInvalid {
+                message: format!("{} trailing bytes after SPLIT_V1 frame", 
cur.len()),
+                source: None,
+            });
+        }
+        Ok(split)
+    }
+
+    fn read_split_v1(cur: &mut &[u8], depth: usize) -> 
crate::Result<DataSplit> {
+        if depth >= 32 {
+            return Err(crate::Error::DataInvalid {
+                message: "QueryAuthSplit nesting exceeds 32".into(),
+                source: None,
+            });
+        }
+        let magic = read_i64(cur)?;
         if magic != SPLIT_SER_MAGIC {
             return Err(crate::Error::DataInvalid {
                 message: format!("invalid SPLIT_V1 magic: {magic:#018x}"),
                 source: None,
             });
         }
-        let version = read_i32(&mut cur)?;
+        let version = read_i32(cur)?;
         if version != SPLIT_SER_VERSION {
             return Err(crate::Error::Unsupported {
                 message: format!("SplitSerializer version {version} not 
supported"),
             });
         }
-        let type_id = read_i32(&mut cur)?;
+        let type_id = read_i32(cur)?;
         let split = match type_id {
-            SPLIT_SER_TYPE_DATA_SPLIT => Self::read_body(&mut cur)?,
+            SPLIT_SER_TYPE_QUERY_AUTH_SPLIT => {
+                let split = Self::read_split_v1(cur, depth + 1)?;
+                read_auth_response(cur)?;
+                // Rules transported in a frame are not a grant minted by this
+                // catalog session. Reading it still requires the original 
split.
+                split.planned(None)
+            }
+            SPLIT_SER_TYPE_DATA_SPLIT => Self::read_body(cur)?,
             SPLIT_SER_TYPE_INDEXED_SPLIT => {
-                let im = read_i64(&mut cur)?;
+                let im = read_i64(cur)?;
                 if im != INDEXED_SPLIT_MAGIC {
                     return Err(crate::Error::DataInvalid {
                         message: format!("invalid IndexedSplit magic: 
{im:#018x}"),
                         source: None,
                     });
                 }
-                let iv = read_i32(&mut cur)?;
+                let iv = read_i32(cur)?;
                 if iv != INDEXED_SPLIT_VERSION {
                     return Err(crate::Error::Unsupported {
                         message: format!("IndexedSplit version {iv} not 
supported"),
                     });
                 }
-                let body = Self::read_body(&mut cur)?;
-                let ranges_n = read_i32(&mut cur)?;
+                let body = Self::read_body(cur)?;
+                let ranges_n = read_i32(cur)?;
                 if ranges_n < 0 {
                     return Err(crate::Error::DataInvalid {
                         message: "negative row_ranges count".into(),
@@ -1045,8 +1095,8 @@ impl DataSplit {
                 // 16 bytes (two i64), so a crafted huge count cannot 
over-reserve.
                 let mut ranges = Vec::with_capacity((ranges_n as 
usize).min(cur.len() / 16));
                 for _ in 0..ranges_n {
-                    let from = read_i64(&mut cur)?;
-                    let to = read_i64(&mut cur)?;
+                    let from = read_i64(cur)?;
+                    let to = read_i64(cur)?;
                     if from > to {
                         return Err(crate::Error::DataInvalid {
                             message: format!("row range from {from} > to 
{to}"),
@@ -1055,7 +1105,7 @@ impl DataSplit {
                     }
                     ranges.push(RowRange::new(from, to));
                 }
-                let scores_flag = read_u8(&mut cur)?;
+                let scores_flag = read_u8(cur)?;
                 if scores_flag != 0 {
                     return Err(crate::Error::Unsupported {
                         message: "IndexedSplit scores are not 
supported".into(),
@@ -1071,12 +1121,6 @@ impl DataSplit {
                 });
             }
         };
-        if !cur.is_empty() {
-            return Err(crate::Error::DataInvalid {
-                message: format!("{} trailing bytes after SPLIT_V1 frame", 
cur.len()),
-                source: None,
-            });
-        }
         Ok(split)
     }
 }
@@ -1094,6 +1138,87 @@ const SPLIT_SER_MAGIC: i64 = 0x53504C49545F5631; // 
"SPLIT_V1"
 const SPLIT_SER_VERSION: i32 = 1;
 const SPLIT_SER_TYPE_DATA_SPLIT: i32 = 1;
 const SPLIT_SER_TYPE_INDEXED_SPLIT: i32 = 3;
+const SPLIT_SER_TYPE_QUERY_AUTH_SPLIT: i32 = 5;
+
+fn write_auth_response(
+    out: &mut Vec<u8>,
+    response: &crate::api::AuthTableQueryResponse,
+) -> crate::Result<()> {
+    fn string(out: &mut Vec<u8>, value: &str) -> crate::Result<()> {
+        let length = i32::try_from(value.len()).map_err(|e| 
crate::Error::DataInvalid {
+            message: e.to_string(),
+            source: None,
+        })?;
+        out.extend_from_slice(&length.to_be_bytes());
+        out.extend_from_slice(value.as_bytes());
+        Ok(())
+    }
+    out.push(1);
+    match &response.filter {
+        None => out.push(0),
+        Some(filters) => {
+            out.push(1);
+            out.extend_from_slice(&(filters.len() as i32).to_be_bytes());
+            for filter in filters {
+                string(out, filter)?;
+            }
+        }
+    }
+    match &response.column_masking {
+        None => out.push(0),
+        Some(masks) => {
+            out.push(1);
+            out.extend_from_slice(&(masks.len() as i32).to_be_bytes());
+            let mut masks: Vec<_> = masks.iter().collect();
+            // Java's TreeMap compares UTF-16 units, including supplementary 
keys.
+            masks.sort_by(|(a, _), (b, _)| 
a.encode_utf16().cmp(b.encode_utf16()));
+            for (name, mask) in masks {
+                string(out, name)?;
+                string(out, mask)?;
+            }
+        }
+    }
+    Ok(())
+}
+
+/// Consume wire rules without creating a trusted grant from untrusted bytes.
+fn read_auth_response(cur: &mut &[u8]) -> crate::Result<()> {
+    fn string(cur: &mut &[u8]) -> crate::Result<()> {
+        let length = read_i32(cur)?;
+        if length < 0 {
+            return Err(crate::Error::DataInvalid {
+                message: "NULL authorization rule in QueryAuthSplit".into(),
+                source: None,
+            });
+        }
+        let bytes = take(cur, length as usize)?;
+        std::str::from_utf8(bytes).map_err(|e| crate::Error::DataInvalid {
+            message: e.to_string(),
+            source: None,
+        })?;
+        Ok(())
+    }
+    if read_u8(cur)? == 0 {
+        return Ok(());
+    }
+    for multiplier in [1, 2] {
+        if read_u8(cur)? != 0 {
+            let count = read_i32(cur)?;
+            if count < 0 || count as usize > cur.len() / (4 * multiplier) {
+                return Err(crate::Error::DataInvalid {
+                    message: "invalid QueryAuthSplit rule count".into(),
+                    source: None,
+                });
+            }
+            for _ in 0..count {
+                for _ in 0..multiplier {
+                    string(cur)?;
+                }
+            }
+        }
+    }
+    Ok(())
+}
 
 /// Java `IndexedSplit#MAGIC` / `VERSION`.
 const INDEXED_SPLIT_MAGIC: i64 = -938472394838495695;
@@ -2194,6 +2319,105 @@ mod tests {
         }
     }
 
+    #[test]
+    fn query_auth_frame_matches_java_and_does_not_restore_a_live_grant() {
+        let response = crate::api::AuthTableQueryResponse {
+            filter: Some(vec!["row甲🙂\0".into()]),
+            column_masking: Some(std::collections::HashMap::from([
+                ("\u{e000}".into(), "bmp".into()),
+                ("𐀀".into(), "supplementary".into()),
+            ])),
+        };
+        // Captured from Java QueryAuthSplit.serialize: UTF-8 with int length,
+        // NUL as one byte, and keys sorted by UTF-16 code units.
+        let expected = 
hex::decode("0101000000010000000b726f77e794b2f09f998200010000000200000004f09080800000000d737570706c656d656e7461727900000003ee808000000003626d70").unwrap();
+        let mut tail = Vec::new();
+        write_auth_response(&mut tail, &response).unwrap();
+        assert_eq!(tail, expected);
+        let grant =
+            
QueryAuthGrant::parse(&crate::api::AuthTableQueryResponse::default(), 1, None, 
&[])
+                .unwrap();
+        // Use a valid parsed policy for the live frame. The arbitrary strings
+        // above test transport independently of grant validation.
+        let mut frame_tail = Vec::new();
+        write_auth_response(&mut frame_tail, 
grant.response().unwrap()).unwrap();
+        let ordinary = v1_data_split_builder().build().unwrap();
+        let marked = ordinary.clone().planned(Some(Arc::new(grant)));
+        assert!(marked.serialize().is_err());
+        let frame = marked.serialize_split_v1().unwrap();
+        let mut expected_frame = Vec::new();
+        expected_frame.extend_from_slice(&SPLIT_SER_MAGIC.to_be_bytes());
+        expected_frame.extend_from_slice(&SPLIT_SER_VERSION.to_be_bytes());
+        
expected_frame.extend_from_slice(&SPLIT_SER_TYPE_QUERY_AUTH_SPLIT.to_be_bytes());
+        
expected_frame.extend_from_slice(&ordinary.serialize_split_v1().unwrap());
+        expected_frame.extend_from_slice(&frame_tail);
+        assert_eq!(frame, expected_frame);
+        let copy = DataSplit::deserialize_split_v1(&frame).unwrap();
+        assert!(copy.query_auth_required());
+        assert!(copy.query_auth_grant().is_none());
+        assert_eq!(copy.data_files(), ordinary.data_files());
+        assert!(copy.serialize().is_err());
+        assert!(copy.serialize_split_v1().is_err());
+    }
+
+    #[test]
+    fn deserialize_java_query_auth_golden_keeps_the_required_marker() {
+        // Java SplitSerializer(QueryAuthSplit(DataSplit v9, policy)) with BMP,
+        // supplementary and NUL text in its policy strings.
+        let split =
+            
DataSplit::deserialize_split_v1(include_bytes!("goldens/split_v1_query_auth.bin"))
+                .unwrap();
+        assert_eq!(split.bucket(), 3);
+        assert_eq!(split.data_files().len(), 2);
+        assert!(split.query_auth_required());
+        assert!(split.query_auth_grant().is_none());
+        assert!(split.serialize_split_v1().is_err());
+    }
+
+    #[test]
+    fn query_auth_frame_bounds_policy_lengths_and_nesting() {
+        let ordinary = v1_data_split_builder()
+            .build()
+            .unwrap()
+            .serialize_split_v1()
+            .unwrap();
+        let mut prefix = Vec::new();
+        prefix.extend_from_slice(&SPLIT_SER_MAGIC.to_be_bytes());
+        prefix.extend_from_slice(&SPLIT_SER_VERSION.to_be_bytes());
+        
prefix.extend_from_slice(&SPLIT_SER_TYPE_QUERY_AUTH_SPLIT.to_be_bytes());
+        let mut frame = prefix.clone();
+        frame.extend_from_slice(&ordinary);
+        let policy_start = frame.len();
+        let response = crate::api::AuthTableQueryResponse {
+            filter: Some(vec!["甲".repeat(30000)]),
+            column_masking: Some(Default::default()),
+        };
+        write_auth_response(&mut frame, &response).unwrap();
+        assert!(DataSplit::deserialize_split_v1(&frame)
+            .unwrap()
+            .query_auth_required());
+        for tail in [
+            vec![1, 0],
+            vec![1, 1, 0x7f, 0xff, 0xff, 0xff],
+            vec![1, 1, 0xff, 0xff, 0xff, 0xff],
+            vec![1, 1, 0, 0, 0, 1, 0xff, 0xff, 0xff, 0xff, 0],
+        ] {
+            frame.truncate(policy_start);
+            frame.extend_from_slice(&tail);
+            assert!(DataSplit::deserialize_split_v1(&frame).is_err());
+        }
+        let mut nested = prefix.repeat(33);
+        nested.extend_from_slice(&ordinary);
+        nested.extend_from_slice(&[0; 33]);
+        assert!(DataSplit::deserialize_split_v1(&nested).is_err());
+        frame.truncate(policy_start);
+        frame.extend_from_slice(&[0, 0]);
+        assert!(
+            DataSplit::deserialize_split_v1(&frame).is_err(),
+            "trailing bytes"
+        );
+    }
+
     #[test]
     fn deserialize_split_v1_rejects_huge_ranges_count_without_aborting() {
         let split = DataSplitBuilder::new()

Reply via email to