xiangfu0 commented on code in PR #18953:
URL: https://github.com/apache/pinot/pull/18953#discussion_r3561254017


##########
pinot-plugins/pinot-input-format/pinot-json/src/main/java/org/apache/pinot/plugin/inputformat/json/format/SqliteJsonbPayloadParser.java:
##########
@@ -0,0 +1,433 @@
+/**
+ * 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.
+ */
+package org.apache.pinot.plugin.inputformat.json.format;
+
+import com.google.common.collect.Maps;
+import java.math.BigInteger;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+
+/// Parses the <a href="https://sqlite.org/jsonb.html";>SQLite JSONB</a> binary 
format (SQLite 3.45+).
+///
+/// Every element is a 1–9 byte header followed by a payload. The header's 
first byte packs the element type in
+/// the low nibble and a payload-size descriptor in the high nibble; 
descriptors 12–15 mean the size is a big-
+/// endian `uint8` / `uint16` / `uint32` / `uint64` in the following bytes, 
and 0–11 are the size itself.
+///
+/// Numbers are stored as their ASCII text, so integers narrow to `Integer` / 
`Long` / `BigInteger` and floats
+/// to `Double`, matching the text-JSON value contract. `TEXTJ` strings/labels 
are JSON-unescaped (invalid
+/// escapes are rejected); `TEXT5` additionally accepts JSON5 escapes.
+///
+/// The decoder expects one row per payload, so the top-level element must be 
an `OBJECT`.
+class SqliteJsonbPayloadParser implements JsonPayloadParser {
+
+  // Element types (low nibble of the header's first byte).
+  private static final int TYPE_NULL = 0;
+  private static final int TYPE_TRUE = 1;
+  private static final int TYPE_FALSE = 2;
+  private static final int TYPE_INT = 3;      // canonical decimal integer, 
ASCII
+  private static final int TYPE_INT5 = 4;     // JSON5 integer (hex / leading 
sign), ASCII
+  private static final int TYPE_FLOAT = 5;    // canonical float, ASCII
+  private static final int TYPE_FLOAT5 = 6;   // JSON5 float, ASCII
+  private static final int TYPE_TEXT = 7;     // raw text, no escapes
+  private static final int TYPE_TEXTJ = 8;    // text with JSON escapes
+  private static final int TYPE_TEXT5 = 9;    // text with JSON5 escapes
+  private static final int TYPE_TEXTRAW = 10; // raw text needing quoting, no 
escapes
+  private static final int TYPE_ARRAY = 11;
+  private static final int TYPE_OBJECT = 12;
+
+  private static final BigInteger INT_MIN = 
BigInteger.valueOf(Integer.MIN_VALUE);
+  private static final BigInteger INT_MAX = 
BigInteger.valueOf(Integer.MAX_VALUE);
+  private static final BigInteger LONG_MIN = 
BigInteger.valueOf(Long.MIN_VALUE);
+  private static final BigInteger LONG_MAX = 
BigInteger.valueOf(Long.MAX_VALUE);
+
+  @Override
+  public boolean matches(byte[] payload, int offset, int length) {
+    // The top-level element must be an OBJECT (low nibble == 12) to produce a 
row. That never collides with
+    // text JSON, whose first byte is '{' (0x7B) or '[' (0x5B) -- both low 
nibble 0x0B (ARRAY), never 0x0C.
+    if (length < 1 || (payload[offset] & 0x0F) != TYPE_OBJECT) {
+      return false;
+    }
+    // The nibble alone is a weak signal: one byte in sixteen of arbitrary 
binary would claim the payload. Also
+    // require the object's declared size to exactly fill the payload -- the 
same validity rule parse() enforces
+    // -- so AUTO cannot hand a corrupt message to this parser on the strength 
of a single nibble.
+    int sizeDescriptor = (payload[offset] & 0xFF) >>> 4;
+    int headerLength = headerLength(sizeDescriptor);
+    if (length < headerLength) {
+      return false;
+    }
+    return declaredSize(payload, offset, sizeDescriptor) == (long) length - 
headerLength;
+  }
+
+  /// Bytes occupied by an element header: the type/size-descriptor byte plus 
the width of any explicit size
+  /// field. Descriptors 0-11 encode the size in the byte itself; 12-15 
prepend a 1-, 2-, 4- or 8-byte
+  /// big-endian size.
+  ///
+  /// Shared by [#matches] and [#readElement] so detection and parsing can 
never disagree about the layout.
+  private static int headerLength(int sizeDescriptor) {
+    switch (sizeDescriptor) {
+      case 12:
+        return 2;
+      case 13:
+        return 3;
+      case 14:
+        return 5;
+      case 15:
+        return 9;
+      default:
+        return 1;
+    }
+  }
+
+  /// Payload size declared by the element header starting at {@code offset}. 
The caller must first ensure
+  /// [#headerLength] bytes are available. A descriptor-15 size with its sign 
bit set yields a negative value,
+  /// which every caller rejects (it can neither be a valid length nor equal a 
payload length).
+  private static long declaredSize(byte[] payload, int offset, int 
sizeDescriptor) {
+    switch (sizeDescriptor) {
+      case 12:
+        return readUnsignedBE(payload, offset + 1, 1);
+      case 13:
+        return readUnsignedBE(payload, offset + 1, 2);
+      case 14:
+        return readUnsignedBE(payload, offset + 1, 4);
+      case 15:
+        return readUnsignedBE(payload, offset + 1, 8);
+      default:
+        return sizeDescriptor;
+    }
+  }
+
+  /// Reads {@code count} big-endian bytes as an unsigned value. An 8-byte 
field with the sign bit set yields a
+  /// negative long, which never equals a payload length and is rejected by 
the bounds checks.
+  private static long readUnsignedBE(byte[] payload, int offset, int count) {
+    long value = 0;
+    for (int i = 0; i < count; i++) {
+      value = (value << 8) | (payload[offset + i] & 0xFFL);
+    }
+    return value;
+  }
+
+  @Override
+  public Map<String, Object> parse(byte[] payload, int offset, int length) {
+    int limit = offset + length;
+    Cursor cursor = new Cursor(payload, offset, limit);
+    Object value = readElement(cursor, limit);
+    if (!(value instanceof Map)) {
+      throw new IllegalArgumentException("Top-level SQLite JSONB element must 
be an object");
+    }
+    // SQLite's validity rule: the outer element must exactly fill the BLOB. 
Without this, a payload whose
+    // top-level element declares a short size (e.g. a bare 0x0C followed by 
data) would decode to a partial --
+    // possibly empty -- row and silently discard the trailing bytes instead 
of rejecting a corrupt message.
+    if (cursor._pos != limit) {
+      throw new IllegalArgumentException(
+          "Top-level SQLite JSONB element consumed " + (cursor._pos - offset) 
+ " of " + length + " bytes");
+    }
+    //noinspection unchecked
+    return (Map<String, Object>) value;
+  }
+
+  /// Reads one element. {@code parentEnd} is the exclusive end of the 
enclosing container (the whole payload at
+  /// the top level); an element may never extend past it, otherwise a nested 
element could overrun its parent
+  /// while still fitting the payload and silently swallow its following 
siblings.
+  private static Object readElement(Cursor cursor, int parentEnd) {
+    int header = cursor.peekUInt8();
+    int type = header & 0x0F;
+    int sizeDescriptor = header >>> 4;
+    int headerStart = cursor._pos;
+    // Decode the header through the same helpers matches() uses, so detection 
and parsing cannot drift apart.
+    cursor.skip(headerLength(sizeDescriptor));
+    long payloadSize = declaredSize(cursor._buf, headerStart, sizeDescriptor);
+    int start = cursor._pos;
+    int end = cursor.boundedEnd(start, payloadSize, parentEnd);
+    Object result;
+    switch (type) {
+      case TYPE_NULL:
+        result = null;
+        break;
+      case TYPE_TRUE:
+        result = Boolean.TRUE;
+        break;
+      case TYPE_FALSE:
+        result = Boolean.FALSE;
+        break;
+      case TYPE_INT:
+        result = parseInt(cursor.ascii(start, end), 10);
+        break;
+      case TYPE_INT5:
+        result = parseInt5(cursor.ascii(start, end));
+        break;
+      case TYPE_FLOAT:
+      case TYPE_FLOAT5:
+        result = parseFloat(cursor.ascii(start, end));

Review Comment:
   Fixed in `f270bee`. `TYPE_FLOAT` now validates against the RFC 8259 number 
grammar (`-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?`) before 
`Double.parseDouble`, so `NaN`, `Infinity`, a leading `+`, and Java hex floats 
are rejected; the permissive parser is kept only for `TYPE_FLOAT5`.
   
   Following the same principle ("permissive only for the *5 types"), I applied 
it to canonical `TYPE_INT` as well, which had the analogous leniency via 
`Long.parseLong`/`BigInteger` (leading `+`, leading zeros). Happy to narrow it 
back to `TYPE_FLOAT` only if you'd rather keep this change scoped to what you 
flagged.
   
   Regressions in `testSqliteCanonicalNumbersRejectNonCanonicalTokens`: 
canonical `NaN`/`Infinity`/`+1.5`/hex-float and `+5`/`007` throw, while 
`testSqliteJson5NumberVariants` confirms `FLOAT5` still accepts 
`NaN`/`Infinity`/`+1.5` and the canonical happy paths still decode.
   
   _🤖 Addressed by [Claude Code](https://claude.com/claude-code)_



##########
pinot-plugins/pinot-input-format/pinot-json/src/main/java/org/apache/pinot/plugin/inputformat/json/format/SqliteJsonbPayloadParser.java:
##########
@@ -0,0 +1,433 @@
+/**
+ * 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.
+ */
+package org.apache.pinot.plugin.inputformat.json.format;
+
+import com.google.common.collect.Maps;
+import java.math.BigInteger;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+
+/// Parses the <a href="https://sqlite.org/jsonb.html";>SQLite JSONB</a> binary 
format (SQLite 3.45+).
+///
+/// Every element is a 1–9 byte header followed by a payload. The header's 
first byte packs the element type in
+/// the low nibble and a payload-size descriptor in the high nibble; 
descriptors 12–15 mean the size is a big-
+/// endian `uint8` / `uint16` / `uint32` / `uint64` in the following bytes, 
and 0–11 are the size itself.
+///
+/// Numbers are stored as their ASCII text, so integers narrow to `Integer` / 
`Long` / `BigInteger` and floats
+/// to `Double`, matching the text-JSON value contract. `TEXTJ` strings/labels 
are JSON-unescaped (invalid
+/// escapes are rejected); `TEXT5` additionally accepts JSON5 escapes.
+///
+/// The decoder expects one row per payload, so the top-level element must be 
an `OBJECT`.
+class SqliteJsonbPayloadParser implements JsonPayloadParser {
+
+  // Element types (low nibble of the header's first byte).
+  private static final int TYPE_NULL = 0;
+  private static final int TYPE_TRUE = 1;
+  private static final int TYPE_FALSE = 2;
+  private static final int TYPE_INT = 3;      // canonical decimal integer, 
ASCII
+  private static final int TYPE_INT5 = 4;     // JSON5 integer (hex / leading 
sign), ASCII
+  private static final int TYPE_FLOAT = 5;    // canonical float, ASCII
+  private static final int TYPE_FLOAT5 = 6;   // JSON5 float, ASCII
+  private static final int TYPE_TEXT = 7;     // raw text, no escapes
+  private static final int TYPE_TEXTJ = 8;    // text with JSON escapes
+  private static final int TYPE_TEXT5 = 9;    // text with JSON5 escapes
+  private static final int TYPE_TEXTRAW = 10; // raw text needing quoting, no 
escapes
+  private static final int TYPE_ARRAY = 11;
+  private static final int TYPE_OBJECT = 12;
+
+  private static final BigInteger INT_MIN = 
BigInteger.valueOf(Integer.MIN_VALUE);
+  private static final BigInteger INT_MAX = 
BigInteger.valueOf(Integer.MAX_VALUE);
+  private static final BigInteger LONG_MIN = 
BigInteger.valueOf(Long.MIN_VALUE);
+  private static final BigInteger LONG_MAX = 
BigInteger.valueOf(Long.MAX_VALUE);
+
+  @Override
+  public boolean matches(byte[] payload, int offset, int length) {
+    // The top-level element must be an OBJECT (low nibble == 12) to produce a 
row. That never collides with
+    // text JSON, whose first byte is '{' (0x7B) or '[' (0x5B) -- both low 
nibble 0x0B (ARRAY), never 0x0C.
+    if (length < 1 || (payload[offset] & 0x0F) != TYPE_OBJECT) {
+      return false;
+    }
+    // The nibble alone is a weak signal: one byte in sixteen of arbitrary 
binary would claim the payload. Also
+    // require the object's declared size to exactly fill the payload -- the 
same validity rule parse() enforces
+    // -- so AUTO cannot hand a corrupt message to this parser on the strength 
of a single nibble.
+    int sizeDescriptor = (payload[offset] & 0xFF) >>> 4;
+    int headerLength = headerLength(sizeDescriptor);
+    if (length < headerLength) {
+      return false;
+    }
+    return declaredSize(payload, offset, sizeDescriptor) == (long) length - 
headerLength;
+  }
+
+  /// Bytes occupied by an element header: the type/size-descriptor byte plus 
the width of any explicit size
+  /// field. Descriptors 0-11 encode the size in the byte itself; 12-15 
prepend a 1-, 2-, 4- or 8-byte
+  /// big-endian size.
+  ///
+  /// Shared by [#matches] and [#readElement] so detection and parsing can 
never disagree about the layout.
+  private static int headerLength(int sizeDescriptor) {
+    switch (sizeDescriptor) {
+      case 12:
+        return 2;
+      case 13:
+        return 3;
+      case 14:
+        return 5;
+      case 15:
+        return 9;
+      default:
+        return 1;
+    }
+  }
+
+  /// Payload size declared by the element header starting at {@code offset}. 
The caller must first ensure
+  /// [#headerLength] bytes are available. A descriptor-15 size with its sign 
bit set yields a negative value,
+  /// which every caller rejects (it can neither be a valid length nor equal a 
payload length).
+  private static long declaredSize(byte[] payload, int offset, int 
sizeDescriptor) {
+    switch (sizeDescriptor) {
+      case 12:
+        return readUnsignedBE(payload, offset + 1, 1);
+      case 13:
+        return readUnsignedBE(payload, offset + 1, 2);
+      case 14:
+        return readUnsignedBE(payload, offset + 1, 4);
+      case 15:
+        return readUnsignedBE(payload, offset + 1, 8);
+      default:
+        return sizeDescriptor;
+    }
+  }
+
+  /// Reads {@code count} big-endian bytes as an unsigned value. An 8-byte 
field with the sign bit set yields a
+  /// negative long, which never equals a payload length and is rejected by 
the bounds checks.
+  private static long readUnsignedBE(byte[] payload, int offset, int count) {
+    long value = 0;
+    for (int i = 0; i < count; i++) {
+      value = (value << 8) | (payload[offset + i] & 0xFFL);
+    }
+    return value;
+  }
+
+  @Override
+  public Map<String, Object> parse(byte[] payload, int offset, int length) {
+    int limit = offset + length;
+    Cursor cursor = new Cursor(payload, offset, limit);
+    Object value = readElement(cursor, limit);
+    if (!(value instanceof Map)) {
+      throw new IllegalArgumentException("Top-level SQLite JSONB element must 
be an object");
+    }
+    // SQLite's validity rule: the outer element must exactly fill the BLOB. 
Without this, a payload whose
+    // top-level element declares a short size (e.g. a bare 0x0C followed by 
data) would decode to a partial --
+    // possibly empty -- row and silently discard the trailing bytes instead 
of rejecting a corrupt message.
+    if (cursor._pos != limit) {
+      throw new IllegalArgumentException(
+          "Top-level SQLite JSONB element consumed " + (cursor._pos - offset) 
+ " of " + length + " bytes");
+    }
+    //noinspection unchecked
+    return (Map<String, Object>) value;
+  }
+
+  /// Reads one element. {@code parentEnd} is the exclusive end of the 
enclosing container (the whole payload at
+  /// the top level); an element may never extend past it, otherwise a nested 
element could overrun its parent
+  /// while still fitting the payload and silently swallow its following 
siblings.
+  private static Object readElement(Cursor cursor, int parentEnd) {
+    int header = cursor.peekUInt8();
+    int type = header & 0x0F;
+    int sizeDescriptor = header >>> 4;
+    int headerStart = cursor._pos;
+    // Decode the header through the same helpers matches() uses, so detection 
and parsing cannot drift apart.
+    cursor.skip(headerLength(sizeDescriptor));
+    long payloadSize = declaredSize(cursor._buf, headerStart, sizeDescriptor);
+    int start = cursor._pos;
+    int end = cursor.boundedEnd(start, payloadSize, parentEnd);
+    Object result;
+    switch (type) {
+      case TYPE_NULL:
+        result = null;
+        break;
+      case TYPE_TRUE:
+        result = Boolean.TRUE;
+        break;
+      case TYPE_FALSE:
+        result = Boolean.FALSE;
+        break;
+      case TYPE_INT:
+        result = parseInt(cursor.ascii(start, end), 10);
+        break;
+      case TYPE_INT5:
+        result = parseInt5(cursor.ascii(start, end));
+        break;
+      case TYPE_FLOAT:
+      case TYPE_FLOAT5:
+        result = parseFloat(cursor.ascii(start, end));
+        break;
+      case TYPE_TEXT:
+      case TYPE_TEXTRAW:
+        result = cursor.utf8(start, end);
+        break;
+      case TYPE_TEXTJ:
+        result = unescape(cursor.utf8(start, end), false);
+        break;
+      case TYPE_TEXT5:
+        result = unescape(cursor.utf8(start, end), true);
+        break;
+      case TYPE_ARRAY:
+        return readArray(cursor, end);
+      case TYPE_OBJECT:
+        return readObject(cursor, end);
+      default:
+        throw new IllegalArgumentException("Reserved/invalid SQLite JSONB 
element type: " + type);
+    }
+    cursor._pos = end;
+    return result;
+  }
+
+  private static List<Object> readArray(Cursor cursor, int end) {
+    List<Object> list = new ArrayList<>();
+    while (cursor._pos < end) {
+      list.add(readElement(cursor, end));
+    }
+    return list;
+  }
+
+  private static Map<String, Object> readObject(Cursor cursor, int end) {
+    Map<String, Object> map = Maps.newHashMapWithExpectedSize(4);
+    while (cursor._pos < end) {
+      Object label = readElement(cursor, end);
+      if (!(label instanceof String)) {
+        throw new IllegalArgumentException("SQLite JSONB object label must be 
text");
+      }
+      if (cursor._pos >= end) {
+        throw new IllegalArgumentException("SQLite JSONB object is missing a 
value for label: " + label);
+      }
+      map.put((String) label, readElement(cursor, end));
+    }
+    return map;
+  }
+
+  private static Object parseInt(String text, int radix) {
+    // Fast path: values that fit a long (the overwhelming majority) avoid 
BigInteger allocation.
+    try {
+      return narrowLong(Long.parseLong(text, radix));
+    } catch (NumberFormatException e) {
+      return narrow(new BigInteger(text, radix));
+    }
+  }
+
+  /// Narrows an integer to the smallest of `Integer` / `Long` / `BigInteger` 
that holds it, mirroring the
+  /// Jackson text-JSON contract (`BigInteger` is later widened to 
`BigDecimal` by the record extractor).
+  private static Object narrow(BigInteger value) {
+    if (value.compareTo(INT_MIN) >= 0 && value.compareTo(INT_MAX) <= 0) {
+      return value.intValue();
+    }
+    if (value.compareTo(LONG_MIN) >= 0 && value.compareTo(LONG_MAX) <= 0) {
+      return value.longValue();
+    }
+    return value;
+  }
+
+  private static Object parseInt5(String text) {
+    String digits = text;
+    boolean negative = false;
+    if (!digits.isEmpty() && (digits.charAt(0) == '+' || digits.charAt(0) == 
'-')) {
+      negative = digits.charAt(0) == '-';
+      digits = digits.substring(1);
+    }
+    Object magnitude;
+    if (digits.length() > 2 && digits.charAt(0) == '0' && (digits.charAt(1) == 
'x' || digits.charAt(1) == 'X')) {
+      magnitude = parseInt(digits.substring(2), 16);
+    } else {
+      magnitude = parseInt(digits, 10);
+    }
+    if (!negative) {
+      return magnitude;
+    }
+    return magnitude instanceof BigInteger ? narrow(((BigInteger) 
magnitude).negate())
+        : narrowLong(-((Number) magnitude).longValue());
+  }
+
+  private static Object narrowLong(long value) {
+    // NOTE: keep these as separate returns. A `cond ? (int) value : value` 
ternary would numerically promote
+    // the int branch to long, always boxing to Long.
+    if (value >= Integer.MIN_VALUE && value <= Integer.MAX_VALUE) {
+      return (int) value;
+    }
+    return value;
+  }
+
+  private static Double parseFloat(String text) {
+    // JSON5 permits a leading '+' and the tokens Infinity / NaN, all of which 
Double.parseDouble accepts once
+    // the '+' is stripped.
+    String normalized = !text.isEmpty() && text.charAt(0) == '+' ? 
text.substring(1) : text;
+    return Double.parseDouble(normalized);
+  }
+
+  /// Single-pass JSON string unescape. Returns the input unchanged (no 
allocation) when it contains no
+  /// backslash. Rejects invalid escapes for `TEXTJ`; when {@code json5} is 
set, also accepts the JSON5 escape
+  /// extensions (`\'`, `\v`, `\0`, `\xHH`, line continuations, and `\<char>` 
passthrough).
+  private static String unescape(String content, boolean json5) {
+    int firstEscape = content.indexOf('\\');
+    if (firstEscape < 0) {
+      return content;
+    }
+    int n = content.length();
+    StringBuilder sb = new StringBuilder(n);
+    sb.append(content, 0, firstEscape);
+    int i = firstEscape;
+    while (i < n) {
+      char c = content.charAt(i++);
+      if (c != '\\') {
+        sb.append(c);
+        continue;
+      }
+      if (i >= n) {
+        throw new IllegalArgumentException("Dangling escape in SQLite JSONB 
text");
+      }
+      char e = content.charAt(i++);
+      switch (e) {
+        case '"':
+          sb.append('"');
+          break;
+        case '\\':
+          sb.append('\\');
+          break;
+        case '/':
+          sb.append('/');
+          break;
+        case 'b':
+          sb.append('\b');
+          break;
+        case 'f':
+          sb.append('\f');
+          break;
+        case 'n':
+          sb.append('\n');
+          break;
+        case 'r':
+          sb.append('\r');
+          break;
+        case 't':
+          sb.append('\t');
+          break;
+        case 'u':
+          sb.append((char) parseHex(content, i, 4));
+          i += 4;
+          break;
+        default:
+          i = appendJson5Escape(sb, content, e, i, json5);
+          break;
+      }
+    }
+    return sb.toString();
+  }
+
+  private static int appendJson5Escape(StringBuilder sb, String content, char 
e, int i, boolean json5) {
+    if (!json5) {
+      throw new IllegalArgumentException("Invalid JSON escape '\\" + e + "' in 
SQLite JSONB text");
+    }
+    switch (e) {
+      case '\'':
+        sb.append('\'');
+        return i;
+      case 'v':
+        sb.append('\u000B');
+        return i;
+      case '0':
+        sb.append('\0');
+        return i;
+      case 'x':
+        sb.append((char) parseHex(content, i, 2));
+        return i + 2;
+      case '\n':
+        return i; // line continuation
+      case '\r':
+        return i < content.length() && content.charAt(i) == '\n' ? i + 1 : i; 
// CRLF continuation
+      default:
+        sb.append(e); // \<char> -> char
+        return i;
+    }
+  }
+
+  private static int parseHex(String content, int start, int len) {
+    if (start + len > content.length()) {
+      throw new IllegalArgumentException("Truncated \\u / \\x escape in SQLite 
JSONB text");
+    }
+    int value = 0;
+    for (int i = start; i < start + len; i++) {
+      int digit = Character.digit(content.charAt(i), 16);
+      if (digit < 0) {
+        throw new IllegalArgumentException("Invalid hex escape in SQLite JSONB 
text");
+      }
+      value = (value << 4) | digit;
+    }
+    return value;
+  }
+
+  /// Sequential reader over a bounded region of a byte array. Multi-byte size 
fields are big-endian per the
+  /// SQLite JSONB spec.
+  private static final class Cursor {
+    private final byte[] _buf;
+    private final int _limit;
+    private int _pos;
+
+    private Cursor(byte[] buf, int start, int limit) {
+      _buf = buf;
+      _pos = start;
+      _limit = limit;
+    }
+
+    /// The next byte, without advancing.
+    private int peekUInt8() {
+      if (_pos >= _limit) {
+        throw new IllegalArgumentException("Truncated SQLite JSONB payload");
+      }
+      return _buf[_pos] & 0xFF;
+    }
+
+    /// Advances past {@code count} bytes, verifying they are present. 
Compares without adding to {@code _pos}
+    /// so the check cannot overflow.
+    private void skip(int count) {
+      if (count > _limit - _pos) {
+        throw new IllegalArgumentException("Truncated SQLite JSONB payload");
+      }
+      _pos += count;
+    }
+
+    /// Validates that a payload of {@code size} bytes starting at {@code 
start} fits inside the enclosing
+    /// container (never merely inside the whole payload) and returns its 
exclusive end. Compares without adding
+    /// to {@code start} so an adversarial `uint64` size cannot overflow past 
the guard. A {@code start} already
+    /// past {@code parentEnd} — possible when a trailing element's header 
straddles the boundary — makes
+    /// {@code parentEnd - start} negative and is therefore rejected too.
+    private int boundedEnd(int start, long size, int parentEnd) {
+      if (size < 0 || size > parentEnd - start) {
+        throw new IllegalArgumentException("SQLite JSONB element size exceeds 
its enclosing container");
+      }
+      return start + (int) size;
+    }
+
+    private String ascii(int start, int end) {
+      return new String(_buf, start, end - start, StandardCharsets.US_ASCII);
+    }
+
+    private String utf8(int start, int end) {
+      return new String(_buf, start, end - start, StandardCharsets.UTF_8);

Review Comment:
   Fixed in `f270bee`. Text is now decoded with a `CharsetDecoder` configured 
`onMalformedInput(CodingErrorAction.REPORT)` / `onUnmappableCharacter(REPORT)` 
instead of `new String(..., UTF_8)`, so invalid UTF-8 throws 
`IllegalArgumentException` and fails the record rather than substituting U+FFFD 
into a field name or value.
   
   Regression in `testSqliteTextMustBeValidUtf8`: valid multibyte UTF-8 (`é` = 
`0xC3 0xA9`) still decodes, while a lone `0x80` in either a value or a field 
name is rejected.
   
   _🤖 Addressed by [Claude Code](https://claude.com/claude-code)_



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to